最適化: 速度とサイズのトレードオフ
誰もが自分のプログラムを非常に高速かつ非常に小さくしたいと思いますが、通常は
その両方の特性を同時に満たすことはできません。この節では、
rustc が提供するさまざまな最適化レベルと、それらがプログラムの
実行時間およびバイナリサイズにどのような影響を与えるかを説明します。
最適化なし
これはデフォルトです。cargo build を呼び出すと、開発(別名
dev)プロファイルを使用します。このプロファイルはデバッグ向けに
最適化されているため、デバッグ情報を有効にし、最適化は有効にしません。
つまり、-C opt-level = 0 を使用します。
少なくともベアメタル開発においては、デバッグ情報は Flash / ROM の領域を 消費しないという意味でコストがゼロなので、実際には release プロファイルでも デバッグ情報を有効にすることを推奨します – これはデフォルトでは無効です。 そうすることで、release ビルドをデバッグするときにもブレークポイントを 使えるようになります。
[profile.release]
# シンボルがあると便利で、Flash 上のサイズも増えない
debug = true
最適化なしはデバッグに最適です。コードのステップ実行が、文を 1 つずつ
実行しているように感じられるうえ、GDB でスタック変数や関数引数を print
できます。コードが最適化されていると、変数を表示しようとしても
$0 = <value optimized out> と表示される結果になります。
dev プロファイルの最大の欠点は、生成されるバイナリが非常に大きく、
しかも遅いことです。通常はサイズのほうがより大きな問題です。というのも、
最適化されていないバイナリは Flash を数十 KiB 占有することがあり、
ターゲットデバイスにはその容量がないかもしれないからです – 結果として、
最適化されていないバイナリがデバイスに収まりません!
より小さく、デバッガで扱いやすいバイナリは作れるのでしょうか。はい、 ちょっとしたコツがあります。
依存関係の最適化
Cargo には profile-overrides という機能があり、これを使うと
依存関係の最適化レベルを上書きできます。この機能を使えば、
トップクレートは最適化せずデバッガで扱いやすいままにして、
すべての依存関係をサイズ優先で最適化できます。
ただし、ジェネリックコードは、定義されたクレートではなく、 インスタンス化されたクレート側で最適化されることがあります。 アプリケーション内でジェネリック構造体のインスタンスを作成し、 それが大きなフットプリントを持つコードを引き込んでいることに気付いた場合、 関係する依存関係の最適化レベルを上げても効果がない可能性があります。
以下に例を示します:
# Cargo.toml
[package]
name = "app"
# ..
[profile.dev.package."*"] # +
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 使用量を
6 KiB 削減できます。依存関係の中にステップインすると、再び
<value optimized out> メッセージが表示されるようになりますが、通常は
依存関係ではなくトップクレートをデバッグしたいことがほとんどです。そして、
依存関係を 本当に デバッグする必要があるなら、profile-overrides
機能を使って特定の依存関係を最適化対象から外せます。以下に例を示します:
# ..
# `cortex-m-rt` クレートは最適化しない
[profile.dev.package.cortex-m-rt] # +
opt-level = 0 # +
# ただし、ほかのすべての依存関係は最適化する
[profile.dev.package."*"]
codegen-units = 1 # より良い最適化
opt-level = "z"
これでトップクレートと cortex-m-rt はデバッガで扱いやすくなります!
速度を優先した最適化
2018-09-18 時点で、rustc は 3 つの「速度優先」最適化レベル、
opt-level = 1、2、3 をサポートしています。cargo build --release
を実行すると、デフォルトで opt-level = 3 の release プロファイルを
使用することになります。
opt-level = 2 と 3 はどちらも、バイナリサイズを犠牲にして速度を
最適化しますが、レベル 3 はレベル 2 よりも多くのベクトル化と
インライン化を行います。特に、opt-level が 2 以上になると LLVM が
ループを展開するようになることがわかるでしょう。ループ展開は Flash / ROM
の観点ではかなり高コストです(たとえば、配列をゼロクリアするループでは
26 バイトが 194 バイトになります)が、条件が適切であれば(たとえば反復回数が
十分に大きければ)実行時間を半分にできることもあります。
現在のところ、opt-level = 2 と 3 でループ展開を無効にする方法はないため、
そのコストを負担できない場合はプログラムをサイズ優先で最適化するべきです。
サイズを優先した最適化
2018-09-18 時点で、rustc は 2 つの「サイズ優先」最適化レベル、
opt-level = "s" と "z" をサポートしています。これらの名前は
clang / LLVM から受け継いだもので、あまり説明的ではありませんが、
"z" は "s" よりも小さいバイナリを生成することを示す意図があります。
release バイナリをサイズ優先で最適化したい場合は、以下に示すように
Cargo.toml の profile.release.opt-level 設定を変更してください。
[profile.release]
# または "z"
opt-level = "s"
これら 2 つの最適化レベルでは、関数をインライン化するかどうかを決めるために
使われる指標である LLVM の inline threshold が大きく引き下げられます。
Rust の原則の 1 つにゼロコスト抽象化があります。これらの抽象化は、不変条件を
保つために多くの newtype や小さな関数(たとえば deref や as_ref のように
内部の値を借用する関数)を使う傾向があるため、inline threshold が低いと LLVM が
最適化の機会(たとえば不要な分岐の除去や、クロージャ呼び出しのインライン化)を
逃す可能性があります。
サイズ優先で最適化する場合は、inline threshold を上げてみて、それが
バイナリサイズに何らかの影響を与えるかを確認したくなるかもしれません。
inline threshold を変更する推奨方法は、.cargo/config.toml 内のほかの
rustflags に -C inline-threshold フラグを追加することです。
# .cargo/config.toml
# これは cortex-m-quickstart テンプレートを使用していることを前提としています
[target.'cfg(all(target_arch = "arm", target_os = "none"))']
rustflags = [
# ..
"-C", "inline-threshold=123", # +
]
どの値を使うべきでしょうか。1.29.0 時点では、各最適化レベルが使用する inline threshold は次のとおりです:
opt-level = 3は 275 を使用しますopt-level = 2は 225 を使用しますopt-level = "s"は 75 を使用しますopt-level = "z"は 25 を使用します
サイズ優先で最適化する際は、225 と 275 を試すべきです。