OpenAIは、ChatGPTのデータ基盤で発生していた原因不明のクラッシュを、「疫学」の考え方を応用した分析で解明したと明らかにしました[1]。調査の結果、まったく別々の2つのバグが偶然同時に見つかり、そのうち1つは広く使われるオープンソースのライブラリに18年前から潜んでいたものでした[1]。
「あり得ない」謎のクラッシュ
OpenAIのモデルやエージェントは、推論時に関連データを検索するため、拡張性の高いデータ基盤に依存しています[1]。その一部は性能とメモリ効率を最大化できるC++で書かれていますが、C++にはメモリ安全性がないため、誤ったメモリ番地への書き込みがクラッシュを引き起こすことがあります[1]。
問題が起きたのは、ChatGPTのデータ基盤の中核を担う「Rockset」というサービスでした[1]。Rocksetは検索とリアルタイム分析のためのクラウドネイティブなデータシステムで、OpenAIが2024年に買収しています[2]。ChatGPTが会話やワークスペースの知識ベースを検索する際に使われています[1]。
数か月前、このRockset内部で奇妙なクラッシュが観測されました[1]。通常のC++関数が処理を終えた後、でたらめな番地に戻ろうとして、カーネル(OSの中核)がプログラムを停止させるという現象です[1]。関数の戻り先アドレスがNULL(空)になっていたり、スタックポインタ(%rsp、スタックの現在位置を指すCPUのレジスタ)が8バイトずれていたりと、通常のアプリケーションでは起こり得ない壊れ方をしていました[1]。考えられる仮説にはすべて強い反証があり、担当チームには「あり得ないバグ」に見えたといいます[1]。
「医者」から「疫学者」への発想の転換
当初チームは、少数のコアダンプ(クラッシュした瞬間のメモリの状態を保存したファイル)を精査して仮説を立て、一つずつ潰していく従来型のデバッグを試みました[1]。しかし戻り先の探索範囲が膨大で、ログからクラッシュを分類しようとしても、記録されたスタックトレース自体が壊れているため正確に絞り込めませんでした[1]。
行き詰まったチームは、アプローチを大きく切り替えます。1人の患者を深く診る「医者」のやり方から、集団全体を見てパターンを探す「疫学者」のやり方へ、という発想です[1]。特定のリリースで始まったのか、特定のハードウェアや地域、カーネルのバージョンと相関するのか、複数の異なる集団が1つの症状に見えているだけではないか。こうした問いを立て、まずは質の高いデータを集めることにしました[1]。
転機になったのは、ChatGPTにスクリプトを書かせ、過去1年分の本番環境のコアダンプをすべて自動で分析したことでした[1]。各コアダンプの先頭部分をダウンロードしてレジスタを取り出し、既知の誤検出を除外したうえで、クラッシュを「NULLへの復帰」「スタックの不整合」「その他」に自動で分類したのです[1]。
正体は2つの別々のバグだった
きれいなデータセットができると、相関はすぐに現れました[1]。1つの奇妙なバグだと思っていたものが、実は2つの別々のクラッシュ集団だったのです[1]。
「スタックの不整合」型は1つの地域に集中し、明確な開始時期があり、稼働時間の長いノードでは起きていませんでした[1]。追跡の結果、1台の物理マシンのハードウェア不良が原因と分かり、そのホストを利用対象から外すとクラッシュは消えました[1]。CPUが計算を正しく実行しない「サイレントなハードウェア破損」だったわけです[1]。
もう一方の「NULLへの復帰」型は、複数のクラスターや地域に散らばっていました[1]。ハードウェア要因を切り分けたことで、残ったクラッシュはすべてC++の例外巻き戻し(exception unwinding、例外の発生時に適切な後処理や受け皿へ制御を移す仕組み)の最中に起きていたと判明します[1]。
18年間眠っていたlibunwindの競合状態
OpenAIのバイナリは、例外巻き戻しを担う「GNU libunwind」と「libgcc」という2つのライブラリとリンクしており、実際に使われていたのはGNU libunwindの方でした[1]。
原因は、このGNU libunwind内部の処理にありました[1]。復帰先のレジスタ状態を保持する構造体をスタック上に作り、内部の _Ux86_64_setcontext という処理に渡すのですが、この処理はスタックポインタ%rspを書き換えた後で、その構造体から命令ポインタ(次に実行する命令の位置を指す値)を読み出していました[1]。%rspを書き換えた瞬間、その構造体はもう「使用中のスタック」の外側に出てしまいます[1]。
ここにシグナル(実行中のプログラムへ割り込みをかける通知)が届くと、カーネルが%rspの少し下に処理用の領域を確保し、まだ読み出していない構造体を上書きしてしまう恐れがあります[1]。その結果、復帰先の命令ポインタがNULLに壊れていた、というのが真相でした[1]。この競合状態(race condition、処理のタイミング次第で結果が変わってしまう不具合)の危険な窓は、なんと命令1つ分。現代のCPUではおよそ100ピコ秒(1ピコ秒は1兆分の1秒)しかありませんでした[1]。
このバグは、x86_64でC++の例外巻き戻しに対応した最初のバージョンから存在しており、18年以上も誰にも気づかれずにいたものでした[1]。
なぜ今になって表面化したのか
18年も潜んでいたバグが今になって現れたのは、Rocksetがこの問題を起こしやすい条件を3つとも満たしていたためです[1]。過負荷を抑える仕組みとして例外を高頻度で投げること、CPU時間を細かく計測するためにSIGUSR2というシグナルを頻繁に送っていること、そして今年に入ってそのシグナル処理が使うスタックの量が増えたことです[1]。この3つの積が、ようやく問題が表面化する閾値を超えたと説明しています[1]。
対処として、OpenAIは例外巻き戻しをGNU libunwindからlibgccの実装へ切り替えました[1]。これは大規模環境でのロック競合の面でも有利な選択だったといいます[1]。あわせて、再現用のコードと修正をGNU libunwind本体へ報告(アップストリーム)しています[1]。さらに、コアダンプがなくてもログだけで再発を検知できるようシグナル処理を改良し、不良ノードを見つけやすくする運用面の変更も加えました[1]。
まとめ
OpenAIがこの調査から得た最大の教訓は、巧みなアセンブリ解析や低レイヤーの深い知識よりも、「質の高いデータセットを作ること」が最も重要だったという点です[1]。2つの現象を1つの物語に混ぜて推論している間は解けなかった問題が、集団全体の正確なデータを得た途端に構造がはっきり見えたといいます[1]。信頼性とはバグを直すことだけでなく、解けない問題を診断可能な問題へ変えるためのデータと仕組みを整えることでもある。地味ではありますが、大規模システムを運用するうえで示唆に富む事例です。
出典:https://openai.com/index/core-dump-epidemiology-data-infrastructure-bug
