世界を動かす技術を、日本語で。

GCCを使用したRustコンパイラの構築

概要

  • Rustコンパイラを GCCベース でブートストラップする試み
  • LLVMを使わず、 cg_gcc を利用した3段階のビルド工程
  • #[inline(always)] 属性による再帰関数の問題とその回避策
  • 128ビット整数の SwitchInt サポート不備によるビルド障害
  • パフォーマンスと正確性を両立するための実装上の工夫

RustコンパイラをGCCでブートストラップする挑戦

  • Rustコンパイラの ブートストラップ とは、Rust自身でRustコンパイラをビルドする工程
  • LLVMを使用せず、 GCCベース のコード生成(cg_gcc)を利用
  • ビルド工程は3段階
    • Stage 1: 既存のLLVMベースRustコンパイラでGCCベースのrustcとコード生成をビルド
    • Stage 2: GCCベースのコード生成でRustコンパイラを再ビルド
    • Stage 3: Stage2でビルドしたコンパイラで再度ビルドし、バイナリの同一性を確認
  • Stage3到達が GSoCプロジェクト の目標

現状の課題とバグ

  • 3つの主要なバグが Stage3ビルド の妨げ
  • バグの特定には「コンパイラのロボトミー」と呼ぶ デバッグ手法 を使用
    • 問題のあるクレートや関数のソースコードを直接修正し、ビルドを進める
    • 例:128ビット整数未対応、インライン化属性の除去、最適化無効化

#[inline(always)]と再帰関数の問題

  • #[inline(always)] 属性付きの再帰関数がGCCバックエンドでエラーとなる
  • LLVMではこの属性は 「ヒント」扱い で、インライン不可能なら無視
  • GCCでは「常にインライン化」を厳格に解釈し、自己呼び出しで失敗
  • 対策1:全ての#[inline(always)]を#[inline]として扱う
    • 簡単だが、 パフォーマンス低下 の懸念
  • 対策2:関数が 再帰的 (直接・間接問わず)である場合のみ属性を弱める
    • 直接再帰チェックだけでは不十分(間接再帰を見逃す)
    • 間接再帰まで考慮すると 実装が複雑・非効率 となる

効率的な属性チェック手法

  • #[inline(always)] を持つ関数が、同じ属性を持つ関数を呼び出しているかをMIRで調査
    • MIR(Mid-level Intermediate Representation)を利用した低コストなチェック
    • 基本ブロックの終端(Terminator)を走査し、呼び出し先の属性を確認
    • 条件を満たす場合のみ#[inline(always)]→#[inline]へ変換
  • この方法により、 必要最小限の修正 で問題を回避しつつ、パフォーマンスも維持

128ビットSwitchIntのバグ

  • SwitchInt はMIRの条件分岐命令で、Cのswitch文に類似
  • 128ビット整数を扱うSwitchIntが GCCバックエンド で未対応
  • libgccjitがエラーを出し、ビルドが停止
  • 根本原因は、GCCのIRで128ビット整数の定数生成が正しく扱えないこと
  • 対策には libgccjitの拡張 や、128ビット整数サポートの追加が必要

まとめ

  • RustコンパイラのGCCベースブートストラップは 多くの技術的課題 を含む
  • #[inline(always)]と再帰128ビット整数の分岐 など、LLVMとは異なるGCCの特性を考慮した実装工夫が不可欠
  • MIRや属性チェックによる 効率的な問題回避 が重要
  • 今後も パフォーマンスと正確性のバランス を意識した開発が求められる

Hackerたちの意見

一見するとそうは思えないかもしれないけど、これはすごい進展だよ。コンパイラがブートストラップできるようになるのは大きな成果だし、特にRustは色んな要素がうまくいかないといけないからね。信頼性のあるブートストラップができるようになれば、パフォーマンス向上のためのステップがたくさん始められる。おめでとう!

gccにはあんまり詳しくないんだ。ほんとにパフォーマンス向上に大きく寄与するの?

今日、またRustをいじり始めたばかり。神の加護を。

すごく面白い記事だった。最近、誰かがrustcの遅さは大部分がllvmによるものだと言ってたのを聞いた。ここでの作業とはあまり関係ないかもしれないけど、異なるツールチェインでコンパイラを構築するアイデアは好きだし、将来的に何か影響が出るかもしれないね。

遅いのは、借用チェッカーがNP完全だから。LLVMがrustcに対してGCCより遅いコードを生成するかもしれないけど、スナッピーさの欠如の主な原因には全然近くないと思う。

ワークロードによるけど、コード生成はコンパイル全体の時間の大部分を占めるよね。とはいえ、LLVMが常に修正が必要な場所ってわけじゃない。例えば、rustcがLLVMで多くの時間を使う理由の一つは、rustcがLLVMに渡すコードが多すぎるからで、LLVMのオプティマイザーに改善を頼ってるんだ。時間が経つにつれて、LLVMに投げるコードの量が改善されてきて、パフォーマンスも上がってるよ。

craneliftを使った実験的なコンパイラバックエンドがあるんだけど、デバッグビルドの時間を改善することになってるんだ。Rustの長いコンパイル時間に関するスレッドではあまり言及されていないから、何か見落としているのか分からないな。 [1] https://github.com/rust-lang/rustc_codegen_cranelift/ [2] https://cranelift.dev/

普通、コンパイラのデバッグはかなり簡単なんだ。まあ、普通の実行可能ファイルだからね。 > でも、ブートストラッププロセスでは全体がすごく複雑になる。実は、rustcは直接呼び出されるわけじゃないんだ。ブートストラップスクリプトがコンパイラのラッパーを呼び出すんだ。 > そのラップされたrustcを実行するのも簡単じゃない。複雑な環境フラグをたくさん設定する必要があるから。 > つまり、Rustコンパイラのデバッグ方法はわからないってこと。99.9%の確率で簡単な方法がどこかに文書化されてるはずだけど、探そうとは思わなかった。これを投稿した後、誰かが「お、Xをやればいいだけだよ」って教えてくれるだろうけど。 > でも、この記事を書いてる時点では、どうやってやるのかわからなかった。 > それで、実行中のプロセスにgdbをアタッチできるの? いや、あまりにも早くクラッシュしちゃうから無理だよ。この問題がどれだけ頻繁に起こるか、そしてそれに対処するための様々なトリックを持ってるのがちょっと面白い。時々、スクリプトをパッチしてgdb --args [元のコマンド]を呼び出すようにするけど、これはシンプルなシェルスクリプトの場合にしか価値がないし、stdin/stdoutがどこに行くか追跡できる時だけだ。そうじゃない場合は、実行する前に少しスリープするようにコードをパッチして、GDBをアタッチするチャンスを作ることもある。いくつかのプラットフォームでは、プロセスのexecを通知してくれたり、時にはそれをインターセプトしたりできる(例えばEDRソリューションとして)こともあって、その時はプロセスが立ち上がる前に一時停止させることもある。でも、一般的にもっと良い方法があればいいのにな…LLDBには「待機フラグ」があるけど、新しいプロセスを待ってる間ずっとループしてるだけで、早すぎる段階で死んじゃうものはキャッチできないんだ。

他のアイデア: * 全プロセスツリーをgdbの下でset detach-on-fork offで実行する。 * LD_PRELOADでライブラリを挿入して、起動時やシグナル/終了時にスリープを入れる。理想的には、特定のプロセスを再帰的に名前付けして識別できるインフラがあればいいな。

SIGSEGVをフックして、プロセスの端末に最適な推測を使ってgdbを起動するLD_PRELOADライブラリを持ってるよ(今のところ、stdioのリダイレクトが多いプロセスをデバッグする必要がなかったから、あまり賢くないけど)。

同意だな。最近、大きなJavaプログラムを扱ってて、90分くらい(長すぎた、ちょっと執着しちゃった)頑張ったけど、デバッガに入れるのを諦めた。ここはrustにがっかりするところで、「cargo debug」が内蔵されてないんだ(外部プログラムはあるけど、あんまりうまくいかない)。手動でgdbをつなごうとすると、ほとんどのシンボルが欠けてることが多い。デバッガ優先の言語を真剣に考えてみたいな、どんな体験になるのか見てみたい。

著者がコアダンプを強制できるかどうか気になってきた。最近のスナップショット機能を使えば、モダンなIntelプロセッサーでプロセッサートレースが取得できるから、実際のインタラクティブなデバッグセッションがなくても役立つかも(最近はスナップショットで十分だから、そんなセッションはやってないけど)。

デバッグしたい実行ファイルに読み込むCライブラリがあるんだ(過去にはPythonのも作ったことがある)。環境変数に基づいて動作するから、普通はそれを常にリンクしてる。読み込まれると、自動的にVSCodeと通信してデバッガを起動して接続を待つんだ。結果的に、環境変数を設定してスクリプトを実行するだけで、どんなにスクリプトやMakefileの中に埋もれていても、いい感じのGUIデバッガがプロセスに自動で接続される。https://github.com/Timmmm/autodebug 現在、これはC++ライブラリのデバッグに使ってる。Questa(商用のSystemVerilogシミュレーター)に動的に読み込まれるもので、Pythonスクリプトがカスタムビルドシステムで実行される。過去には、Makefileから起動されたCライブラリによって読み込まれたインタープリターで実行されるPythonコードをデバッグするために使ったこともある。そう、他にも理由はあったけど、その会社は生き残れなかったな…。

ソフトウェアがデバッグや状況の観察を完全に妨げるように作られているのは、本当に信じられないよね(フックもログもエラーメッセージもなし)。そこでバグをどうやって直すのか、全く分からないわ。

ここで本当にありがたいのが、.NetのコマンドSystem.Diagnostics.Debugger.Launch(); なんだよね。これを使うと、どのデバッガーを使うか選ぶウィンドウが出てきて、アプリケーションをその中で開いてくれるんだ。

時間旅行デバッグによるプロセス記録は、この問題にぴったりな気がする。そうすれば、プロセスの実行を100%キャッチして、後で詳しく調査できるからね。私たち(Undo.io)は、プロセスのツリーを追跡して、プログラム名のグロブに基づいてプロセス記録を開始する技術を考え出したんだ。これは、https://docs.undo.io/UsingTheLiveRecorderTool.html の--record-onフラグだよ。私たちのウェブサイトから無料トライアルも取れるよ。オープンソースでは、rr(https://rr-project.org/)を使えば、最初のプロセスをrr recordすれば、プロセスツリー全体をキャッチできると思う。そうすれば、興味のあるものを見られるよ。他の人が言っているように、GDBのfollow-fork設定を使って賢いこともできるけど、プロセス記録はこういう複雑な状況をキャッチするのに理想的だと思う。後で何が起こったかを見直せるからね。

他人のエンジニアリングを見るのが大好き。

再帰関数を常にインライン化しようとするのは、逆にエラーにすべきだと思う。でも、その変更をするのは望ましくないかもね。既存のクレートが壊れちゃう可能性があるし、後方互換性も影響するだろうし。

「inline(always)」はClangの「always_inline」に対応してると思うし、Clangのドキュメントはその動作をより明確にしてるね。> インライン化のヒューリスティックは無効化され、最適化レベルに関係なく常にインライン化が試みられる。だから、「常にインライン化を試みる」と解釈すべきで、「これをインライン化しなければならない」や、「これがインライン化されるべきか」というヒューリスティックに影響を与える他の属性とは違う。追記:興味深い属性かもしれないけど、インクラインの話をしてるつもりじゃなかったんだ。

再帰関数は展開されるとインラインにできるよ。これは有効な最適化なんだ。「llvm inline recursion」でググってみて。存在するし、うまくいくはずだよ。フィボナッチは標準的なテストケースだね。

何かがインライン化できるかどうかを確認する一番いい方法は、実際に試してみることじゃない?GCCにインライン化を試させて、できない場合はインライン化しないように指示すればいいんじゃない?

本当に考えてみて。経験のない人がswitchの代わりにifsを使っているのをどれくらい見る?このパターンはほとんどのコンパイラが認識して、最適化するほど一般的なんだ。これには笑っちゃったよ。gccが最適化してくれるようにわざとバカなふりをするのは、面白いし天才的だね。

実際、コンパイラはswitchをifsに変換するんじゃなくて、その逆なんだよ。