最適化:速度とサイズのトレードオフ

誰もがプログラムを超高速で超小さくしたいと願いますが、両方の特性を最大化することはできません。 このセクションは、rustcが提供する異なる最適化レベルについて、どのようにプログラムの実行時間とバイナリサイズに影響するかを説明します。

最適化なし

これはデフォルトです。cargo buildを実行する場合、開発(別名dev)プロファイルを使います。 このプロファイルはデバッグに最適化されています。そのため、デバッグ情報が有効化されており、最適化は一切有効化されていません。 つまり、-C opt-level = 0を使用します。

少なくともベアメタルの開発では、デバッグ情報はある意味ゼロコストです。 デバッグ情報は、Flash/ROMの容量を使いません。そのため、リリースプロファイルで、デフォルトで無効化されているデバッグ情報を有効化することをお勧めします。 これにより、リリースビルドをデバッグする時、ブレイクポイントを使うことができます。

[profile.release]
# シンボルは素晴らしく、Flashのサイズを増やしません
debug = true

最適化しないことはデバッグでは重要です。コードをステップ実行する時、プログラムをステートメントごとに実行しているように感じられるからです。 さらに、スタックの変数や関数の引数をGDBでprintすることができます。 コードが最適化されると、変数を表示しようとしても、$0 = <value optimized out>と表示されます。

devプロファイルの最大の欠点は、バイナリが大きく、遅いことです。 バイナリサイズは通常、より問題となります。最適化されていないバイナリは数十KiBもFlashを専有するからです。 ターゲットデバイスは、数十KiBものFlashを持っていないかもしれず、最適化されていないバイナリは、デバイス内に納まりません。

デバッガで扱いやすい、小さなバイナリを作ることができるのでしょうか?できます。良いやり方があります。

依存関係の最適化

注意 このセクションは、2018-9-18に最後にテストされた安定化していないフィーチャを使います。 それ以降、状況が変わっているかもしれません!

nightlyでは、profile-overridesと呼ばれるCargoフィーチャがあります。これは、依存関係の最適化レベルをオーバーライドします。 このフィーチャを使って、全ての依存クレートのサイズを最適化しながら、トップクレートを最適化しないでデバッガで扱いやすくすることができます。。

例を示します。

# Cargo.toml
cargo-features = ["profile-overrides"] # +

[package]
name = "app"
# ..

[profile.dev.overrides."*"] # +
opt-level = "z" # +

オーバーライドなしでは、次の通りです。

$ cargo size --bin app -- -A
app  :
section               size        addr
.vector_table         1024   0x8000000
.text                 9060   0x8000400
.rodata               1708   0x8002780
.data                    0  0x20000000
.bss                     4  0x20000000

オーバーライドをすると、以下のようになります。

$ cargo size --bin app -- -A
app  :
section               size        addr
.vector_table         1024   0x8000000
.text                 3490   0x8000400
.rodata               1100   0x80011c0
.data                    0  0x20000000
.bss                     4  0x20000000

トップクレートのデバッグ性を失うことなしに、Flash使用量を6KiB減らしています。 依存クレートの中に足を踏み入れると、<value optimized out>のメッセージを目にするでしょう。 しかし、依存クレートではなく、トップクレートをデバッグしたい場合がほとんどでしょう。 依存クレートをデバッグする必要がある場合、特定の依存クレートを最適化から除外するために、profile-overridesフィーチャを使えます。 例えば、以下のようになります。

# ..

# `cortex-m-rt`クレートは最適化しません
[profile.dev.overrides.cortex-m-rt] # +
opt-level = 0 # +

# しかし、他の依存クレートは最適化します
[profile.dev.overrides."*"]
codegen-units = 1 # better optimizations
opt-level = "z"

これで、トップクレートとcortex-m-rtクレートはデバッガで扱いやすくなります!

速度の最適化

2018-09-18のrustcは、3つの「速度最適化」を提供しています。opt-level = 123です。 cargo build --releaseを実行した時、デフォルトではopt-level = 3のリリースプロファイルを使います。

opt-level = 23は、バイナリサイズを犠牲にして、速度を最適化します。レベル3はレベル2より、ベクトル化とインライン化を行います。 特に、opt-level2以上の場合、LLVMがループを展開するのがわかるでしょう。 ループ展開は、Flash/ROMの観点からはよりコストが高いです(例えば、配列のループをゼロにする場合、26バイトから194バイトまで増加します)。 しかし、適切な条件では、実行時間が半分になります(例えば、イテレーションの回数が十分大きい場合)。

現在、opt-level = 23でループ展開を無効化する方法はありません。 ループ展開のコストを払うことができない場合、プログラムサイズの最適化をするべきです。

サイズの最適化

2018-09-18のrustcは、2つのサイズ最適化を提供しています。opt-level = "s""z"です。 これらの名前は、clang / LLVMから受け継いでおり、意味がわかりにくいです。 "z"は、"s"より小さなバイナリを作る意図を意味します。

リリースバイナリのサイズを最適化したい場合、Cargo.tomlprofile.release.opt-level設定を下記の通り変更します。

[profile.release]
# または"z"
opt-level = "s"

これらの2つの最適化レベルは、LLVMのインラインしきい値を大幅に減らします。 インラインしきい値は、関数をインライン化するか否かを決めるために使われる基準値です。 Rustの原則の1つは、ゼロコスト抽象化です。 これらの抽象化は、不変条件を保持するため、多くの新しい型と小さな関数を使う傾向にあります (例えば、derefas_refのような内部の値を借用するための関数)。 そのため、インラインしきい値を低くすると、LLVMが最適化の機会を失います (例えば、不要な分岐を削除したり、クロージャをインライン呼び出しにする、など)。

サイズの最適化を行っている時、バイナリサイズに影響があるかどうかを見るために、インラインしきい値を増やしたいかもしれません。 インラインしきい値を変更するお勧めの方法は、.cargo/config内のrustflagsに-C inline-thresholdフラグを追加することです。

# .cargo/config
# cortex-m-quickstartテンプレートを使っていることを想定しています
[target.'cfg(all(target_arch = "arm", target_os = "none"))']
rustflags = [
  # ..
  "-C", "inline-threshold=123", # +
]

この値は何に使われるのでしょうか? 1.29.0では、下記の値は、異なる最適化レベルで使われるインラインしきい値です

  • opt-level = 3は275を使います
  • opt-level = 2は225を使います
  • opt-level = "s"は75を使います
  • opt-level = "z"は25を使います

サイズの最適化をするときは、225275を試してみるべきです。