要約 / ポイント
コンパイラの最適化の試みが、わずか64KBのメモリをゼロクリアするためだけに256KBものコードを生成した。この壊滅的な失敗により、エミュレーターは実行中に壊れたコードを書き換えざるを得なくなり、「賢い」コードに関する時代を超えた教訓が明らかになった。
64KBのタスクから生まれた256KBのバグ
わずか64KBのstack memoryを初期化するためだけに、途方もない256 kilobytesものmachine codeを生成してしまうほど、見当違いなコンパイラを想像してみてください。これは画期的なAI modelや複雑なシミュレーションのためではありませんでした。プログラムの唯一の、信じられないほど基本的な目標は、メモリブロックをゼロクリアすることでした。これは、数個の命令で実行されるべき効率の典型である基本的な操作です。それは、小さな釘を打つのに大槌を使うようなものです。
しかし、Windows x86 emulatorの時代、あるコンパイラは純粋なプログラミングの傲慢さを犯しました。メモリをクリアするためのコンパクトで効率的なループを生成する代わりに、操作全体を完全にアンロールしてしまったのです。この壊滅的な「最適化」は、65,000個以上もの個別のbyte-write instructionsに膨れ上がり、それぞれがbinary内の独立した異なるコマンドとなりました。
各命令は、単一のバイトをゼロに設定する、個別の骨の折れるステップとなりました。結果として生成された実行可能コードは、初期化されるべきデータの4倍のサイズに膨れ上がり、不合理な4:1のcode-to-data size ratioを生み出しました。compiler heuristicsがいかにひどく失敗しうるかを示すこの途方もない肥大化は、emulatorチームに「うん、これは実行しない」と宣言させ、彼らのアプローチを根本的に再構築するきっかけとなりました。
「最適化」が問題になるとき
コンパイラアーキテクトは、巧妙な最適化によってパフォーマンス向上を追求することがよくあり、loop unrollingはその代表的な例です。この正当な手法は、loop control overhead(counter incrementsやconditional branchesの排除)を削減し、instruction-level parallelismを露呈させることで、理論的にはinstruction pipeliningを改善し、memory latenciesを隠蔽することを目的としています。本質的に、それは反復的な制御ロジックを、より長く直線的な一連の操作と交換するものです。
しかし、Windows x86 emulatorの時代に動作していたこのコンパイラは、その概念を常識の範囲を超えて押し進めました。コンパクトなループの代わりに、わずか64 kilobytesのstack memoryをゼロにするために、それぞれが単一のバイトを書き込む65,000以上の個別の命令を生成したのです。これは最適化ではなく、壊滅的な誤算であり、machine codeを256 kilobytesにまで膨張させ、コード対データの比率が驚異的な4:1になりました。
このような極端なcode bloatは、instruction cacheを完全に破壊しました。unrollingによる理論上の速度向上は、CPUがより遅いmemoryから常に新しいinstructionsをフェッチし、冗長なコードでcacheをスラッシングしたため、消え去りました。このナイーブなcompiler heuristicは、抽象的な最適化理論とhardware constraintsの不変の現実との間の深い乖離を示し、見事に失敗しました。emulatorチームの「うん、これは実行しない」という率直な評価は、この不条理さを完璧に捉えていました。
ランタイムのヒーロー:JIT Compilerによるライブ書き換え
Windows x86 emulatorsの時代には奇妙なシナリオが展開されました。これらは、x86 codeを実行時にnative instruction setに変換するように設計された洗練されたシステムです。これらのemulatorsは、異なるarchitectures用に元々コンパイルされたアプリケーションを実行するために、「基本的にJIT compilerのように」機能するDynamic Binary Translation(DBT)を採用しており、これはcompilerの不手際に対する唯一の防御となることがよくありました。
エミュレータエンジニアは、実行中に病的な非効率性をすぐに発見しました。64キロバイトのスタックメモリをゼロクリアするためだけに256キロバイトのアンロールされたマシンコードに直面し、彼らの共通の反応は明白でした。「うん、あれは実行しないよ。」65,000を超える個別のバイト書き込み命令という肥大化の規模は、単純にパフォーマンスを著しく低下させ、コードを使用不能にしました。
英雄的なランタイムソリューションが実現しました。エミュレータチームは、バイナリトランスレータ内に特別な検出機能を実装しました。システムがこの特定の、ひどく最適化されていないパターンに遭遇すると、その不正なコードを傍受しました。コンパイラの悲惨な出力を実行する代わりに、ランタイムシステムはそれを破棄し、その場で適切でタイトループを動的に生成し、メモリのゼロクリアを正確かつ効率的に実行しました。このライブ書き換えは、究極のランタイムヒーローでした。このような歴史的なエミュレータの英雄的行為の詳細については、The time the x86 emulator team found code so bad that they fixed it during emulation - The Old New Thingを参照してください。
頑張りすぎたコンパイラからの教訓
256KBのバグが鮮やかに示すように、最適化は危険な綱渡りです。わずか64KBのスタックメモリを初期化するためのコンパイラの積極的なループアンローリングは、不合理な4:1のコード対データ肥大化をもたらし、65,000以上の命令を生成しました。この病的な結果は、「より最適化されている」がしばしば「はるかに悪い」を意味しうることを証明しています。
Enjoying this? Get one like it in your inbox each morning.
one email a day · unsubscribe in two clicks · no third-party tracking
幸いなことに、現代のコンパイラはこの教訓を学びました。LLVMやGCCのようなツールが採用している今日の洗練された費用対効果モデルは、コードサイズ、キャッシュ局所性、命令パイプライン効率などの要素を綿密に比較検討しています。これらのモデルは、かつてパフォーマンスを著しく低下させたような無制限の最適化を防ぎます。
決定的に重要なのは、JITコンパイラと動的バイナリトランスレータが依然として不可欠であることです。Java Virtual Machines、.NET Runtimes、AppleのRosetta 2のようなシステムは、ランタイム時にコードを継続的に監視し、適応させます。それらは一般的なケースだけでなく、特定のワークロードやハードウェアに合わせて動的に調整し、重要な防御層として機能します。
Better Stackの「This Code Was So Bad the Emulator Rewrote It Live」によって強調されたこの歴史的な逸話は、ランタイムシステムの計り知れない力を浮き彫りにしています。それらは、パフォーマンスを調整するだけでなく、上流のコード生成におけるひどいエラーを積極的に修正し、手に負えない肥大化をその場で効率的な実行に変える、重要な最後の防衛線を提供します。
よくある質問
コンパイラ最適化におけるループアンローリングとは何ですか?
ループアンローリングとは、コンパイラがループをループ本体の繰り返しシーケンスに置き換える手法です。これにより、ループ制御のオーバーヘッド(カウンターチェックなど)は減少しますが、全体のコードサイズは増加します。
コンパイラはなぜ64KBのメモリをゼロクリアするために256KBのコードを生成したのですか?
コンパイラはループアンローリングを極端に適用し、単純なメモリゼロクリアループを、バイトごとに1つずつ、65,000を超える個別の命令に変換しました。これにより、単純なタスクに対して4倍もの大規模なコード肥大化が発生しました。
JIT(Just-In-Time)コンパイラとは何ですか?
Just-In-Time(JIT)コンパイラは、多くのランタイムシステムが持つ機能で、実行中にコードを機械語命令に変換します。これにより、コードが実際にどのように使用されているかに基づいて、適応的な最適化を行うことができます。
エミュレータはどのようにして非効率なコードを修正したのですか?
x86エミュレータは、ランタイム時に特定の病的な命令パターンを検出する動的バイナリトランスレータ(JITのようなもの)を使用しました。その後、256KBの悪いコードを破棄し、その場で単一の効率的なループに動的に置き換えました。
