コンテンツにスキップ

ビルドを極める

この章でわかること:

  • LTO・codegen-unitsの実測トレードオフ(ビルド17→39秒、バイナリ5.8→3.9MB)
  • PGO(プロファイルに基づく最適化)の完全な手順と、正直な実測結果
  • コンパイル時間を削る体系的な方法 — 開発イテレーションも性能のうち
  • バイナリサイズの最小化(実測: 422KB→279KB)と、その代償
  • なぜ体系に必須か — コードを1行も変えずに引き出せる性能と、 日々のビルド待ち時間は、どちらも「ビルドの知識」だけで決まります。 6章で紹介した設定群を、実測で裏づけて完成させます

この章の実測はすべて筆者のMac(Apple M4)で行いました。 ビルド時間はマシンとキャッシュ状態に強く依存するため、 絶対値ではなく比率を見てください。

6章で「速度優先ならlto = "thin"codegen-units = 1」と書きました。その代償を数字にします。 本書のexamples(wgpu依存ツリー込み)のクリーンビルドで比較しました。

設定 クリーンビルド バイナリサイズ
lto=off, codegen-units=16 (cargo既定) 17秒 5.8MB
lto=thin, codegen-units=1 (6章の推奨) 29秒 4.6MB
lto=fat, codegen-units=1 39秒 3.9MB

LTOを深めるほど、ビルドは遅く、バイナリは小さくなります (クレート横断でコードが融合・重複排除されるためです)。 実行速度への効果はコードの性質に依存します——クレートの境界を 高頻度で越える呼び出しが多いほど効き、この本の行列積のような 「ホットループが1クレートに閉じた」コードでは差が出にくくなります。 判断は8章の計測で行ってください。

実務の定石は、プロファイルを分けることです。

[profile.release] # 日常のリリースビルド: 既定のまま速く回す
[profile.dist] # 配布用: 時間をかけて最良を狙う
inherits = "release"
lto = "thin"
codegen-units = 1

PGO(profile-guided optimization、6章)は 「実際の実行の統計」をコンパイラに与える手法です。手順は4段階です。

Terminal window
# 0. 前回のプロファイルを消しておく(混入防止)
rm -rf /tmp/pgo-data
# 1. 計測を仕込んだバイナリを作る
# (--target を明示すると、ビルドスクリプト自身の実行が
# プロファイルに混ざるのを防げます)
RUSTFLAGS="-Cprofile-generate=/tmp/pgo-data" cargo build --release
# 2. 代表的なワークロードで実行する(プロファイルが書き出される)
./target/release/myapp --typical-workload
# 3. プロファイルを1つに統合する。llvm-profdataは
# `rustup component add llvm-tools` で入る(PATH外にあるので
# ~/.rustup/toolchains/*/lib/rustlib/*/bin/ から探す)
llvm-profdata merge -o /tmp/pgo-data/merged.profdata /tmp/pgo-data
# 4. プロファイルを使って再ビルドする
RUSTFLAGS="-Cprofile-use=/tmp/pgo-data/merged.profdata" cargo build --release

コンパイラは分岐の実測頻度をもとに、ホットパスのコード配置 (16章のフロントエンド改善)、 インライン判断、間接呼び出しの直接化などを行います。

正直な実測結果を報告します。筆者が「偏った間接呼び出しを 5000万回行う」マイクロベンチマークでこの手順を試したところ、 ベースライン約70ミリ秒に対しPGO版は約75ミリ秒——改善なしでした。 小さなプログラムでは、プロファイルなしでもコンパイラの静的な 判断がすでに最適に近く、PGOの出番がなかったのです。

PGOが効くと報告されているのは、コンパイラ自身、ブラウザ、 大規模サーバのような「コードが巨大で、分岐が多様で、 命令キャッシュが逼迫する」プログラムです(rustc自身もPGOで ビルドされています)。数%〜十数%の改善が典型で、 魔法ではなく、大きなコードの整理整頓と考えるのが正確です。 手順を自動化するcargo-pgoも あります。さらに先には、リンク済みバイナリを実行プロファイルで 並べ替えるBOLTという道具も存在します(cargo-pgoが対応)。

Rustの遅いコンパイルは開発の摩擦そのものです。原因と対策を 体系的に並べます。

まず計測から(8章の原則はここでも同じです)。

Terminal window
cargo build --timings # クレートごとの所要時間のHTMLレポート

時間を消費する主要な要因と対策:

  • 依存クレートの多さ--timingsで上位を特定し、 不要な依存とfeatureを削ります(default-features = false)。 依存は実行性能ではなくビルド性能の敵です
  • 単相化の爆発7章のとおり、 ジェネリクスは使用型ごとにコードを複製します。巨大なジェネリック 関数は、型に依存しない本体を非ジェネリックな内部関数に 切り出すと激減します。コンパイル時間とバイナリサイズが 問題の境界では、dyn(実行時コストと引き換え)も正当な選択です
  • リンク時間 — 大きなプロジェクトではリンカの置き換えが 効きます。Linuxではmoldが定番で、macOSも近年の標準リンカが 高速化しています。インクリメンタルビルドの終盤はほぼリンク時間 なので、体感への効果が大きい項目です
  • 開発ループの選択 — 型検査だけならcargo check(コード生成を 丸ごと省く)。エディタのrust-analyzerもこれに相当します。 CIではsccache等のコンパイルキャッシュが効きます

最後はサイズです。サイズ優先のプロファイルを作り、実測しました (対象は小さなCLIベンチマーク)。

[profile.min-size]
inherits = "release"
opt-level = "z" # サイズ優先の最適化
lto = "fat"
panic = "abort" # 巻き戻し機構を捨てる(6章)
strip = true # シンボル情報を除去

結果: 422KB → 279KB(34%減)。ただし実行時間は約70ミリ秒から 約90ミリ秒に悪化しました。opt-level = "z"はループ展開や インライン化を抑えるため、サイズと速度は明確なトレードオフです (一方で16章のとおり、命令キャッシュが 逼迫する大きなプログラムでは、小さくすることが速さに転じる場合も あります——だから計測です)。

何が太らせているかはcargo-bloatで 関数単位に分解できます。よくある犯人は、単相化の複製、 Debug/フォーマット機構の連鎖、そして(Rustは標準ライブラリを 静的リンクするため)使っていないようで使われているstdのコードです。 サイズが本当に厳しい組み込みやWebAssemblyの世界では、 これらに加えてno_std(標準ライブラリなし構成)という 選択肢が現れます——本書では名前の紹介にとどめます。

  • LTOとcodegen-unitsは「ビルド時間 ⇄ バイナリサイズ・(コード次第で)速度」の ダイヤルです。実測ではビルド17→39秒、サイズ5.8→3.9MBの幅がありました。 日常用と配布用でプロファイルを分けるのが定石です
  • PGOは4段階の手順で導入できますが、小さなプログラムには効きません (実測: 効果なし)。大規模で分岐の多様なコードの整理整頓です
  • コンパイル時間は--timingsで計測し、依存削減・単相化の抑制・ リンカ・cargo checkで削ります
  • サイズはopt-level="z"+panic="abort"+stripで34%減。 ただし速度と引き換えでした(実測: 70→90ミリ秒)

次章はPart Vの締めくくりとして、データ構造の「実装の中身」—— HashMapのSwissTable、BTreeの実性能、SmallVec、ビット操作——を 実測で開きます。「アルゴリズムとデータ構造が最優先」(8章)の 判断を、実装レベルの知識で支えます。