RustではじめるCPUとGPU

ビルドを極める

この章でわかること:

  • 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: 手順と実測

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

# 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章の原則はここでも同じです)。

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章)という 判断を、実装レベルの知識で裏づけます。