OpenAIのエンジニアは、ChatGPTの検索やデータプラグインを支えるC++データインフラサービス「Rockset」で発生する謎のクラッシュを、数週間にわたり調査しました。関数が不正なメモリアドレスへ戻ったり、スタックポインタが実行中に8バイトずれたりする現象が観測され、チームが考えられるあらゆる仮説には反証となる強力な証拠が揃っていました。バグは不可能に思えました。

1つのバグだと考えていたものは、実は偶然同時に発見された2つの無関係なバグでした。突破口となったのは、個々のクラッシュを深く調べるのではなく、チームが「疫学的デバッグ」と呼ぶ手法に切り替えたことでした。つまり、過去1年間のすべての本番コアダンプを自動的に分析するパイプラインを構築し、個々の事例ではなく母集団レベルのパターンを探す方法です。

チームはChatGPTにスクリプトを書かせ、各コアファイルのプレフィックスをダウンロードしてレジスタを抽出し、既知の偽陽性を除外した上で、各クラッシュを「return-to-null」「misaligned-stack」「その他」のいずれかにラベル付けさせました。このスクリプトを前年のRocksetコアダンプすべてに対して並列実行したところ、相関関係が即座に現れました。1つの症候群に見えていたものは、実際には全く異なるシグネチャを持つ2つの異なるクラッシュ母集団でした。

misaligned-stackクラッシュはすべて1つのAzureリージョンから発生しており、明確な開始日があり、長時間稼働するノードでは一切出現しませんでした。調査の結果、CPUが静かに誤った結果を生成する1台の物理ホストに起因することが判明しました。過熱やマシンチェック例外は発生しておらず、ただ静かに計算を誤っていたのです。そのホストをサービスから外すと、misaligned-stackクラッシュは完全に消失しました。

ハードウェアによるクラッシュを切り分けたことで、残ったreturn-to-nullクラッシュは解決可能になりました。チームは以前、C++例外アンウィンドが原因ではないと判断していましたが、それは例外を使用しないコードパスでもクラッシュが発生していたためです。しかし、それらの反例はすべてハードウェア破損クラスタ由来のものでした。その混入を除去すると、残るクラッシュはすべて例外アンウィンド中に発生していました。

根本原因は、18年前から存在していたGNU libunwindの_Ux86_64_setcontext関数内のレースコンディションでした。C++例外アンウィンド中、libunwindはスタック上にucontext_t構造体を合成し、希望するレジスタ状態を書き込んだ後、_Ux86_64_setcontextを呼び出してクリーンアップハンドラへ制御を移します。問題は、_Ux86_64_setcontextがスタックポインタ(%rsp)を新しいスタックフレームへ更新した後で、古い構造体から命令ポインタを読み込み終えるという点です。%rspが変更された瞬間、構造体はアクティブスタックの一部ではなくなり、カーネルのレッドゾーン保証の保護も失われます。この%rsp更新と%rip読み取りの間のわずかな窓でシグナルが到着すると、カーネルがシグナルフレームを構造体の上に構築し、命令ポインタを破損させます。その結果、関数はNULLやゴミへジャンプします。

レース窓はちょうど1命令分です。現代のクロック速度では、およそ100ピコ秒に相当します。ほとんどのプログラムでは決してトリガーされません。しかしOpenAIのRocksetはtimer_createを使ってCPU時間数ミリ秒ごとにSIGUSR2シグナルを配信し、軽量なクエリごとのアカウンティングを行っているため、典型的なアプリケーションよりはるかに多くのシグナル配信イベントが発生します。この頻度が、理論上はあり得るレースを本番クラッシュに変えていました。

チームは修正と自己完結型の再現コードをGNU libunwindへupstreamし、他のアンウィンダー(libgcc)には同じ問題がないことを確認しました。修正では、%ripを読み取ってから%rspを更新するよう命令順序を変更し、窓を完全に排除しています。

チーム自身による教訓の要約は、以下のように全文引用する価値があります。

最も重要なステップは、巧妙なアセンブリ解析や詳細な知識ではありませんでした。高品質なデータセットを構築することでした。このデータセットがなければ、2つの異なる現象を1つの物語に混ぜてしまい、混乱から抜け出そうとしていました。正確で完全な母集団データを得た瞬間、問題の構造が明らかになりました。

説明不能な本番クラッシュをデバッグするすべてのチームへ:複数のバグを混同していないか確認してください。あらゆる仮説と矛盾するように見える症状は、実は矛盾していないかもしれません。たまたま混ぜてしまっている2つの異なる仮説と一致している可能性があります。構造を最も早く見つける方法は、個々の事例を深く分析することではなく、失敗の全母集団にわたる完全でラベル付けされたデータです。

完全なエンジニアリングブログ記事には、詳細なスタック図、脆弱なアセンブリ命令、および2つの母集団を明らかにしたクラッシュ率の可視化が含まれています。

About the Author

Steef-Jan Wiggers