OpenAI 的模型和智能代理愈來愈依賴可擴展的數據基礎架構,以便在推理時搜尋相關數據:也就是模型正在思考你的問題時。其中一些服務以 C++ 編寫;C++ 對系統的低層控制讓我們能盡量提升效能並減少記憶體用量。隨着我們擴展規模,這些效率優勢十分重要;但 C++ 缺乏記憶體安全,意味錯誤可能會寫入不正確或不存在的記憶體位址而導致當機。
幾個月前,我們觀察到 Rockset 服務內部出現當機問題。Rockset 是我們 ChatGPT 數據基礎架構中的定製部分,對許多數據外掛程式和對話搜尋都很關鍵。在每次當機中,一個普通的 C++ 函數看似完成後,卻返回到一個錯誤位址,導致核心停止程式,因為指令指標已不再指向程式碼。有時堆疊框架中的返回位址槽位是 NULL。有時堆疊指標 CPU 暫存器本身看起來偏移了 8 個位元組,彷彿 %rsp 不知為何在正常執行中途被遞減了。兩種情況下,當機都發生在返回時。
這些都不是應用程式碼的正常失效模式。一次意外寫入剛好只落在已儲存的返回位址上並非不可能,但機率極低。若沒有涉及內嵌組合語言、setcontext 或 longjmp(我們全都沒有使用),卻有錯誤令 %rsp 偏移 8,這更奇怪,因為編譯後的程式碼只會在函數序言和尾聲直接調整該暫存器。我們(或 ChatGPT)想到的每個假設都有強力證據反駁,因此這個錯誤看起來像是不可能發生。
我們原以為只有一個問題,最後卻發現是兩個互不相關的錯誤、只是剛好同時被發現。第一,是一台 Azure 主機上的無聲硬件損壞,CPU 根本沒有正確運算。第二,是 GNU libunwind 中一個存在了 18 年的競態條件;這是一個廣泛使用的開源程式庫中一直未被察覺的錯誤。
本文講述我們如何像流行病學家一樣思考,並建立涵蓋整個當機群體的高質素數據集,從而識別並修復看似無法解釋的當機。
首先,讓我們更深入看看 Rockset。這是一個雲端原生數據系統,用於搜尋和即時分析;我們在 OpenAI 的許多內部用途都使用它,例如同步連接器(Rockset 於 2024 年被 OpenAI 收購)。串流更新用於維持工作區知識庫的最新索引,讓 ChatGPT 在回答問題或執行動作時可以搜尋相關資料。
Rockset 的執行層以 C++ 編寫。C++ 語言提供對 CPU 的低層存取,有利效能和效率,但也意味應用程式錯誤可能導致無效記憶體存取和分段錯誤。為協助追查這些問題,我們使用 folly 的致命訊號處理器,在當機發生時記錄堆疊追蹤,並將相應的核心傾印(程式當機時狀態的快照)上載到 Azure blob 儲存體,以便日後分析。Rockset 的所有查詢處理葉節點都有複本,從而盡量降低當機對客戶端的影響。不過,每次分段錯誤都對應一個需要修復的錯誤,才能達到我們的可靠性和質量目標。
我們最初的方法,是把這些核心傾印當作傳統除錯問題處理:非常仔細地檢查幾個核心傾印、提出假設,然後逐一排除。
大部分當機都發生在名為 DocumentTree::updateDocument 的方法中。在這些當機中,情況看來是 updateDocument 呼叫了某個未知函數 X,X 執行期間堆疊遭到損壞,然後 X 返回到一個並非可執行程式碼的位址。在某些情況下,X 剛被彈出的框架看似有效,只是其已儲存返回位址為 NULL。在其他情況下,堆疊指標本身看似不對,但下一個有效框架似乎仍是 updateDocument。
我們不知道堆疊是何時遭到損壞的,這令搜尋空間變得非常龐大。updateDocument 是一個大型方法,會進行大量內聯,因此 X 的候選項多得令人難以招架。
這是我們 C++ 程式碼中的錯誤嗎?是編譯器或連結問題嗎?是我們某個執行階段程式庫的問題嗎?是 Linux 核心在訊號傳遞或內容切換方面的錯誤嗎?還是更罕見的東西?如果這是一次意外寫入,為何沒有被我們的 ASAN 模擬環境捕捉到?
我們嘗試使用應用程式層級日誌來識別所有問題個案,但堆疊損壞錯誤很難單靠日誌分類,因為記錄下來的堆疊追蹤本身已損壞或缺失。我們無法構建一個同時沒有假陽性和假陰性的日誌查詢。我們手動檢查了更多核心傾印,並找到一些額外例子,但這個過程太費人力,無法提供可信的數據集。
在調查的這個階段,我們(錯誤地)排除了硬件錯誤,因為我們在多個區域和多種硬件類型上都觀察到當機,所以仍在尋找純軟件原因。有幾天,我們非常深入地研究一次 %rsp 未對齊的當機,利用堆疊和暫存器內容重建當機前的歷史。這帶來一些可能的線索,但由於我們沒有放下「所有錯誤都有同一原因」這個初步結論,因此始終未能打破僵局。
在談到調查的轉捩點之前,有必要先解釋我們從核心檔案中提取的是甚麼資訊。
Rockset 以 -fno-omit-frame-pointer 編譯,因此始終可以透過 %rbp 存取活動堆疊框架,而呼叫端會形成一條框架指標的鏈結串列。
在 Linux x86_64 上,AMD64 System V ABI 還會在 %rsp 以下保留 128 個位元組作為 Red zone。該區域可供使用者空間程式碼使用;更重要的是,作為 ABI 契約的一部分,核心承諾在傳遞訊號時不會覆寫它。
Red zone 是我們除錯返回後當機的關鍵,因為它保留了返回前的一些資訊。當觸發 SIGSEGV 時,folly 的致命訊號處理器會在當機執行緒的堆疊上執行。不再活動的堆疊框架(因為其函數已返回)會被訊號處理器覆寫,最後 128 個位元組除外。因此我們才會說「X 剛被彈出的堆疊框架看似有效,只是返回位址為 NULL」這類話。Red zone 會保留部分非活動框架,有時只保留某個非活動框架的尾部。
我們找到一次堆疊未對齊的當機,當中涉及的所有函數都非常小。這讓我們看到 %rsp 是在一個相對簡單的函數執行期間變得未對齊,而且之後還有更多呼叫成功完成。程式只在活動函數最後嘗試返回時才當機。這些程式碼路徑都沒有使用例外、內嵌組合語言、setcontext 或 longjmp,因此如果堆疊指標真的如核心傾印所示般改變,就沒有任何合理的使用者空間程式碼錯誤能解釋此事。
這把我們推向了核心層面。
Rockset 使用訊號的方式比大多數程式更進取。查詢執行會被拆分成許多交換數據的輕量工作。這對高效處理高 QPS 工作負載很重要,但也令每個查詢的 CPU 計算變得棘手,因為多個查詢的工作會多工到同一個執行緒池上。
我們的解法稱為 coarse_thread_cputime_clock,它能以足夠低的成本近似 clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...),可在每個工作邊界取樣。timer_create API 可用於根據多種時間流逝概念安排定期訊號傳遞,包括 CPU 時間的累積。我們安排每隔數毫秒 CPU 時間傳遞一次訊號 (SIGUSR2),屆時訊號處理器會更新一個執行緒本地值。即使許多工作在執行期間沒有看到粗略時鐘前進,把所有差值加總後,仍能得到查詢實際 CPU 時間的無偏估計。
由於我們如此頻繁地傳遞訊號,內容切換或訊號傳遞方面的罕見核心錯誤看來是合理可能。我們花時間閱讀錯誤報告、核心原始碼,以及 Azure 專用的核心修補程式。我們嘗試過壓力測試。但找不到任何看似相關的東西。
到那一刻,我們決定退一步,嘗試另一種方法。
除錯這類問題,大致有兩種方式。
一種是像某種醫生一樣:專注於一名病人,做大量測試,並嘗試從詳細證據診斷單一病例。
另一種則更像流行病學家:觀察整個群體,詢問是否存在單一病例無法揭示的模式。這個錯誤是否從某個特定版本開始?它是否與某一硬件 SKU(特定 CPU 和伺服器型號)、某一區域或某一核心版本相關?在看似同一種症候群之中,是否隱藏着多個不同叢集?
我們此前大多處於醫生模式。關鍵轉變,是我們決定需要收集高質素的群體數據。
我們先前嘗試自動找出所有問題案例都失敗,因為我們一直試圖在日誌上做文字搜尋。核心傾印本身包含的資訊多很多,但手動查看無法擴展。我們決定投入精力建立一條可自動分析核心傾印的管道。
我們讓 ChatGPT 編寫一個腳本,下載每個核心檔案的前綴、提取暫存器、利用日誌過濾已知假陽性,並自動把當機標記為 Return-to-null、Misaligned-stack 或其他。然後,我們將該腳本並行套用到前一年所有生產環境的 Rockset 核心傾印。
這就是轉捩點。
一旦有了乾淨的數據集,相關性立即浮現。我們一直視為同一個奇怪錯誤的現象,其實是兩個不同的當機群體。
Return-to-null 核心傾印分散於許多叢集和地理區域。它們的頻率最近有所上升,但沒有明確的開始日期,也沒有清晰的基礎架構邊界。
Misaligned-stack 當機看起來則完全不同。它們全都來自同一區域,有明確開始日期,而且從未發生在已長時間運行的節點上。即使它們涉及多台 Azure VM(雲端託管的虛擬機器),其模式看起來像是一台硬件故障的實體機器,導致剛好落在上面的任何 VM 都會出現問題。
就在那一刻,我們意識到自己在思考上把兩個錯誤混為一談。由於我們一直把兩個錯誤的反例混在一起,所以找不到單一而連貫的解釋。
有了乾淨的 Kubernetes 節點清單和時間戳,我們便能把堆疊未對齊的當機追溯到單一實體主機,而將其加入拒絕清單亦很容易。
即使經過數星期壓力測試,我們也無法在受控環境中重現該主機上的暫存器損壞。然而,一旦問題主機停用,堆疊未對齊的當機便消失了。
移除故障主機並非永久解決方案,因為它不能防止同一問題再次出現。不過,我們可以修改軟件,令類似問題一旦復發,也能容易地被偵測和處理。我們改進了致命訊號處理器,加入暫存器狀態,讓我們只憑日誌就能偵測復發(無需核心傾印)。我們更改了控制平面,令 VM 通常被重用而非回收;在我們所處的基礎架構堆疊層級,這讓偵測故障節點容易得多。我們也更新了執行手冊(以及團隊的思維模型),納入這種可能性。
把故障主機造成的當機分離出來後,餘下的 Return-to-null 核心傾印就容易理解得多。早前我們曾排除例外展開,因為以為有反例:當機發生在肯定沒有使用例外的程式碼路徑中。但那些反例全都來自硬件損壞叢集。
當我們帶着這點重新檢視餘下核心傾印時,發現這個結論剛好相反:所有當機都發生在例外展開期間。
當 C++ 拋出例外時,執行階段必須找出哪個 Catch 區塊應接收它,以及途中哪些解構函數或清理處理器應該執行。編譯器會產生這些中繼資料,但實際配對會在執行階段動態進行。
例外展開其實不是由呼叫 throw 的函數執行,而是由所產生的編譯後程式碼呼叫輔助函數來完成。這些執行階段例程會檢查堆疊、擷取堆疊上函數的中繼資料、動態尋找清理處理器和 Catch 區塊,然後把控制轉移到其中一個位置。轉移控制包括展開所有介乎其間的堆疊框架(包括輔助函數的框架)。
在操作上,這更接近 longjmp 或 fiber 切換,而不是普通的呼叫和返回。除了被呼叫端儲存暫存器必須還原,堆疊框架暫存器 %rbp 和 %rsp 也必須還原。
我們的二進制檔連結到兩個包含 C++ 例外展開函數實作的程式庫:libgcc 和 GNU libunwind。動態連結器選用了 GNU libunwind 的定義。這令我們頗感意外;我們原以為基於符號版本規則,libgcc 的實作會勝出;然而檢查運行中的二進制檔後,發現並非如此。
此時,隨着我們放寬另一個在以為只有一個錯誤時所作的假設,工作假設也隨之改變。
也許我們看到的不是普通函數返回到 NULL。也許我們看到的是一次展開轉移,實質上是 setcontext 風格的暫存器還原,而目的地指令指標在控制轉移前已變成 NULL。換言之,問題在於展開程式庫的錯誤數據,而不是堆疊上的返回位址槽位錯誤。
這大幅縮小了問題範圍。要麼 GNU libunwind 計算了錯誤的目標狀態,要麼它計算了正確狀態,但在套用之前被某些東西損壞。
我們閱讀 GNU libunwind 原始碼,發現它會在堆疊上合成一個 ucontext_t,填入清理處理器框架所需的暫存器狀態,然後把指向該結構的指標交給一個內部組合語言例程:_Ux86_64_setcontext。
到這裏,我們已掌握所有拼圖。
合成的 ucontext_t 位於其中一個堆疊框架內,而該框架會在 _Ux86_64_setcontext 執行期間被展開。_Ux86_64_setcontext 是否在更改 %rsp 之後仍從該結構讀取,而此時該結構已不再屬於活動堆疊?若是如此,這會令它容易被訊號傳遞覆寫,例如我們頻繁傳遞的 SIGUSR2。
答案是肯定的。
以下是我們所用 GNU libunwind 版本中 _Ux86_64_setcontext 的最後六條指令,主要由把記憶體載入目的地暫存器的 mov 指令組成:
(%rdi 指向配置在堆疊上的 ucontext_t,而 UC_MCONTEXT_* 巨集只是展開為特定暫存器儲存位置的固定偏移。)
第一條指令就是競態窗口的開始。它會更新 %rsp,使其指向活動堆疊的新底部。一旦發生這件事,%rdi 指向的結構就不再屬於活動堆疊(或 red zone),也不再是核心不可觸碰的範圍。
通常這不會造成問題,但如果訊號在剛好正確(或者說,剛好錯誤?)的時刻到達,核心會在 %rsp-128 建立訊號框架。這可能覆寫 %rdi 所指向的記憶體。
如果這發生在下一條指令讀取 UC_MCONTEXT_GREGS_RIP(%rdi) 之前,還原後的指令指標就可能被損壞。在我們的當機中,它變成了 NULL。
這就是那個錯誤。
這段組合語言也解釋了其中一個令我們困惑的觀察:為何函數 X 在前一個堆疊框架的返回位址槽位中有一個 NULL。
setcontext 的設計是還原所有暫存器,包括 %rdi,因此在控制轉移的最後一刻,它不能使用該暫存器來讀取 UC_MCONTEXT_GREGS_RIP(%rdi)。相反,它會較早讀取該值,將其儲存到堆疊,還原更多暫存器,然後使用 retq 讀取已儲存的值並轉移控制。
核心傾印中看似「一個函數返回到 NULL」,實際上是「unwinder 在堆疊上合成了一個目標返回位址,但該目標在轉移完成前已被損壞」。我們原以為返回位址槽位的損壞必定是原地發生,因為我們不知道有任何地方會刻意把(可被損壞的)數據寫入返回位址槽位。
這個錯誤之所以顯得荒謬,是因為競態窗口極其狹窄。在這類競態條件中,外部事件(訊號)必須發生在另一個執行緒所採取的兩個步驟之間。這些步驟彼此愈接近,競態條件發生的機率就愈低。
在這個案例中,脆弱窗口真的只有一條指令那麼寬!訊號必須在 %rsp 已被更改之後、下一條指令載入 %rip 之前傳遞。在現代超純量亂序 CPU 上,每個週期可執行數條這類簡單指令,因此競態窗口約為一百皮秒。
我們發現這個競態時,第一反應是它必定太罕見,不足以解釋觀察到的當機率。我們在整個機群每天看到十多次 Return-to-null 當機。例外清理期間一個單一指令的競態,真的能解釋這一點嗎?
我們轉向費米估算。如果脆弱窗口約為 秒,而 SIGUSR2 每 秒 CPU 時間到達一次,那麼每個例外清理處理器或 Catch 區塊約有 的機率輸掉這場競態。
Rockset 把例外用作其內部擷取反壓機制的一部分。單一過載主機每秒可拋出約 個例外。這意味使用反壓的主機平均故障間隔為 秒,也就是每隔數小時一次當機。以機群規模而言,這已足以解釋觀察到的當機頻率。
GNU libunwind 的錯誤已有 18 年以上歷史,存在於首個支援 C++ 例外展開的 x86_64 版本中。
那麼,為何它現在才浮現?
當機率大致與拋出多少例外和傳遞多少訊號成正比。它也取決於訊號處理器消耗多少堆疊。
Rockset 在這三個軸向都不尋常。我們將高頻率拋出例外作為正常過載控制的一部分;因為 coarse_thread_cputime_clock,我們異常頻繁地傳遞 SIGUSR2;而今年較早前,我們在 SIGUSR2 處理器加入對 timer_getoverrun 的呼叫,以便計算合併訊號,令它使用更多堆疊。
最後這項變更似乎很重要。如果處理器使用的堆疊足夠少,可能不會觸及並覆寫陳舊的 ucontext_t 記憶體。在那項變更之前,我們完全沒有觀察到這些當機。變更之後,當機率仍然很低,直到我們為一些會施壓反壓機制的用途提升負載。
換言之,libunwind 的錯誤一直存在,只是我們的例外率、訊號率和處理器堆疊用量的乘積,最近才跨過了在營運上可見的門檻。
這個機制也解釋了一個巧合:硬件錯誤和 libunwind 錯誤的當機,大多都發生在 DocumentTree::updateDocument 內。來自 libunwind 的當機高度集中於此方法,因為我們拋出例外以施加擷取反壓時,它總是處於活動狀態。%rsp 未對齊的當機也高度集中於此,因為故障硬件節點屬於我們用於大量擷取的 SKU,而這類工作會把大部分 CPU 時間花在該方法中。
我們的即時緩解措施,是從 GNU libunwind 切換到 libgcc 的 unwinder。這本身就是一個不錯的取捨:libgcc 的實作受惠於大量減少鎖競爭的工作,這在擴展至大型 VM 時很重要。
我們亦向 GNU libunwind 上游提交了一個自包含重現程式和一個修正(在新視窗中開啟),並確認其他 unwinders 沒有類似問題。
這段除錯旅程讓我們學到許多關於動態連結、DWARF 展開中繼資料、Linux 訊號傳遞、System V ABI,以及 C++ 例外機制的具體細節。但最主要的教訓比這些都簡單。
最重要的一步,不是巧妙地閱讀組合語言,也不是對細節有深入知識,而是建立一個高質素數據集。沒有這個數據集時,我們把兩個截然不同的現象混為一談,並試圖憑推理擺脫混亂。一旦有了準確而完整的群體數據,問題結構便變得明顯:其中一個當機群體屬於故障主機,另一個則屬於 libunwind 中的競態。數據愈完善,除錯就愈容易。
對 Rockset 這類基礎架構系統而言,這一點非常重要。這次調查強化了我們對深入儀器化、自動化調查,以及持續改進營運工具的承諾。可靠性不只是問題發生後修復錯誤;它還關乎建立數據、工作流程和技能,把看似無法解決的問題轉化為可診斷、可解決的問題。
作者
By Nathan Bronson及Member of Technical Staff