GoのARM64コンパイラでバグを発見!
引用元:https://news.ycombinator.com/item?id=45516000
ARMアセンブリを読めるってすごい発見だね!デバッグパスも納得。IRでもできそうだけど、そうしない良い理由があるんだ。スタックサイズをpush/popしてメモリアクセスと引き換えに命令を節約する方法もあるかも?GoのGCがどう見てるか分からないけど、みんなの意見聞きたいな。
ARMアセンブリが読めると良いよね。でも、$記号を使った変な記述は普通のAArch64アセンブリじゃないよ!記事で「スタックは一度しか動かない」ってルールに触れても良かったかもね。
「スタックは一度しか動かない」ってルールは聞いたことないな。C/C++では強制されてるの見たことないし、Google AIに複雑なC関数作らせてgodboltで見ても、push/popが頻繁に行われてるよ。GoみたいなGCには関係するかもね。
このバグがRSPへの即値加算の特殊ケースとしてアセンブラで修正されなかったのが少し意外。コンパイラだけのパッチなら、AArch64アセンブリコードに他のバグが潜んでる可能性もあるよね。
それって賢明かな?実装された解決策は、RSPに加算する値を一時レジスタに保持してるよね。Goアセンブラの使われ方は詳しくないけど、add $imm, rsp, rsp
で$immが十分大きい場合に無関係なレジスタが破壊されたら、すごく驚くと思うな。特に破壊されるのが手書きGoアセンブリでよく使われる一時レジスタだったらね。
いくつかのアーキテクチャ、AArch64もそうだと思うけど、アセンブラが必要とする特別な状況で上書きされるためのスクラッチレジスタが予約されてるんだよ。
Javaや.NETのようなランタイムでは、命令セットの途中でコンテキスト変更を避けるためにセーフポイントがあるのが普通だよ。
最適化ありでコンパイルした?GCCだと-O0でスタックに色々やるけど、最適化すれば一つの関数でpush/popは一回にまとめるのが普通だよ。allocaとか動的なスタック割り当てだと変わるけどね。
.NETでもコード生成バグはあるよ。この記事で一番気になったのは、他の調査ツールじゃなくていきなりコアダンプを使ったこと。メモリ破損の問題を調査するとき、うちではデフォルトでダンプを使うんだ。
C/C++は例外処理のためにMicrosoft ABIとかItanium ABIでスタックポインタの状態を詳細に記述するツールを使ってるんだ。特にItanium ABIのDWARF CFIはアセンブリがうるさいくらい。Goはこういう複雑さを避けたのは理解できるけど、その結果が今回のバグにつながったんじゃないか、って話だよ。詳しくは<a href=”https://learn.microsoft.com/en-us/cpp/build/exception-handli…”>https://learn.microsoft.com/en-us/cpp/build/exception-handli…</a>を見てね。
ARM64では命令間のレジスタ使用に特定のルールはないね。呼び出し規約でcaller-savedとcallee-savedレジスタが決まってて、x18みたいなプラットフォームレジスタはOSが勝手に使うこともあるから注意が必要。でも、命令レベルではそういうルールはないし、マクロが一時レジスタを使うときは、普通はユーザーがどれを使うか指定するもんだよ。
「ドル」記号があるのは普通のAArch64アセンブリじゃないって?それはAT&TとIntelの構文の違いだよ。アセンブリに詳しくないなら、<a href=”https://en.wikipedia.org/wiki/X86_assembly_language#Syntax”>https://en.wikipedia.org/wiki/X86_assembly_language#Syntax</a>で調べるといいよ。
スタックの各ページを触るコードを生成するのは、ユーザーが巨大なスタックを確保したときに、それが攻撃者に悪用されて任意のメモリ位置を指すようにならないためだよ。各ページを明示的にアクセスさせるとクラッシュになるからね。昔、可変長配列でスレッドスタックを切り替えるユーザー空間スレッドライブラリがあったのを思い出すな。
MicrosoftのARM64アンワインディングABIはもっと複雑そうに見えるね。これを見てみて。<a href=”https://learn.microsoft.com/en-us/cpp/build/arm64-exception-…”>https://learn.microsoft.com/en-us/cpp/build/arm64-exception-…</a>
2006年にIBMのJVMとWebsphereで似た経験はあるよ。でもGoはAssemblyを直接使えるから、JVMやCLRみたいにサフィポイントをどこでも期待できないんじゃないかな。だって、手書きのAssemblyコードに呼び出しが飛ぶ可能性があるんだからね。
DWARFのセクション6.4はかなり複雑だよね。psABIやLSBで少し修正されてるけど、全体の複雑さから見れば大したことないよ。興味があるなら、<a href=”https://dwarfstd.org/doc/DWARF5.pdf#page=171”>https://dwarfstd.org/doc/DWARF5.pdf#page=171</a>とか<a href=”https://gitlab.com/x86-psABIs/x86-64-ABI/-/jobs/artifacts/ma…”>https://gitlab.com/x86-psABIs/x86-64-ABI/-/jobs/artifacts/ma…</a>や<a href=”https://refspecs.linuxfoundation.org/LSB_4.0.0/LSB-Core-gene…”>https://refspecs.linuxfoundation.org/LSB_4.0.0/LSB-Core-gene…</a>を見てみて。
それはx86の話だね。
通常は「LDR Rd, =expr」っていう疑似命令を使うんだ。直接作れない即値は、PC相対メモリにコピーされて、そこからレジスタにロードされるよ。だから、「add constant to SP」は2命令(8バイト)と4バイトのデータ領域で、合計12バイトになっちゃうんだ。詳しくはこちらを見て。<a href=”https://developer.arm.com/documentation/dui0801/l/A64-Data-T…”>https://developer.arm.com/documentation/dui0801/l/A64-Data-T…</a>
Goユーザーは関数呼び出しとしてしかAssemblyを挿入できないよ。安全性のためかもしれないね。でも、runtime/internal/atomic
みたいにオーバーヘッドなしで注入する方法もあるけど、それにはruntimeとcompiler toolchainの変更が必要だよ。
アーキテクチャによってはアセンブラやカーネルが使う予約レジスタがあって、割り込みハンドラで変更されても元に戻らないことがあるんだ。
それって可能なの?即値を作るにはレジスタが必要だよね。アセンブラが勝手に使うのは良くないから、ヘルパーレジスタを受け取るADDマクロとか?でも、それだとAArch64の他の問題は直らないし。AMD64は知らないけど、スレッドローカルとかスタックフレーム確保も現実的じゃないと思うな。
アセンブラの方言ってめちゃくちゃ多いんだよな。NES(やSNES、GBとか)で使う2A06アセンブラは変な癖があって、$が16進数、%が2進数、#がアドレス、レジスタがオペコードに組み込まれてたりするんだ。PlaystationはMIPSの方言だったけど、PS2はIntelスタイルだったってさ。
エンジニアたちはできるかどうかに夢中で、本当にやるべきかどうかを考えなかったんだね。
うん、ARM64のx18を例に挙げたんだよね。アセンブラ用の予約レジスタは知らなかったけど、MIPSには$1、別名$atっていう”assembler temporary”があったんだな。
これ、多分Plan 9 Assemblyの方言が原因だろうね。AT&TとIntelの間に違いがあるだけでも大変なのにさ。でも、Goがコンパイル言語のツールにアセンブラを含めるっていう1990年代の伝統を復活させたのは、ホントに素晴らしいことだと思うよ。
https://go.dev/doc/asm
x86以外のアーキテクチャに目を向けるべきだね。昔のMIPSではよくあったことだよ。
https://jdebp.uk/FGA/function-perilogues.html#StandardMIPS
僕は16年以上前に、x86でスタックポインタに対して2つの読み書き変更操作を行うことについて書いたものがあるよ。
https://jdebp.uk/FGA/function-perilogues.html#Standardx86
そういうアーキテクチャもあるけど、全部古いRISCチップだよ。
コンパイラは大きな定数をMOV/MOVKシーケンス(32ビット命令あたり16ビットのデータをエンコード)で処理するのが一般的だったな。メモリからロードするのは32ビットARMでよく見たよ。
待ちきれない君のために、修正はこちらだよ:
https://github.com/golang/go/commit/f7cc61e7d7f77521e073137c…
リンクされたIssueを確認中にこれに気づいたんだ:https://github.com/golang/go/issues/73259#issuecomment-31004… Goチームは自然言語ボットを使ってるの? それとも単にコメントに“backport”とかがあるか見てるだけかな?
もっとコメントを表示(1)
後者だよ:https://github.com/golang/build/blob/master/cmd/gopherbot/go…(https://go.dev/wiki/gopherbotで見つけた)
“please”と“backport”の両方が必要なんて、ちょっと面白いね、笑。
もっと言えば、前者(gabyhelp)もあるよ:https://github.com/golang/oscar/tree/master/internal/gaby
これは人気のあるオープンソースプロジェクトだし、君自身が修正内容が明記されたコメントに返信してるんだ。何が起きてるかは誰でも確認できるし、この問題も修正も透明性が高い。たとえ君がコードを理解できなくても多くの人が理解してる。だから君の主張は不必要なパラノイアに見えるし、それが低評価の原因じゃないかな。
そのアカウントは22時間前に作られたばかりだから、関わるのは無意味だよ。
コンパイラがバグの原因だと疑うのは本当に難しいんだよね。エンジニアはツールを信じるように教えられてるから、自分のコードばかり疑っちゃう。この考え方のせいで、コンパイラのレアなバグは余計見つけにくいんだよな。
昔はコンパイラをよく疑ったもんだ。Turbo Pascal 6でバグ見つけたんだよ。関数名と同じ変数名を使うと、結果がめちゃくちゃになるやつ。Pascalだと関数名に結果を代入するから、このバグは困ったんだよね。例はこれ→https://godbolt.org/z/s6srhTW66
TP6だとsucc(seg(x))とpred(seg(x))がseg(x)と同じになるバグがあったな。昔のTurbo Pascalは+ 1よりsucc(…)の方がいいコード作ってたんだ。メモリを16バイト多めに確保してアライメントする工夫とかもしてた。このバグを見つけるのに1~2日かかったっけ。
昔はもっとコンパイラを疑うのが普通だったね。80年代後半から90年代初頭のミニコンで、Pascalコンパイラのバグを見つけたことがあるんだ。デバッガと逆アセンブリで突き止めて、FAXで開発者に報告したら、フロッピーで修正版が送られてきたよ。何度か経験したな。
自分のオープンソースプロジェクトでもあったよ。変なクラッシュで、報告者が頑張って突き止めてくれたんだ。結局、Goコンパイラのバグだった。これね→https://github.com/golang/go/issues/20427
HFTみたいな分野だと、コンパイル過程を限界まで最適化するから、バグがよく出てくるんだ。HFT企業はGCCやClangの超変なバグを見つけては自慢してるよ。ナノ秒単位が重要だから、古いコンパイラのSnapshot版を使うことさえあるんだ。
HFTだと、コンパイラのバグ修正を秘密にしちゃうかもしれないね。そうすれば、他のHFTは恩恵を受けられないし。
両方見たな。秘密にする会社もあれば、報告する会社も。だって、競合が全く同じバグに当たる可能性は低いし、パッチを維持するコストもバカにならない。XTXのTernFSみたいに、競合への利益を恐れずに公開する例もあるしさ。
すごくいい技術ブログだね。説明がすごく分かりやすくて、俺でも賢くなった気分になるよ。マーケティングとしても成功してるね、このチームは超優秀だなって思っちゃうもん。これってAmpere Altra?うちはEpyc使ったけどね。
この問題はコンパイラのバグというより、デバッグ情報生成のバグに見えるな。スタックが大きすぎると、オフセットを一時レジスタに入れて、それをrspに加算する単一の不可分なオペコードになるんだ。これで競合は避けられるけど、ランタイムを悲観的にするのはどうなんだろうね。DWARFのバイトコードがあれば、真のスタックポインタを復元できるはずだけど。
この問題ってコンパイラバグじゃなくて、デバッグ情報生成のバグみたいに見えるんだけど。でも、それって結局同じことだよね?本番環境のワークフローで起きたバグなんだから、コンパイラバグって呼んでも全然おかしくないんじゃないかな。
ありがとう。アンワインダー情報もデバッグ情報だと俺は思ってるよ。デバッグ以外でもよく使われるけどね。
実際のバグについてだけど、スタックをフレームポインタで辿ってアンワインドしてるんじゃないなら、命令ポインタをキーにしたテーブルを見て、前のフレームのレジスタ内容を計算する必要があるんだ。GoがSPとアンワインドテーブルのアプローチを使ってるなら、2つのADDに対してテーブルのエントリが分かれてなかったのが本当のバグだろうね。
フレームポインタをGoランタイムが使ってるなら、フレームポインタの更新が正しくなかったってことで、それはコード生成のバグだ。個人的にはフレームポインタは全然好きじゃないんだよね。レジスタをデバッグのために消費するなんて、純粋主義の俺は嫌だなぁ。
CloudflareはどんなARM64マシンを使ってて、何に使ってるの?去年はAMD EPYCのGen 12サーバーを発表してたのに(https://blog.cloudflare.com/gen-12-servers/)、ARM64の話は出てなかった記憶があるんだけど。
でも今、ARM64をフル生産で動かしてるみたいだね。
俺はCloudflareの人間じゃないけど、彼らのブログはよく読んでるよ。記事でセキュアブートについて触れてることからわかるように、彼らはAMDと並行してAmpereを何年も前からデプロイしてるんだ。
目的としては効率化のためのEdge関連みたいだけど、他の用途にも使ってるかもしれないね。詳しくはこちらを読んでみて:
https://blog.cloudflare.com/designing-edge-servers-with-arm-…
https://blog.cloudflare.com/arms-race-ampere-altra-takes-on-…
https://blog.cloudflare.com/arm-takes-wing/
うん、でもあれらはかなり古い記事だよね。古いAmpereサーバーって、今のEPYCと比べるともう効率的じゃないんじゃないかなって印象があったんだ。
だから、彼らの現在の世代のARM64サーバーがどんな感じなのか気になるんだよね。
Cloudflareは一部の非Edgeコンピュートをパブリッククラウドでホストしてるって聞いた覚えがあるな。コントロールプレーンとかのやつ。それかもしれないね。
Goに、全命令をシングルステップ実行して、opcodeごとにGC割り込みをトリガーするモードがあったらどうかな?そういうバグを見つけるのがもっと簡単になると思うんだけど。
素晴らしい発見と解説だね。余談だけど、これはモデルチェッカーじゃ対応できないタイプの問題だと思うんだ。完璧で複雑なTLA+/Lean/FizzBeeモデルを書いて、たとえそのモデルからコードを生成できたとしても、プラットフォーム、コンパイラ、言語の問題で、こういうバグに遭遇することはあるからね。でも、ありがたいことに、そういうバグは稀だけどさ。
そうだね。モデルチェッキングは設計が正しいかを確認するためのもので、実装の問題じゃないんだ。
実装についてはCompCertみたいな認定コンパイラ(https://compcert.org/)も使えるけど、それでも自分のコードが正しいことを示す必要があるし、CompCert自体にもまだ認定されてない部分があるんだよね。
Cloudflareって、てっきり100% Rustとx86 (EPYC)だと思ってたよ。
GoとARMも使ってるって聞いて、へぇ~ってなったね。
あの規模の会社が単一言語ってことはないでしょ。ARMを使うのは水平スケーリングのワークロードで理にかなってるから、別に驚くことじゃないよ。
Cloudflareは昔からx86にデプロイしてても全部Armビルドを保持してたんだって。いざって時に切り替えやすいようにね。RustもGoもたくさん使ってるよ。
スタックポインタは常にアトミックに調整しろよ、みんな。
プリエンプション書いた人はX86で作業してて、可変長命令のおかげで定数を保持できてアトミックに処理されると信じてたんだろうね。で、ARMへの移植時に高レベルから自動的に”分割”されて簡単にした結果、このバグが生まれたって感じかな。誰のせいってわけでもないけど、結果は悪かったね。
”誰のせいってわけでもないけど、結果は悪かったね”
いや、全面的に、アセンブラじゃないアセンブラを書いたのが悪いよ。99%アセンブラだけど1%はIRみたいなやつね。自滅行為だよ。
もっとコメントを表示(2)
昔のアセンブラはめっちゃいろいろできたんだぜ。
そうそう。S/360のアセンブリとか、たまに高レベル言語みたいに見えるよね。MVSだとOSや標準ライブラリの機能は凝ったマクロとして実装されてて独自の呼び出し構文があったんだ。今はレジスタでパラメータ渡す関数を呼ぶのが普通だけどね。90年代にはアセンブリでOOPプログラミングをサポートするマクロアセンブラもあったんだよ。Borland Turbo Assembler 5.0とか思い出すな、結構楽しかった。
そういうのはまだあるよ、PC文化系のNASM, YASM, MASM(今もMSVCの一部)とか。そういえばEmbarcaderoはまだTurbo Assembler持ってるね。
https://docwiki.embarcadero.com/RADStudio/Athens/en/Turbo_As…
今や過去のものだけど、ゲーム機用アセンブラもマクロ機能がかなり強力だったな。UNIXのアセンブリ文化は好きじゃなかったな。Cが登場したら、Cコンパイラから生成されたアセンブリをアセンブルするだけの最低限の機能になっちゃったからね。マクロアセンブラの気の利いた機能は他のプラットフォームから来たんだ。NASMをプラットフォームのアセンブラの代わりに使ったりとかね。GNU ASもclangも基本的なこと以外のアセンブラとしての能力はそんなに良くないよ。
”アセンブラ”のエラーじゃなくて、内部の高レベルIRから変換する別の部分かもしれないよ。それにほとんどの場合、レジスタ操作命令(可能な限りコンパクトに生成したいやつ)にとっては分割された操作は問題にならないんだ、普通のアトミックはメモリアドレスとは別だからね。それでも、もしプリエンプションより前にコード生成が書かれてたなら、プリエンプションを実装した側が関数エピローグを考慮しなかったのはかなりずさんだったね。まぁ、スタック/フレームポインタを4KB以上静的に調整するのはちょっとしたエッジケースだけど。
こんな状況で緩和されたアトミック加算を使ったことがある、数十人のうるさい連中は手を挙げてくれ。SPを可能な限り偏執的に更新するのが、そういうものの存在する理由だよ。(Go言語では緩和されたアトミックを表現できないけど、技術的にはコンパイラでランタイムコードのためにサポートを追加できるよ)
俺も全く同じこと考えてたわ。
わかんないんだけど、マシン スレッドって2つの命令の途中でどうやって止められてたの? これベアメタルだよね?
GoはGC通知に割り込みを使ってるよ。
シグナルだよ。
だから昔から、できるならシグナルとスレッドを一緒に使うなって言われてたんだよな。
いつものCloudflareブログの記事、最高!
魔法みたいなインフラやMLじゃないエンジニアリングだよね。いつか応募するぞ!
コンパイラのバグって実は結構よくあるんだよ(俺もgccで年に数個見つけてた)。でも筆者が言うように、大規模な運用じゃないと見つからないものもあって、ほとんどの人はそこまで深く調べないんだ。
今日応募するのを止めてるものって何?
いい質問だね。主に場所(フランスにはない)と、どの求人に応募すべきかわからないんだ。バックグラウンドはネットワーキングじゃなく数学、HPCとかだけど、Cloudflareブログに出てくるようなIntel NICs関連のeBPFやカーネル ネットワーク層の問題によく遭遇するんだ。Cloudflareで働いてる人と話せれば、どんな仕事か理解できそうだけど、この未知の世界にちょっと気後れしてるのかもね :-)
俺は2020年にCloudflareでインターンしたけど、すごく楽しかったからめっちゃオススメする!
場所のことは言えないけど、君が興味あることや経験は彼らがやってることとかなり重なりそうだよ。かなり深い技術的なことをやってるんだ。
もし誰かと話したいなら、GitHub、Twitter、LinkedInで働いてる人を探して、メッセージを送って20分くらい話せないか聞いてみるのがいいよ!俺も何度もやってるけど、いつもすごく良い結果だったよ。
俺にjgc@Cloudflareにメールしてくれれば、適切な人に君の詳細を転送してあげるよ。
前のコメントの人みたいに、Cloudflareのブログ記事を読むといつも「こういう仕事したいな」って思って求人ページ見ちゃうんだよね。残念ながら私の国には求人ないんだけど、これからもチェックし続けるよ!
これらの企業にとって、勤務地はあんまり関係ないと思うな。とにかく応募してみるべきだよ。俺は紛争地域に住んでる人と働いたこともあるしね。スキルがあれば金は払う。適当なReactエンジニアじゃなくて、コンパイラのバグを見つけて何千万ドルも会社を救えるような人なら、絶対雇うだろうね!
残念だけど、95%のケースでは大企業だと勤務地はかなり重要だよ。俺ももっと面白い仕事がしたい立場だけど、興味ある会社がオフィスを構えてるところと、俺が住みたい場所がうまく合わないんだよね。人生を大きく変えるほどじゃない限りはね。(”断るのがもったいない”ってレベルなら話は別だけど。)
俺はハッキリ言って「断るのがもったいない」レベルの人について話してたんだよ。会社の最終利益を最適化できる人は誰でも雇われる。普通のReactエンジニアとかありふれたJava開発者が田舎からリモートで雇われるなんてことはないけど、50倍くらいの価値を提供できるなら、もちろん大歓迎だろ。コンパイラの最適化について議論してるんだから、俺のメッセージは明らかだと思ってたんだけどな。
(うん、君のメッセージはかなり明確だったと思うよ。でも、このスレッド全体で見ると、投稿者が自分をそのスキルレベルに位置付けてるかはハッキリしなかったね。)コンパイラ最適化や熟練したソフトウェア開発者の話をしてる時でも、スキルの幅ってかなり広いと思うんだ。俺も結構できる方だとは思うけど、Lars Bak(Googleが彼の為にデンマークにオフィスを作ったらしいね)ほどじゃないしね。
どうやって自分を「ありふれた」レベルより上って評価するの?俺はフルリモート開発者として働いてるけど、自分が特別だとは思えないんだよね。客観的に自分が優れてるって、どうやって判断するのかな?
どこで自分のことについて言った?もしそういう意味なら、それはあなたの投影か深い不安からくるものだね。特別な人って何かって聞くなら、それは役割とスキルセットによるよ。前のコメントでも言ったけど、コンパイラのバグを見つけて直せるような超一流の人は稀な存在なんだ。特に、そのスキルで会社が莫大なお金を節約できて、それを他の場所に使えるならね。世界にはAIの分野を進められる人はほんの一握りしかいないだろうし、彼らの多くは中国人移民だけど、OpenAIやMetaなんかが莫大なお金を払ってるでしょ。リモートの仕事に関してだけど、以前、俺たちはPostgresとOracle RDBMSの内情を熟知してる数少ない人の一人だったから、契約者として1時間500ドルくらいで雇ったことがあるよ。すごく重要な移行をやってたからね。
世界中で新しいRTO(Return To Office)義務が出てるみたいだから、最近は勤務地が要因じゃなかったかもしれないけど、これからはそうなるかもね。
他の多くの会社と比べて報酬が低いんだ。(応募はしたけど、結局は受け入れなかったよ。)
こんなバグを見ると、「どんなテスト方法だったらこれを見つけられたんだろう?」って疑問に思うね。それは一般的な方法であるべきで、事前にバグを知ってるようなものじゃなくちゃダメだよね。