Rust パフォーマンスブック
2020年11月初版発行
Nicholas Nethercote ほか著
はじめに
多くの Rust プログラムにとって、パフォーマンスは重要です。
本書には、実行時の速度、メモリ使用量、バイナリサイズなど、Rust プログラムのパフォーマンス関連の特性を改善できるテクニックが含まれています。Compile Times セクションには、Rust プログラムのコンパイル時間を改善するテクニックも含まれています。一部のテクニックはビルド設定を変更するだけで済みますが、多くはコードの変更を必要とします。
一部のテクニックは完全に Rust 固有のものですが、他の言語で書かれたプログラムにも(多くの場合は変更を加えたうえで)適用できる考え方を含むものもあります。General Tips セクションには、あらゆるプログラミング言語に適用できる一般的な原則も含まれています。それでもなお、本書は主に Rust プログラムのパフォーマンスを扱うものであり、プロファイリングと最適化に関する汎用的なガイドの代わりになるものではありません。
本書は、実用的で実証済みのテクニックにも焦点を当てています。その多くには、実際の Rust プログラムでそのテクニックがどのように使われたかを示すプルリクエストやその他のリソースへのリンクが添えられています。本書には主著者の経歴が反映されており、科学技術計算などの他分野よりも、コンパイラ開発にやや偏っています。
本書は、素早く読めるように、深さよりも広さを重視して意図的に簡潔に書かれています。適切な場合には、より深く説明している外部ソースへのリンクを掲載しています。
本書は、中級および上級の Rust ユーザーを対象としています。Rust 初心者には学ぶべきことが十分すぎるほどあり、これらのテクニックは彼らにとって有益でない注意散漫の原因となる可能性が高いです。
ベンチマーキング
ベンチマーキングでは通常、同じことを行う 2 つ以上のプログラムのパフォーマンスを比較します。場合によっては、Firefox vs Safari vs Chrome のように、2 つ以上の異なるプログラムを比較することもあります。また、同じプログラムの 2 つの異なるバージョンを比較することもあります。後者の場合、「この変更によって処理は速くなったのか?」という問いに対して、信頼性の高い答えを得られます。
ベンチマーキングは複雑なトピックであり、この本の範囲を超えて網羅的に扱うことはできませんが、ここでは基本を示します。
まず、測定するワークロードが必要です。理想的には、プログラムの現実的な使用状況を表すさまざまなワークロードを用意します。実世界の入力を使うワークロードが最適ですが、microbenchmarks や stress tests も適度に使えば有用です。
次に、ワークロードを実行する方法が必要です。これは使用するメトリクスも決定します。
- Rust 組み込みの benchmark tests は単純な出発点ですが、不安定な機能を使用するため、nightly Rust でしか動作しません。
- Criterion と Divan は、より高度な代替手段です。
- Hyperfine は優れた汎用ベンチマーキングツールです。
- Bencher は GitHub CI を含む CI 上で継続的ベンチマーキングを実行できます。
- Gungraun は、高精度な測定を備えた
cargo bench統合を提供します。 - カスタムのベンチマーキングハーネスも可能です。たとえば、rustc-perf は Rust コンパイラのベンチマークに使用されるハーネスです。
メトリクスに関しては多くの選択肢があり、適切なものはベンチマーク対象のプログラムの性質によって異なります。たとえば、バッチプログラムに適したメトリクスが、インタラクティブなプログラムに適しているとは限りません。多くの場合、ウォールタイムはユーザーが知覚するものに対応するため、明らかな選択肢です。しかし、ウォールタイムは分散が大きくなることがあります。特に、メモリレイアウトのわずかな変化が、大きいものの一時的なパフォーマンス変動を引き起こすことがあります。そのため、分散のより小さい他のメトリクス(サイクル数や命令数など)が妥当な代替手段となる場合があります。
複数のワークロードから得られた測定値を要約することも課題であり、その方法はさまざまですが、明らかに最善と言える単一の方法はありません。
優れたベンチマーキングは困難です。とはいえ、特にプログラムの最適化を始める段階では、完璧なベンチマーキング環境を用意することに過度に気を使う必要はありません。並のベンチマーキングでも、ベンチマーキングをまったく行わないよりははるかに優れています。何を測定しているのかについて柔軟な姿勢を保ち、プログラムのパフォーマンス特性を学ぶにつれて、時間をかけてベンチマーキングを改善していけます。
ビルド設定
Rust プログラムは、コードを変更しなくても、ビルド設定を変更するだけで パフォーマンスを大きく変えることができます。Rust プログラムごとに、選択可能な ビルド設定は多数あります。選択した設定は、コンパイル時間、ランタイム速度、 メモリ使用量、バイナリサイズ、デバッグしやすさ、プロファイリングしやすさ、 コンパイル済みプログラムが実行されるアーキテクチャなど、コンパイルされたコードの いくつかの特性に影響します。
ほとんどの設定の選択は、1つ以上の特性を改善する一方で、 別の1つ以上の特性を悪化させます。たとえば、一般的なトレードオフは、 コンパイル時間が悪化することを受け入れる代わりに、ランタイム速度を高めることです。 プログラムにとって正しい選択は、あなたのニーズとプログラムの詳細に依存し、 パフォーマンス関連の選択(その大半がそうです)はベンチマークで 検証するべきです。
この章を注意深く読み、すべてのビルド設定の選択肢を理解する価値があります。
しかし、せっかちな人や忘れっぽい人のために、
cargo-wizard はこの情報をカプセル化しており、適切な
ビルド設定を選ぶ手助けをしてくれます。
Cargo は、ワークスペースのルートにある Cargo.toml ファイル内の
プロファイル設定だけを参照することに注意してください。依存関係で定義された
プロファイル設定は無視されます。したがって、これらのオプションは主に
ライブラリクレートではなく、バイナリクレートに関係します。
リリースビルド
最も重要なビルド設定の選択は単純ですが 見落としやすい ものです。高いパフォーマンスが必要なときは、リリースビルド を
開発ビルド ではなく使用していることを確認してください。
通常、これは Cargo に --release フラグを指定することで行います。
開発ビルドがデフォルトです。これはデバッグには適していますが、最適化されていません。
cargo build または cargo run を実行すると生成されます。(あるいは、
追加オプションなしで rustc を実行しても、最適化されていないビルドが生成されます。)
cargo build を実行したときの、次の最終出力行を考えてみましょう。
Finished dev [unoptimized + debuginfo] target(s) in 29.80s
この出力は、開発ビルドが生成されたことを示しています。コンパイルされたコードは
target/debug/ ディレクトリに配置されます。cargo run は開発ビルドを
実行します。
それに比べて、リリースビルドははるかに最適化されており、デバッグアサーションと
整数オーバーフローチェックを省き、デバッグ情報も省きます。開発ビルドに対して
10〜100倍の高速化は珍しくありません! cargo build --release または
cargo run --release を実行すると生成されます。(あるいは、rustc には
-O や -C opt-level など、最適化ビルド用の複数のオプションがあります。)
これは追加の最適化のため、通常は開発ビルドよりも時間がかかります。
cargo build --release を実行したときの、次の最終出力行を考えてみましょう。
Finished release [optimized] target(s) in 1m 01s
この出力は、リリースビルドが生成されたことを示しています。コンパイルされたコードは
target/release/ ディレクトリに配置されます。cargo run --release は
リリースビルドを実行します。
開発ビルド(dev プロファイルを使用)とリリースビルド(release プロファイルを使用)の
違いの詳細については、Cargo プロファイルのドキュメントを参照してください。
リリースビルドで使用されるデフォルトのビルド設定の選択は、コンパイル時間、 ランタイム速度、バイナリサイズなど、前述の特性の間で適切なバランスを提供します。 しかし、次のセクションで説明するように、調整可能な点は多数あります。
ランタイム速度の最大化
次のビルド設定オプションは、主にランタイム速度を最大化するように設計されています。 その一部は、バイナリサイズを削減する場合もあります。
コード生成ユニット
Rust コンパイラーは、コンパイルを並列化(したがって高速化)するために、クレートを
複数の コード生成ユニット に分割します。しかし、これにより、
潜在的な最適化の一部を見逃す可能性があります。コンパイル時間が増加する代わりに、
ユニット数を1に設定することで、ランタイム速度を向上させ、バイナリサイズを
削減できる場合があります。Cargo.toml ファイルに次の行を追加します。
[profile.release]
codegen-units = 1
リンク時最適化
リンク時最適化(LTO)は、ランタイム速度を10〜20%以上 向上させ、さらにバイナリサイズも削減できる、プログラム全体を対象とした 最適化手法です。その代償としてコンパイル時間は悪化します。LTO にはいくつかの 形態があります。
LTO の最初の形態は、軽量な LTO である thin local LTO です。
デフォルトでは、コンパイラーは0ではない最適化レベルを伴う任意のビルドで
これを使用します。これにはリリースビルドが含まれます。このレベルの
LTO を明示的に要求するには、Cargo.toml ファイルに次の行を入れます。
[profile.release]
lto = false
LTO の2つ目の形態は thin LTO です。これは少しより積極的で、
コンパイル時間も増加させる一方で、ランタイム速度を向上させ、
バイナリサイズを削減する可能性が高いものです。有効にするには、
Cargo.toml で lto = "thin" を使用します。
LTO の3つ目の形態は fat LTO です。これはさらに積極的で、
ビルド時間を再び増加させる一方で、パフォーマンスをさらに向上させ、
バイナリサイズをさらに削減する可能性があります(ただし 常にそうとは限りません)。
有効にするには、Cargo.toml で lto = "fat" を使用します。
最後に、LTO を完全に無効化することも可能です。これにより、ランタイム速度は
悪化し、バイナリサイズは増加する可能性が高いものの、コンパイル時間は短縮されます。
これには Cargo.toml で lto = "off" を使用します。これは
lto = false オプションとは異なることに注意してください。前述のとおり、
lto = false は thin local LTO を有効なままにします。
代替アロケーター
Rust プログラムで使用されるデフォルトの(システム)ヒープアロケーターを、 代替アロケーターに置き換えることが可能です。正確な効果は個々のプログラムと 選択した代替アロケーターに依存しますが、実際にはランタイム速度の大幅な向上と メモリ使用量の大幅な削減が確認されています。各プラットフォームの システムアロケーターにはそれぞれ固有の長所と短所があるため、効果は プラットフォームによっても異なります。代替アロケーターの使用は、 バイナリサイズとコンパイル時間を増加させる可能性も高いです。
jemalloc
LinuxとMacで人気のある代替アロケータの1つは jemalloc で、tikv-jemallocator クレート経由で使用できます。これを使用するには、Cargo.toml ファイルに依存関係を追加します。
[dependencies]
tikv-jemallocator = "0.5"
次に、Rustコードに以下を追加します。例えば src/main.rs の先頭に追加します。
#[global_allocator]
static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
さらにLinuxでは、jemallocを透過的巨大ページ(THP)を使用するように構成できます。これにより、メモリ使用量が増える可能性はありますが、プログラムをさらに高速化できる場合があります。
これは、プログラムをビルドする前に MALLOC_CONF 環境変数(または場合によっては _RJEM_MALLOC_CONF)を適切に設定することで行います。例:
MALLOC_CONF="thp:always,metadata_thp:always" cargo build --release
コンパイル済みプログラムを実行するシステムも、THPをサポートするように構成されている必要があります。詳細については、このブログ記事を参照してください。
mimalloc
多くのプラットフォームで動作するもう1つの代替アロケータは mimalloc で、mimalloc クレート経由で使用できます。これを使用するには、Cargo.toml ファイルに依存関係を追加します。
[dependencies]
mimalloc = "0.1"
次に、Rustコードに以下を追加します。例えば src/main.rs の先頭に追加します。
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
CPU固有命令
古い(または他の種類の)プロセッサーでのバイナリの互換性を気にしない場合は、x86-64 CPU向けのAVX SIMD命令など、特定のCPUアーキテクチャに固有の最新の(そして潜在的に最速の)命令を生成するようコンパイラに指示できます。
コマンドラインからこれらの命令を要求するには、-C target-cpu=native フラグを使用します。例:
RUSTFLAGS="-C target-cpu=native" cargo build --release
あるいは、1つ以上のプロジェクトについて、config.toml ファイルからこれらの命令を要求するには、以下の行を追加します。
[build]
rustflags = ["-C", "target-cpu=native"]
これにより、特にコンパイラがコード内にベクトル化の機会を見つけた場合、ランタイム速度が向上する可能性があります。
-C target-cpu=native が最適に機能しているかどうかわからない場合は、rustc --print cfg と rustc --print cfg -C target-cpu=native の出力を比較して、後者の場合にCPU機能が正しく検出されているかを確認してください。そうでない場合は、-C target-feature を使用して特定の機能をターゲットにできます。
プロファイルガイド最適化
プロファイルガイド最適化(PGO)は、プログラムをコンパイルし、プロファイリングデータを収集しながらサンプルデータ上で実行し、そのプロファイリングデータを使用してプログラムの2回目のコンパイルを導くコンパイルモデルです。これにより、ランタイム速度が10%以上向上する可能性があります。 例1, 例2。
これはセットアップに多少の労力を要する高度な手法ですが、場合によっては価値があります。詳細については、rustc PGOドキュメントを参照してください。また、cargo-pgo コマンドを使用すると、Rustバイナリの最適化にPGO(および類似の BOLT)を使いやすくなります。
残念ながら、PGOはcrates.ioでホストされ、cargo install 経由で配布されるバイナリではサポートされていないため、その有用性は制限されます。
バイナリサイズの最小化
以下のビルド構成オプションは、主にバイナリサイズを最小化するように設計されています。ランタイム速度への影響はさまざまです。
最適化レベル
Cargo.toml ファイルに以下の行を追加することで、バイナリサイズの最小化を目指す最適化レベルを要求できます。
[profile.release]
opt-level = "z"
これにより、ランタイム速度も低下する可能性があります。
代替案は opt-level = "s" で、これはバイナリサイズの最小化を少し控えめにターゲットにします。opt-level = "z" と比較すると、わずかに多いインライン化とループのベクトル化も許可します。
panic! 時にアボート
パニック時にアンワインドする必要がない場合、例えばプログラムが catch_unwind を使用していないためであれば、コンパイラに単にパニック時にアボートするよう指示できます。パニック時でも、プログラムはバックトレースを生成します。
これにより、バイナリサイズが小さくなり、ランタイム速度がわずかに向上する可能性があり、コンパイル時間もわずかに短縮される場合があります。Cargo.toml ファイルに以下の行を追加します。
[profile.release]
panic = "abort"
シンボルのストリップ
Cargo.toml に以下の行を追加することで、リリースビルドからシンボルをストリップするようコンパイラに指示できます。
[profile.release]
strip = "symbols"
例。
ただし、シンボルをストリップすると、コンパイル済みプログラムのデバッグやプロファイリングがより難しくなる可能性があります。例えば、ストリップされたプログラムがパニックした場合、生成されるバックトレースには通常より有用な情報が少なくなる可能性があります。正確な影響はプラットフォームによって異なります。
デバッグ情報をリリースビルドからストリップする必要はありません。デフォルトでは、ローカルのリリースビルドではデバッグ情報は生成されず、標準ライブラリのデバッグ情報はリリースビルドでRust 1.77以降自動的にストリップされています。
その他のアイデア
より高度なバイナリサイズ最小化手法については、優れた min-sized-rust リポジトリにある包括的なドキュメントを参照してください。
コンパイル時間の最小化
以下のビルド構成オプションは、主にコンパイル時間を最小化するように設計されています。
リンク
コンパイル時間の大部分は実際にはリンク時間であり、特に小さな変更後にプログラムを再ビルドする場合に顕著です。一部のプラットフォームでは、デフォルトのリンカーより高速なリンカーを選択できます。 1つの選択肢は lld です。これは Linux と Windows で利用できます。lld は Rust 1.90 以降、Linux でデフォルトのリンカーになっています。Windows ではまだデフォルトではありませんが、ほとんどのユースケースで動作するはずです。
コマンドラインから lld を指定するには、-C link-arg=-fuse-ld=lld フラグを使用します。例:
RUSTFLAGS="-C link-arg=-fuse-ld=lld" cargo build --release
または、1つ以上のプロジェクトに対して config.toml ファイルから lld を指定するには、次の行を追加します:
[build]
rustflags = ["-C", "link-arg=-fuse-ld=lld"]
lld の完全なサポートを追跡している GitHub Issue があります。
もう1つの選択肢は mold です。これは現在 Linux で利用できます。
上記の手順で lld を mold に置き換えるだけです。mold は多くの場合、lld より高速です。
例。
ただし、これはかなり新しいものでもあり、すべての場合で動作するとは限りません。
最後の選択肢は wild です。これは現在 Linux でのみ利用できます。mold よりさらに高速な可能性がありますが、成熟度は低くなります。
Mac では、システムリンカーが高速であるため、代替リンカーは必要ありません。
この章の他の選択肢とは異なり、別のリンカーを選択することにトレードオフはありません。通常とは異なることをしていない限り、リンカーはプログラムに対して正しく動作する可能性が高く、その場合、代替リンカーはデメリットなしに劇的に高速になることがあります。
デバッグ情報生成の無効化
release ビルドは最高のパフォーマンスを提供しますが、dev ビルドはより速くビルドできるため、開発中は多くの人が dev ビルドを使用します。dev ビルドを使用しているものの、デバッガーをあまり使わない場合は、debuginfo を無効にすることを検討してください。これにより、dev ビルド時間を大幅に改善でき、20〜40% ほど短縮できることがあります。 例。
デバッグ情報の生成を無効にするには、Cargo.toml ファイルに次の行を追加します:
[profile.dev]
debug = false
これは、スタックトレースに行情報が含まれなくなることを意味する点に注意してください。その行情報を保持したいが、デバッガー向けの完全な情報は必要ない場合は、代わりに debug = "line-tables-only" を使用できます。これでもコンパイル時間に関する利点の大半は得られます。
実験的な並列フロントエンド
nightly Rust を使用している場合は、実験的な並列フロントエンドを有効にできます。これはコンパイル時のメモリ使用量が増える代わりに、コンパイル時間を短縮する可能性があります。生成されるコードの品質には影響しません。
これは、たとえば次のように RUSTFLAGS に -Zthreads=N を追加することで行えます:
RUSTFLAGS="-Zthreads=8" cargo build --release
または、1つ以上のプロジェクトに対して config.toml ファイルから並列フロントエンドを有効にするには、次の行を追加します:
[build]
rustflags = ["-Z", "threads=8"]
8 以外の値も可能ですが、最良の結果をもたらす傾向があるのはその数です。
最良の場合、実験的な並列フロントエンドはコンパイル時間を最大 50% 短縮します。しかし効果には大きなばらつきがあり、コードの特性とそのビルド構成に依存します。また、一部のプログラムではコンパイル時間の改善がありません。
Cranelift コード生成バックエンド
nightly Rust を使用している場合、一部のプラットフォームで Cranelift コード生成バックエンドを有効にできます。これは生成されるコードの品質が低下する代わりに、コンパイル時間を短縮する可能性があります。そのため、release ビルドではなく dev ビルドに推奨されます。
まず、次の rustup コマンドでバックエンドをインストールします:
rustup component add rustc-codegen-cranelift-preview --toolchain nightly
コマンドラインから Cranelift を選択するには、-Zcodegen-backend=cranelift フラグを使用します。例:
RUSTFLAGS="-Zcodegen-backend=cranelift" cargo +nightly build
または、1つ以上のプロジェクトに対して config.toml ファイルから Cranelift を指定するには、次の行を追加します:
[unstable]
codegen-backend = true
[profile.dev]
codegen-backend = "cranelift"
詳細については、Cranelift ドキュメントを参照してください。
カスタムプロファイル
dev および release プロファイルに加えて、Cargo はカスタムプロファイルをサポートしています。たとえば、dev ビルドの実行速度が不十分で、release ビルドのコンパイル時間が日常的な開発には遅すぎると感じる場合、dev と release の中間にあるカスタムプロファイルを作成すると役立つかもしれません。
まとめ
ビルド構成に関しては、多くの選択を行う必要があります。以下のポイントは、上記の情報をいくつかの推奨事項としてまとめたものです。
- 実行時速度を最大化したい場合は、次のすべてを検討してください:
codegen-units = 1、lto = "fat"、代替アロケーター、およびpanic = "abort"。 - バイナリサイズを最小化したい場合は、
opt-level = "z"、codegen-units = 1、lto = "fat"、panic = "abort"、およびstrip = "symbols"を検討してください。 - どちらの場合でも、広範なアーキテクチャサポートが不要であれば
-C target-cpu=nativeを検討し、配布メカニズムで機能するのであればcargo-pgoを検討してください。 - それをサポートしているプラットフォームを使用している場合は、常により高速なリンカーを使用してください。そうすることにデメリットはないためです。
- これらの選択について追加の支援が必要な場合は、
cargo-wizardを使用してください。 - すべての変更を一度に1つずつベンチマークし、期待される効果があることを確認してください。
最後に、この issue は Rust コンパイラー自身のビルド構成の進化を追跡しています。Rust コンパイラーのビルドシステムは、ほとんどの Rust プログラムのものよりも特殊で複雑です。それでも、この issue はビルド構成の選択を大規模なプログラムにどのように適用できるかを示す点で有益かもしれません。
リント
Clippy は、Rust コードでよくある誤りを検出するための lint のコレクションです。一般的に Rust コードに対して実行するのに優れたツールです。また、多数の lint が、最適とはいえないパフォーマンスを引き起こし得るコードパターンに関係しているため、パフォーマンス改善にも役立ちます。
問題の自動検出は手動検出より望ましいため、この本の以降の部分では、Clippy がデフォルトで検出するパフォーマンス上の問題については触れません。
基本
インストール後は、簡単に実行できます。
cargo clippy
パフォーマンス lint の完全な一覧は、lint list にアクセスし、“Perf” 以外の lint グループをすべて選択解除することで確認できます。
パフォーマンス lint の提案は、コードを高速化するだけでなく、通常はよりシンプルでより慣用的なコードにつながるため、頻繁に実行されないコードであっても従う価値があります。
逆に、パフォーマンス以外の lint の提案がパフォーマンスを改善することもあります。たとえば、ptr_arg スタイル lint は、&mut Vec<T> 引数を &mut [T] に変更するなど、さまざまなコンテナ引数をスライスに変更することを提案します。ここでの主な動機は、スライスによってより柔軟な API が得られることですが、間接参照が少なくなり、コンパイラにとって最適化の機会が増えるため、より高速なコードにつながる可能性もあります。
例。
型の禁止
以降の章では、より高速な代替手段を優先して、特定の標準ライブラリ型を避ける価値がある場合があることを見ていきます。これらの代替手段を使うと決めた場合でも、誤って一部の場所で標準ライブラリ型を使ってしまうことは簡単に起こります。
この問題を避けるために、Clippy の disallowed_types lint を使うことができます。たとえば、標準のハッシュテーブルの使用を禁止するには(理由は Hashing セクションで説明します)、次の行を含む clippy.toml ファイルをコードに追加します。
disallowed-types = ["std::collections::HashMap", "std::collections::HashSet"]
プロファイリング
プログラムを最適化するときは、プログラムのどの部分が「ホット」(実行時性能に影響するほど頻繁に実行される)であり、変更する価値があるのかを判断する方法も必要です。これはプロファイリングによって行うのが最適です。
プロファイラー
利用可能なプロファイラーは数多くあり、それぞれに長所と短所があります。以下は、Rust プログラムで正常に使用されてきたプロファイラーの不完全な一覧です。
- perf は、ハードウェアパフォーマンスカウンターを使用する汎用プロファイラーです。 Hotspot と Firefox Profiler は、perf によって記録されたデータを表示するのに適しています。 Linux で動作します。
- Instruments は、macOS の Xcode に付属する汎用プロファイラーです。
- Intel VTune Profiler は汎用プロファイラーです。Windows、Linux、macOS で動作します。
- AMD μProf は汎用プロファイラーです。Windows と Linux で動作します。
- samply は、Firefox Profiler で表示できるプロファイルを生成するサンプリングプロファイラーです。 Mac、Linux、Windows で動作します。
- flamegraph は、perf/DTrace を使用してコードをプロファイリングし、その結果をフレームグラフで表示する Cargo コマンドです。Linux と、DTrace をサポートするすべてのプラットフォーム(macOS、FreeBSD、NetBSD、および場合によっては Windows)で動作します。
- Cachegrind と Callgrind は、グローバル単位、関数単位、ソース行単位の命令数、およびシミュレートされたキャッシュと分岐予測のデータを提供します。Linux と一部の他の Unix で動作します。
- DHAT は、コードのどの部分が多数のアロケーションを引き起こしているかを見つけるのに適しており、ピークメモリ使用量に関する洞察を得るのにも役立ちます。また、
memcpyへのホットな呼び出しを特定するためにも使用できます。Linux と一部の他の Unix で動作します。dhat-rs は実験的な代替手段であり、少し機能は弱く、Rust プログラムに小さな変更が必要ですが、すべてのプラットフォームで動作します。 - heaptrack と bytehound はヒーププロファイリングツールです。Linux で動作します。
countsはアドホックなプロファイリングをサポートしており、eprintln!文の使用と頻度ベースの後処理を組み合わせます。これは、コードの一部についてドメイン固有の洞察を得るのに適しています。すべてのプラットフォームで動作します。- Coz は、最適化の可能性を測定するために因果プロファイリングを実行し、coz-rs を通じて Rust をサポートしています。Linux で動作します。
デバッグ情報
リリースビルドを効果的にプロファイリングするには、ソース行のデバッグ情報を有効にする必要がある場合があります。これを行うには、Cargo.toml ファイルに次の行を追加します。
[profile.release]
debug = "line-tables-only"
debug 設定の詳細については、Cargo documentation を参照してください。
残念ながら、上記の手順を実行しても、標準ライブラリコードに関する詳細なプロファイリング情報は得られません。これは、配布されている Rust 標準ライブラリのバージョンがデバッグ情報付きでビルドされていないためです。
これを回避する最も信頼できる方法は、these instructions に従って独自のコンパイラーと標準ライブラリをビルドし、リポジトリルートの bootstrap.toml ファイルに次の行を追加することです。
[rust]
debuginfo-level = 1
これは手間がかかりますが、場合によってはその労力に見合うことがあります。
あるいは、不安定な build-std 機能を使用すると、プログラムの通常のコンパイルの一部として、同じビルド設定で標準ライブラリをコンパイルできます。ただし、標準ライブラリのデバッグ情報に含まれるファイル名はソースコードファイルを指しません。これは、この機能が標準ライブラリのソースコードもダウンロードするわけではないためです。そのため、この方法は、完全に動作するためにソースコードを必要とする Cachegrind や samply などのプロファイラーには役立ちません。
フレームポインター
Rust コンパイラーはフレームポインターを最適化で取り除く場合があり、これによりスタックトレースなどのプロファイリング情報の品質が低下することがあります。コンパイラーにフレームポインターを使用させるには、-C force-frame-pointers=yes フラグを使用します。例:
RUSTFLAGS="-C force-frame-pointers=yes" cargo build --release
あるいは、(1 つ以上のプロジェクトに対して)config.toml ファイルからフレームポインターの使用を強制するには、次の行を追加します。
[build]
rustflags = ["-C", "force-frame-pointers=yes"]
シンボルのデマングリング
Rust は、コンパイル済みコード内で関数名をエンコードするために、名前マングリングの一形式を使用します。プロファイラーがこれを認識していない場合、その出力には _ZN3foo3barE や _ZN28_$u7b$$u7b$closure$u7d$$u7d$E、または _RMCsno73SFvQKx_1cINtB0_3StrKRe616263_E のように、_ZN または _R で始まるシンボル名が含まれることがあります。
このような名前は、rustfilt を使用して手動でデマングルできます。
プロファイリング中にシンボルのデマングリングで問題が発生している場合は、mangling format をデフォルトのレガシー形式から新しい v0 形式に変更する価値があるかもしれません。
コマンドラインから v0 形式を使用するには、-C symbol-mangling-version=v0 フラグを使用します。例:
RUSTFLAGS="-C symbol-mangling-version=v0" cargo build --release
あるいは、(1 つ以上のプロジェクトに対して)config.toml ファイルからこれらの命令を要求するには、次の行を追加します。
[build]
rustflags = ["-C", "symbol-mangling-version=v0"]
インライン化
ホットでインライン化されていない関数へのエントリとそこからの退出は、多くの場合、実行時間の無視できない割合を占めます。これらの関数をインライン化すると、こうしたエントリと退出が取り除かれ、コンパイラによる追加の低レベル最適化が可能になることがあります。最良の場合、全体的な効果は小さいものの、容易に得られる速度向上となります。
Rust の関数で使用できるインライン属性は 4 つあります。
- なし。その関数をインライン化すべきかどうかは、コンパイラが自ら判断します。 これは、最適化レベル、関数のサイズ、関数がジェネリックかどうか、インライン化が クレート境界をまたぐかどうか、といった要因に依存します。
#[inline]。これは、その関数をインライン化すべきであることを示唆します。#[inline(always)]。これは、その関数をインライン化すべきであることを強く示唆します。#[inline(never)]。これは、その関数をインライン化すべきでないことを強く示唆します。
インライン属性は、関数がインライン化されること、またはインライン化されないことを保証するものではありませんが、実際には #[inline(always)] は、ごく例外的な場合を除いてインライン化を引き起こします。
インライン化は推移的ではありません。関数 f が関数 g を呼び出しており、f の呼び出し箇所で両方の関数をまとめてインライン化したい場合は、両方の関数にインライン属性を付けるべきです。
単純なケース
インライン化に最適な候補は、(a) 非常に小さい関数、または (b) 呼び出し箇所が 1 つしかない関数です。コンパイラは、インライン属性がなくても、こうした関数を自らインライン化することがよくあります。しかし、コンパイラが常に最善の選択をできるとは限らないため、属性が必要になる場合があります。 例 1, 例 2, 例 3, 例 4, 例 5.
Cachegrind は、関数がインライン化されているかどうかを判断するのに適したプロファイラです。Cachegrind の出力を見るとき、関数の最初と最後の行にイベントカウントが付いていない場合に限り、その関数がインライン化されていることが分かります。例:
. #[inline(always)]
. fn inlined(x: u32, y: u32) -> u32 {
700,000 eprintln!("inlined: {} + {}", x, y);
200,000 x + y
. }
.
. #[inline(never)]
400,000 fn not_inlined(x: u32, y: u32) -> u32 {
700,000 eprintln!("not_inlined: {} + {}", x, y);
200,000 x + y
200,000 }
インライン属性を追加した後は、再度測定すべきです。効果は予測しにくいことがあるためです。以前はインライン化されていた近くの関数がインライン化されなくなり、効果がなくなる場合があります。コードが遅くなる場合もあります。インライン化はコンパイル時間にも影響することがあります。特に、関数の内部表現の複製を伴うクレート間インライン化ではその傾向があります。
より難しいケース
関数が大きく、複数の呼び出し箇所を持つものの、ホットな呼び出し箇所が 1 つだけである場合があります。速度のためにホットな呼び出し箇所ではインライン化したい一方で、不要なコード肥大化を避けるためにコールドな呼び出し箇所ではインライン化したくない、という場合です。これを扱う方法は、その関数を常にインライン化されるバリアントと決してインライン化されないバリアントに分割し、後者が前者を呼び出すようにすることです。
たとえば、この関数は次のようになります。
#![allow(unused)] fn main() { fn one() {}; fn two() {}; fn three() {}; fn my_function() { one(); two(); three(); } }
これは次の 2 つの関数になります。
#![allow(unused)] fn main() { fn one() {}; fn two() {}; fn three() {}; // ホットな呼び出し箇所でこれを使用します。 #[inline(always)] fn inlined_my_function() { one(); two(); three(); } // コールドな呼び出し箇所でこれを使用します。 #[inline(never)] fn uninlined_my_function() { inlined_my_function(); } }
アウトライン化
インライン化の逆は アウトライン化 です。つまり、めったに実行されないコードを別の関数に移動することです。このような関数には #[cold] 属性を追加して、その関数がめったに呼び出されないことをコンパイラに伝えることができます。これにより、ホットパスに対してより良いコード生成が行われる可能性があります。
例 1,
例 2.
ハッシュ化
HashSet と HashMap は広く使われている 2 つの型であり、これらを
より高速にする方法があります。
代替ハッシャー
デフォルトのハッシュアルゴリズムは仕様として定められていませんが、本書執筆時点での デフォルトは SipHash 1-3 というアルゴリズムです。このアルゴリズムは高品質であり、 衝突に対する高い保護を提供しますが、比較的低速で、 特に整数のような短いキーでは遅くなります。
プロファイリングによりハッシュ化がホットスポットになっていることが示され、HashDoS 攻撃がアプリケーションにとって懸念事項でない場合、 より高速なハッシュアルゴリズムを使ったハッシュテーブルを使用すると、 大きな速度向上を得られる可能性があります。
rustc-hashは、HashSetとHashMapのドロップイン置き換えとなるFxHashSet型とFxHashMap型を提供します。そのハッシュアルゴリズムは 低品質ですが非常に高速で、特に整数キーで高速です。また、rustc 内では 他のすべてのハッシュアルゴリズムを上回ることがわかっています。(fxhashは、同じ アルゴリズムと型の古い実装であり、メンテナンスはあまり行き届いていません。)fnvはFnvHashSet型とFnvHashMap型を提供します。そのハッシュアルゴリズムはrustc-hashのものより高品質ですが、少し低速です。ahashはAHashSetとAHashMapを提供します。そのハッシュアルゴリズムは、 一部のプロセッサで利用可能な AES 命令サポートを活用できます。
プログラムでハッシュ化の性能が重要な場合、これらの代替手段を複数試してみる価値があります。 たとえば、rustc では次の結果が確認されています。
fnvからfxhashへの切り替えにより、最大 6% の高速化が得られました。fxhashからahashへ切り替える試みでは、1〜4% の低速化という結果になりました。fxhashからデフォルトのハッシャーへ戻す試みでは、 4〜84% の範囲の低速化という結果になりました!
FxHashSet/FxHashMap のような代替手段の 1 つを全体的に使用すると決めた場合でも、
一部の箇所でうっかり HashSet/HashMap を使ってしまうことは簡単に起こります。
この問題を避けるには、Clippy を使用できます。
ハッシュ化を必要としない型もあります。たとえば、整数をラップする newtype があり、
その整数値がランダム、またはランダムに近い場合があります。そのような型では、
ハッシュ化された値の分布は、値自体の分布とそれほど大きくは異なりません。
この場合、nohash_hasher crate が役立つことがあります。
ハッシュ関数の設計は複雑なトピックであり、本書の範囲外です。
ahash documentation には優れた議論があります。
バイト単位のハッシュ化
型に #[derive(Hash)] を注釈すると、生成される hash メソッドは
各フィールドを別々にハッシュ化します。一部のハッシュ関数では、
型を生バイトに変換し、そのバイト列をストリームとしてハッシュ化する方が高速な場合があります。
これは、パディングバイトがないなどの特定の性質を満たす型で可能です。
zerocopy crate と bytemuck crate はどちらも、この種のバイト単位のハッシュ化を行う
hash メソッドを生成する #[derive(ByteHash)] マクロを提供しています。
derive_hash_fast crate の README には、この手法についてより詳しい説明があります。
これは高度な手法であり、性能への影響はハッシュ関数とハッシュ化される型の正確な構造に大きく依存します。 慎重に測定してください。
ヒープ割り当て
ヒープ割り当てはそれなりに高コストです。正確な詳細は使用中の アロケータによって異なりますが、各割り当て(および解放)では通常、 グローバルロックの取得、重要なデータ構造操作、 そして場合によってはシステムコールの実行が伴います。小さな割り当てが 大きな割り当てより必ずしも低コストであるとは限りません。どの Rust の データ構造や操作が割り当てを引き起こすかを理解しておく価値があります。 それらを避けることで、パフォーマンスを大幅に改善できる可能性があるためです。
Rust Container Cheat Sheet には一般的な Rust 型の可視化が掲載されており、 以降のセクションの優れた副読本になります。
プロファイリング
汎用プロファイラで malloc、free、および関連する関数がホットであると
示される場合、割り当て率を下げることや、代替アロケータを使用することを
試す価値がある可能性が高いです。
DHAT は割り当て率を下げる際に使用する優れたプロファイラです。 Linux と一部の他の Unix で動作します。ホットな割り当て箇所と その割り当て率を正確に特定します。正確な結果はさまざまですが、 rustc での経験では、実行された 100 万命令あたり 10 回の割り当てを 削減すると、測定可能なパフォーマンス改善(例: 約 1%)が得られることが 示されています。
以下は DHAT からの出力例です。
AP 1.1/25 (2 children) {
Total: 54,533,440 bytes (4.02%, 2,714.28/Minstr) in 458,839 blocks (7.72%, 22.84/Minstr), avg size 118.85 bytes, avg lifetime 1,127,259,403.64 instrs (5.61% of program duration)
At t-gmax: 0 bytes (0%) in 0 blocks (0%), avg size 0 bytes
At t-end: 0 bytes (0%) in 0 blocks (0%), avg size 0 bytes
Reads: 15,993,012 bytes (0.29%, 796.02/Minstr), 0.29/byte
Writes: 20,974,752 bytes (1.03%, 1,043.97/Minstr), 0.38/byte
Allocated at {
#1: 0x95CACC9: alloc (alloc.rs:72)
#2: 0x95CACC9: alloc (alloc.rs:148)
#3: 0x95CACC9: reserve_internal<syntax::tokenstream::TokenStream,alloc::alloc::Global> (raw_vec.rs:669)
#4: 0x95CACC9: reserve<syntax::tokenstream::TokenStream,alloc::alloc::Global> (raw_vec.rs:492)
#5: 0x95CACC9: reserve<syntax::tokenstream::TokenStream> (vec.rs:460)
#6: 0x95CACC9: push<syntax::tokenstream::TokenStream> (vec.rs:989)
#7: 0x95CACC9: parse_token_trees_until_close_delim (tokentrees.rs:27)
#8: 0x95CACC9: syntax::parse::lexer::tokentrees::<impl syntax::parse::lexer::StringReader<'a>>::parse_token_tree (tokentrees.rs:81)
}
}
この例のすべてを説明することは本書の範囲外ですが、DHAT が割り当てに 関する豊富な情報を提供することは明らかなはずです。たとえば、割り当てが どこでどれくらいの頻度で発生するか、どれくらいの大きさか、どれくらいの 期間生存するか、どれくらいの頻度でアクセスされるかといった情報です。
Box
Box は最も単純なヒープ割り当て型です。Box<T> 値は、ヒープ上に
割り当てられた T 値です。
型を小さくするために、構造体の 1 つ以上のフィールドや enum のフィールドを Box 化する価値がある場合があります。(これについて詳しくは 型サイズ の章を参照してください。)
それ以外の点では、Box は単純であり、最適化の余地はあまりありません。
Rc/Arc
Rc/Arc は Box に似ていますが、ヒープ上の値には 2 つの参照カウントが
付随します。これらは値の共有を可能にし、メモリ使用量を削減する効果的な
方法になり得ます。
しかし、めったに共有されない値に使うと、本来はヒープ割り当てされなかった 可能性のある値をヒープ割り当てすることになり、割り当て率が増える可能性が あります。例。
Box とは異なり、Rc/Arc 値に対して clone を呼び出しても割り当ては
発生しません。代わりに、参照カウントを単にインクリメントするだけです。
Vec
Vec はヒープ割り当て型であり、割り当て回数の最適化や、無駄な領域の量の
最小化について大きな余地があります。これを行うには、その要素がどのように
格納されるかを理解する必要があります。
Vec には、長さ、容量、ポインタという 3 つのワードが含まれます。容量が
非ゼロで、要素サイズが非ゼロの場合、そのポインタはヒープ割り当てされた
メモリを指します。それ以外の場合、割り当てられたメモリは指しません。
Vec 自体がヒープ割り当てされていない場合でも、要素(存在し、サイズが
非ゼロの場合)は常にヒープ割り当てされます。非ゼロサイズの要素が存在する
場合、それらの要素を保持するメモリは必要以上に大きいことがあり、将来追加
される要素のための領域を提供します。存在する要素の数が長さであり、再割り
当てなしに保持できる要素の数が容量です。
ベクターが現在の容量を超えて成長する必要がある場合、要素はより大きな ヒープ割り当てにコピーされ、古いヒープ割り当ては解放されます。
Vec の成長
一般的な方法
(vec![]
または Vec::new、または Vec::default)で作成された新しい空の Vec は、
長さと容量がゼロであり、ヒープ割り当ては不要です。個々の要素を Vec の
末尾に繰り返し push していくと、定期的に再割り当てが行われます。成長戦略は
仕様として定められていませんが、執筆時点では準倍増戦略を使用しており、
結果として容量は 0、4、8、16、32、64、というようになります。(実用上
多くの割り当てを避けられる ため、1 と 2 を経由する代わりに 0 から 4 へ
直接飛びます。)ベクターが成長するにつれて、再割り当ての頻度は指数関数的に
減少しますが、無駄になる可能性のある余剰容量の量は指数関数的に増加します。
この成長戦略は、成長可能なデータ構造としては典型的であり、一般的な場合には
妥当です。しかし、ベクターのおおよその長さが事前に分かっている場合は、
より良くできることがよくあります。ホットなベクター割り当て箇所(例: ホットな
Vec::push 呼び出し)がある場合、その箇所で eprintln! を使用して
ベクターの長さを出力し、その後(たとえば counts を使って)後処理を行い、
長さの分布を判断する価値があります。たとえば、多数の短いベクターがあるかも
しれませんし、少数の非常に長いベクターがあるかもしれません。そして割り当て
箇所を最適化する最善の方法は、それに応じて異なります。
短い Vec
短いベクターが多数ある場合は、smallvec crate の SmallVec 型を使用できます。SmallVec<[T; N]> は Vec のドロップイン置換であり、SmallVec 自体の内部に N 個の要素を格納できます。そして、要素数がそれを超えるとヒープ割り当てに切り替わります。(vec![] リテラルも smallvec![] リテラルに置き換える必要があることにも注意してください。)
例 1,
例 2.
SmallVec は適切に使用すれば割り当て率を確実に下げますが、その使用がパフォーマンス向上を保証するわけではありません。通常の操作では、要素がヒープに割り当てられているかどうかを常に確認する必要があるため、Vec よりもわずかに遅くなります。また、N が大きい場合や T が大きい場合、SmallVec<[T; N]> 自体が Vec<T> よりも大きくなることがあり、SmallVec 値のコピーは遅くなります。いつものように、最適化が有効であることを確認するにはベンチマークが必要です。
短いベクターが多数あり、かつ その最大長を正確に知っている場合は、arrayvec crate の ArrayVec が SmallVec よりも適した選択肢です。ヒープ割り当てへのフォールバックが不要なため、少し高速です。
例.
より長い Vec
ベクターの最小サイズまたは正確なサイズがわかっている場合は、Vec::with_capacity、Vec::reserve、または Vec::reserve_exact を使用して特定の容量を予約できます。たとえば、あるベクターが少なくとも 20 個の要素を持つまで成長することがわかっている場合、これらの関数は単一の割り当てで少なくとも 20 の容量を持つベクターを即座に提供できます。一方、項目を 1 つずつ push すると、4 回の割り当て(容量 4、8、16、32)が発生します。
例.
ベクターの最大長がわかっている場合、上記の関数により、余分な領域を不要に割り当てないようにすることもできます。同様に、Vec::shrink_to_fit を使用して無駄な領域を最小限に抑えることができますが、再割り当てが発生する可能性がある点に注意してください。
String
String はヒープに割り当てられたバイト列を含みます。String の表現と操作は Vec<u8> のそれと非常によく似ています。成長や容量に関連する多くの Vec メソッドには、String::with_capacity など、String 用の同等のものがあります。
smallstr crate の SmallString 型は、SmallVec 型に似ています。
smartstring crate の String 型は、3 ワード分未満の文字を持つ文字列についてヒープ割り当てを回避する、String のドロップイン置換です。64 ビットプラットフォームでは、これは 24 バイト未満の任意の文字列であり、23 文字以下の ASCII 文字を含むすべての文字列が含まれます。
例.
format! マクロは String を生成するため、割り当てを実行することに注意してください。文字列リテラルを使用して format! 呼び出しを避けられる場合は、この割り当てを回避できます。
例.
std::format_args や lazy_format crate が役立つ場合があります。
ハッシュテーブル
HashSet と HashMap はハッシュテーブルです。割り当ての観点では、それらの表現と操作は Vec のものに似ています。キーと値を保持する単一の連続したヒープ割り当てを持ち、テーブルが成長するにつれて必要に応じて再割り当てされます。成長や容量に関連する多くの Vec メソッドには、HashSet::with_capacity など、HashSet/HashMap 用の同等のものがあります。
clone
ヒープに割り当てられたメモリを含む値に対して clone を呼び出すと、通常は追加の割り当てが発生します。たとえば、空でない Vec に対して clone を呼び出すには、要素用の新しい割り当てが必要です(ただし、新しい Vec の容量が元の Vec の容量と同じであるとは限らないことに注意してください)。例外は Rc/Arc で、この場合 clone 呼び出しは参照カウントをインクリメントするだけです。
clone_from は clone の代替です。a.clone_from(&b) は a = b.clone() と等価ですが、不要な割り当てを回避できる場合があります。たとえば、ある Vec を既存の Vec の上に clone したい場合、次の例が示すように、可能であれば既存の Vec のヒープ割り当てが再利用されます。
#![allow(unused)] fn main() { let mut v1: Vec<u32> = Vec::with_capacity(99); let v2: Vec<u32> = vec![1, 2, 3]; v1.clone_from(&v2); // v1 の割り当てが再利用される assert_eq!(v1.capacity(), 99); }
clone は通常割り当てを引き起こしますが、多くの状況で使用するのは妥当であり、コードをより単純にできることがよくあります。プロファイリングデータを使用して、どの clone 呼び出しがホットであり、回避する労力をかける価値があるかを確認してください。
Rust コードには、(a) プログラマーの誤り、または (b) 以前は必要だった clone 呼び出しを不要にするコードの変更により、不要な clone 呼び出しが含まれてしまうことがあります。必要に見えないホットな clone 呼び出しを見つけた場合、単純に削除できることがあります。
例 1,
例 2,
例 3.
to_owned
[ToOwned::to_owned] は多くの一般的な型に対して実装されています。これは通常 clone によって、借用データから所有データを作成するため、多くの場合ヒープ割り当てを引き起こします。たとえば、&str から String を作成するために使用できます。
[ToOwned::to_owned]: https://doc.rust-lang.org/std/borrow/trait.ToOwned.html#tymethod.to_owned
to_owned の呼び出し(および clone や to_string などの関連する呼び出し)は、所有されたコピーではなく、借用されたデータへの参照を構造体に保存することで回避できる場合があります。これには構造体へのライフタイム注釈が必要になり、コードが複雑になるため、プロファイリングとベンチマークによって価値があることが示された場合にのみ行うべきです。
例。
Cow
コードが、借用されたデータと所有されたデータが混在したものを扱う場合があります。エラーメッセージのベクターを想像してください。その一部は静的文字列リテラルで、一部は format! で構築されます。明らかな表現は、次の例に示すように Vec<String> です。
#![allow(unused)] fn main() { let mut errors: Vec<String> = vec![]; errors.push("something went wrong".to_string()); errors.push(format!("something went wrong on line {}", 100)); }
これには、静的文字列リテラルを String に昇格させるための to_string 呼び出しが必要であり、アロケーションが発生します。
代わりに Cow 型を使用できます。これは、借用されたデータまたは所有されたデータのいずれかを保持できます。借用された値 x は Cow::Borrowed(x) でラップされ、所有された値 y は Cow::Owned(y) でラップされます。Cow はさまざまな文字列、スライス、パス型に対して From<T> トレイトも実装しているため、通常は into も使用できます。(または Cow::from も使用できます。これは長くなりますが、型がより明確になるため、コードの可読性が高くなります。)次の例では、これらをすべてまとめています。
#![allow(unused)] fn main() { use std::borrow::Cow; let mut errors: Vec<Cow<'static, str>> = vec![]; errors.push(Cow::Borrowed("something went wrong")); errors.push(Cow::Owned(format!("something went wrong on line {}", 100))); errors.push(Cow::from("something else went wrong")); errors.push(format!("something else went wrong on line {}", 101).into()); }
これで errors は、追加のアロケーションを必要とせずに、借用されたデータと所有されたデータの混在を保持します。この例では &str/String を扱っていますが、&[T]/Vec<T> や &Path/PathBuf などの他の組み合わせも可能です。
データが不変である場合、上記のすべてが当てはまります。しかし、Cow は、変更が必要になった場合に、借用されたデータを所有されたデータへ昇格させることもできます。Cow::to_mut は所有された値への可変参照を取得し、必要に応じてクローンします。これは「clone-on-write」と呼ばれ、Cow という名前の由来です。
この clone-on-write の挙動は、&str のような借用されたデータがあり、それがほとんど読み取り専用だが、ときどき変更する必要がある場合に有用です。
最後に、Cow は Deref を実装しているため、内包するデータに対してメソッドを直接呼び出すことができます。
Cow を動作させるのは扱いづらいことがありますが、多くの場合、その労力に見合います。
コレクションの再利用
Vec のようなコレクションを段階的に構築する必要がある場合があります。通常は、複数の Vec を構築してから結合するよりも、単一の Vec を変更することでこれを行う方が適切です。
たとえば、複数回呼び出される可能性がある Vec を生成する関数 do_stuff がある場合:
#![allow(unused)] fn main() { fn do_stuff(x: u32, y: u32) -> Vec<u32> { vec![x, y] } }
代わりに、渡された Vec を変更する方がよい場合があります:
#![allow(unused)] fn main() { fn do_stuff(x: u32, y: u32, vec: &mut Vec<u32>) { vec.push(x); vec.push(y); } }
再利用可能な「workhorse」コレクションを保持しておく価値がある場合があります。たとえば、ループの各イテレーションで Vec が必要な場合、ループの外側で Vec を宣言し、ループ本体内で使用してから、ループ本体の最後で clear を呼び出すことができます(Vec の容量に影響を与えずに Vec を空にするため)。これにより、各イテレーションでの Vec の使用が他のイテレーションと無関係であるという事実がわかりにくくなる代わりに、アロケーションを回避できます。
例 1,
例 2。
同様に、繰り返し呼び出される 1 つ以上のメソッドで再利用するために、構造体内に workhorse コレクションを保持しておく価値がある場合もあります。
ファイルから行を読み取る
BufRead::lines を使うと、ファイルを 1 行ずつ簡単に読み取ることができます:
#![allow(unused)] fn main() { fn blah() -> Result<(), std::io::Error> { fn process(_: &str) {} use std::io::{self, BufRead}; let mut lock = io::stdin().lock(); for line in lock.lines() { process(&line?); } Ok(()) } }
しかし、それが生成するイテレーターは io::Result<String> を返すため、ファイル内の各行ごとにアロケーションが発生します。
代替として、BufRead::read_line を使用するループ内で workhorse String を使う方法があります:
#![allow(unused)] fn main() { fn blah() -> Result<(), std::io::Error> { fn process(_: &str) {} use std::io::{self, BufRead}; let mut lock = io::stdin().lock(); let mut line = String::new(); while lock.read_line(&mut line)? != 0 { process(&line); line.clear(); } Ok(()) } }
これにより、アロケーションの回数は多くても数回、場合によっては 1 回だけに減ります。(正確な回数は、line を何回再アロケーションする必要があるかに依存し、それはファイル内の行の長さの分布に依存します。)
これは、ループ本体が String ではなく &str を扱える場合にのみ機能します。
例。
代替アロケーターの使用
コードを変更せずに、単に別のアロケーターを使用することで、ヒープアロケーションのパフォーマンスを向上させることも可能です。詳細については、Alternative Allocators セクションを参照してください。
リグレッションの回避
コードによって行われるアロケーションの数やサイズが意図せず増加しないようにするために、dhat-rs の heap usage testing 機能を使用して、特定のコードスニペットが期待される量のヒープメモリを割り当てることを確認するテストを書くことができます。
型サイズ
頻繁にインスタンス化される型を小さくすると、パフォーマンス向上に役立つことがあります。
たとえば、メモリ使用量が多い場合、DHAT のようなヒーププロファイラーを使うと、ホットな割り当て箇所と関係する型を特定できます。これらの型を小さくすると、ピークメモリ使用量を削減でき、メモリトラフィックとキャッシュ圧迫を減らすことでパフォーマンスが向上する可能性もあります。
さらに、128 バイトを超える Rust 型は、インラインコードではなく memcpy でコピーされます。プロファイルで memcpy が無視できない量として現れる場合、DHAT の “copy profiling” モードを使うと、ホットな memcpy 呼び出しがどこにあり、どの型が関係しているかを正確に教えてくれます。これらの型を 128 バイト以下に小さくすると、memcpy 呼び出しを避け、メモリトラフィックを減らすことで、コードを高速化できます。
型サイズの測定
std::mem::size_of は型のサイズをバイト単位で返しますが、多くの場合、正確なレイアウトも知りたいはずです。たとえば、1 つの過大なバリアントが原因で、enum が予想外に大きくなることがあります。
-Zprint-type-sizes オプションは、まさにこれを行います。これは rustc のリリース版では有効になっていないため、nightly 版の rustc を使用する必要があります。Cargo 経由で呼び出す一例を示します。
RUSTFLAGS=-Zprint-type-sizes cargo +nightly build --release
rustc を呼び出す一例を示します。
rustc +nightly -Zprint-type-sizes input.rs
これにより、使用されているすべての型について、サイズ、レイアウト、アラインメントの詳細が出力されます。たとえば、次の型の場合:
#![allow(unused)] fn main() { enum E { A, B(i32), C(u64, u8, u64, u8), D(Vec<u32>), } }
次の内容に加え、いくつかの組み込み型に関する情報が出力されます。
print-type-size type: `E`: 32 bytes, alignment: 8 bytes
print-type-size discriminant: 1 bytes
print-type-size variant `D`: 31 bytes
print-type-size padding: 7 bytes
print-type-size field `.0`: 24 bytes, alignment: 8 bytes
print-type-size variant `C`: 23 bytes
print-type-size field `.1`: 1 bytes
print-type-size field `.3`: 1 bytes
print-type-size padding: 5 bytes
print-type-size field `.0`: 8 bytes, alignment: 8 bytes
print-type-size field `.2`: 8 bytes
print-type-size variant `B`: 7 bytes
print-type-size padding: 3 bytes
print-type-size field `.0`: 4 bytes, alignment: 4 bytes
print-type-size variant `A`: 0 bytes
出力からは次のことが分かります。
- 型のサイズとアラインメント。
- enum の場合、判別子のサイズ。
- enum の場合、各バリアントのサイズ(大きいものから小さいものへソート)。
- すべてのフィールドのサイズ、アラインメント、順序。(コンパイラが
Eのサイズを最小化するために、バリアントCのフィールドを並べ替えていることに注意してください。) - すべてのパディングのサイズと位置。
あるいは、top-type-sizes クレートを使用して、出力をよりコンパクトな形式で表示できます。
ホットな型のレイアウトが分かれば、それを小さくする方法はいくつもあります。
フィールド順序
Rust コンパイラは、サイズを最小化するために、構造体と enum のフィールドを自動的にソートします(#[repr(C)] 属性が指定されている場合を除く)。そのため、フィールド順序を気にする必要はありません。ただし、ホットな型のサイズを最小化する方法はほかにもあります。
より小さい enum
enum に過大なバリアントがある場合、1 つ以上のフィールドを Box 化することを検討してください。たとえば、次の型を:
#![allow(unused)] fn main() { type LargeType = [u8; 100]; enum A { X, Y(i32), Z(i32, LargeType), } }
次のように変更できます。
#![allow(unused)] fn main() { type LargeType = [u8; 100]; enum A { X, Y(i32), Z(Box<(i32, LargeType)>), } }
これにより、A::Z バリアントに追加のヒープ割り当てが必要になる代わりに、型サイズが小さくなります。A::Z バリアントが比較的まれである場合、全体としてパフォーマンス向上につながる可能性が高くなります。Box によって、特に match パターンでは A::Z がやや使いにくくもなります。
例 1,
例 2,
例 3,
例 4,
例 5,
例 6.
より小さい整数
より小さい整数型を使用することで型を小さくできることはよくあります。たとえば、インデックスには usize を使用するのが最も自然ですが、インデックスを u32、u16、場合によっては u8 として格納し、使用箇所で usize に変換することは、多くの場合妥当です。
例 1,
例 2.
Box 化されたスライス
Rust のベクターは、長さ、容量、ポインターの 3 ワードを含みます。将来変更される可能性が低いベクターがある場合、Vec::into_boxed_slice を使ってそれを Box 化されたスライス に変換できます。Box 化されたスライスは、長さとポインターの 2 ワードだけを含みます。余分な要素容量は破棄されるため、再割り当てが発生することがあります。
#![allow(unused)] fn main() { use std::mem::{size_of, size_of_val}; let v: Vec<u32> = vec![1, 2, 3]; assert_eq!(size_of_val(&v), 3 * size_of::<usize>()); let bs: Box<[u32]> = v.into_boxed_slice(); assert_eq!(size_of_val(&bs), 2 * size_of::<usize>()); }
あるいは、Iterator::collect を使って、イテレーターから Box 化されたスライスを直接構築できます。イテレーターの長さが事前に分かっている場合、これにより再割り当てを避けられます。
#![allow(unused)] fn main() { let bs: Box<[u32]> = (1..3).collect(); }
Box 化されたスライスは、クローンや再割り当てなしで slice::into_vec を使ってベクターに変換できます。
ThinVec
Box 化されたスライスの代替となるのが、thin_vec クレートの ThinVec です。これは機能的には Vec と同等ですが、長さと容量を(要素がある場合は)要素と同じ割り当て内に格納します。つまり、size_of::<ThinVec<T>> は 1 ワードだけです。
ThinVec は、頻繁にインスタンス化される型の中で、空であることが多いベクターに適した選択肢です。また、あるバリアントが Vec を含む場合に、enum の最大バリアントを小さくするためにも使用できます。
リグレッションの回避
型が、そのサイズがパフォーマンスに影響を与え得るほどホットである場合は、意図せずリグレッションしないように静的アサーションを使用することをお勧めします。次の例では、static_assertions クレートのマクロを使用しています。
// この型は頻繁に使用されます。意図せず大きくならないようにしてください。
#[cfg(target_arch = "x86_64")]
static_assertions::assert_eq_size!(HotType, [u8; 64]);
型サイズはプラットフォームによって異なる可能性があるため、cfg 属性は重要です。アサーションを x86_64(通常、最も広く使われているプラットフォーム)に制限するだけで、実際にはリグレッションを防ぐのに十分である可能性が高いでしょう。
標準ライブラリ型
Vec、Option、Result、Rc/Arc などの一般的な標準ライブラリ型のドキュメントを一読しておく価値があります。パフォーマンスの改善に使えることがある興味深い関数を見つけられる場合があります。
Mutex、RwLock、Condvar、Once など、標準ライブラリ型に対する高性能な代替実装について知っておくことも価値があります。
Vec
長さ n のゼロ埋めされた Vec を作成する最良の方法は、vec![0; n] を使うことです。これは単純であり、おそらく resize、extend、あるいは unsafe を含むものなどの代替手段と比べて同等以上に高速です。OS の支援を利用できるためです。
Vec::remove は特定のインデックスにある要素を削除し、後続のすべての要素を 1 つ左にシフトするため、O(n) になります。Vec::swap_remove は特定のインデックスにある要素を末尾の要素で置き換えます。これは順序を保持しませんが、O(1) です。
Vec::retain は、Vec から複数の項目を効率的に削除します。String、HashSet、HashMap など、他のコレクション型にも同等のメソッドがあります。
Option と Result
Option::ok_or は Option を Result に変換し、Option の値が None の場合に使用される err パラメーターを受け取ります。err は先行評価されます。その計算コストが高い場合は、代わりに Option::ok_or_else を使用すべきです。これはクロージャーを介してエラー値を遅延評価します。たとえば、これは次のようにします。
#![allow(unused)] fn main() { fn expensive() {} let o: Option<u32> = None; let r = o.ok_or(expensive()); // 常に `expensive()` を評価する }
次のように変更すべきです。
#![allow(unused)] fn main() { fn expensive() {} let o: Option<u32> = None; let r = o.ok_or_else(|| expensive()); // 必要な場合にのみ `expensive()` を評価する }
例。
Option::map_or、Option::unwrap_or、Result::or、Result::map_or、Result::unwrap_or にも同様の代替手段があります。
Rc/Arc
Rc::make_mut/Arc::make_mut はコピーオンライトのセマンティクスを提供します。これらは Rc/Arc への可変参照を作成します。参照カウントが 1 より大きい場合、一意な所有権を保証するために内部の値を clone します。そうでない場合は、元の値を変更します。これらが必要になることは多くありませんが、場合によっては非常に有用です。
例 1、
例 2。
Mutex、RwLock、Condvar、Once
parking_lot クレートは、これらの同期型の代替実装を提供します。parking_lot 型の API とセマンティクスは、標準ライブラリ内の同等の型と似ていますが、同一ではありません。
parking_lot 版は、以前は標準ライブラリ版よりも確実に小さく、高速で、柔軟でした。しかし、標準ライブラリ版はいくつかのプラットフォームで大幅に改善されています。そのため、parking_lot に切り替える前に測定すべきです。
parking_lot 型を全面的に使用することにした場合でも、一部の場所で誤って標準ライブラリの同等の型を使用してしまうことは簡単に起こります。この問題を避けるには Clippy を使用できます。
イテレータ
collect と extend
Iterator::collect は、イテレータを Vec などのコレクションに変換します。通常、これにはメモリ割り当てが必要です。そのコレクションを再びイテレートするだけであれば、collect の呼び出しは避けるべきです。
このため、関数から Vec<T> を返すよりも、impl Iterator<Item=T> のようなイテレータ型を返す方がよいことがよくあります。なお、このブログ記事で説明されているように、これらの戻り値の型には追加のライフタイムが必要になる場合があります。
例。
同様に、イテレータを Vec に collect してから append を使用するのではなく、extend を使用して既存のコレクション(Vec など)をイテレータで拡張できます。
最後に、イテレータを作成するときは、可能であれば Iterator::size_hint または ExactSizeIterator::len メソッドを実装する価値があることがよくあります。そのイテレータを使用する collect や extend の呼び出しでは、イテレータが生成する要素数に関する事前情報が得られるため、メモリ割り当て回数を減らせる場合があります。
チェーン
chain は非常に便利ですが、単一のイテレータより遅くなることもあります。可能であれば、ホットなイテレータでは避ける価値があるかもしれません。
例。
同様に、filter の後に map を使用するよりも、filter_map の方が速い場合があります。
チャンク
チャンク化するイテレータが必要で、チャンクサイズがスライスの長さを正確に割り切ることが分かっている場合は、slice::chunks の代わりに、より高速な slice::chunks_exact を使用してください。
チャンクサイズがスライスの長さを正確に割り切ることが分かっていない場合でも、slice::chunks_exact を ChunksExact::remainder または余剰要素の手動処理と組み合わせて使用する方が速い場合があります。
例 1、
例 2。
関連するイテレータについても同じことが当てはまります。
slice::rchunks、slice::rchunks_exact、およびRChunksExact::remainderslice::chunks_mut、slice::chunks_exact_mut、およびChunksExactMut::into_remainderslice::rchunks_mut、slice::rchunks_exact_mut、およびRChunksExactMut::into_remainder
copied
整数などの小さなデータ型のコレクションをイテレートする場合、iter() の代わりに iter().copied() を使用する方がよい場合があります。そのイテレータを消費するものは、整数を参照ではなく値で受け取り、その場合 LLVM がより良いコードを生成する可能性があります。
例 1、
例 2。
これは高度なテクニックです。効果があることを確かめるには、生成されたマシンコードを確認する必要があるかもしれません。その方法の詳細については、マシンコードの章を参照してください。
境界チェック
デフォルトでは、Rust でのスライスやベクターなどのコンテナー型へのアクセスには 境界チェックが伴います。これはパフォーマンスに影響する可能性があります。たとえばホットループ内などです。 ただし、影響する頻度は予想より少ないかもしれません。
コンテナーの長さをコンパイラーが把握できるようにコードを変更し、 境界チェックを最適化で取り除けるようにする安全な方法はいくつかあります。
- ループ内の直接的な要素アクセスを、イテレーションを使用する形に置き換える。
- ループ内で
Vecにインデックスアクセスする代わりに、ループの前にVecのスライスを作成し、 ループ内ではそのスライスにインデックスアクセスする。 - インデックス変数の範囲に対するアサーションを追加する。 例 1, 例 2。
これらを機能させるのは難しい場合があります。Bounds Check Cookbook では、このトピックについて さらに詳しく説明しています。
最後の手段として、unsafe なメソッドである get_unchecked と
get_unchecked_mut があります。
I/O
ロック
Rust の print! マクロと println! マクロは、呼び出しごとに stdout をロックします。これらのマクロを繰り返し呼び出す場合は、stdout を手動でロックした方がよいことがあります。
たとえば、次のコードを変更します:
#![allow(unused)] fn main() { let lines = vec!["one", "two", "three"]; for line in lines { println!("{}", line); } }
次のようにします:
#![allow(unused)] fn main() { fn blah() -> Result<(), std::io::Error> { let lines = vec!["one", "two", "three"]; use std::io::Write; let mut stdout = std::io::stdout(); let mut lock = stdout.lock(); for line in lines { writeln!(lock, "{}", line)?; } // `lock` がドロップされると stdout のロックが解除される Ok(()) } }
stdin と stderr も同様に、それらに対して繰り返し操作を行う場合はロックできます。
バッファリング
Rust のファイル I/O はデフォルトではバッファリングされません。ファイルやネットワークソケットに対して、小さな読み取りまたは書き込み呼び出しを何度も繰り返す場合は、BufReader または BufWriter を使用してください。これらは入力と出力用にメモリ内バッファを保持し、必要なシステムコールの回数を最小限に抑えます。
たとえば、次のバッファリングされていない writer コードを変更します:
#![allow(unused)] fn main() { fn blah() -> Result<(), std::io::Error> { let lines = vec!["one", "two", "three"]; use std::io::Write; let mut out = std::fs::File::create("test.txt")?; for line in lines { writeln!(out, "{}", line)?; } Ok(()) } }
次のようにします:
#![allow(unused)] fn main() { fn blah() -> Result<(), std::io::Error> { let lines = vec!["one", "two", "three"]; use std::io::{BufWriter, Write}; let mut out = BufWriter::new(std::fs::File::create("test.txt")?); for line in lines { writeln!(out, "{}", line)?; } out.flush()?; Ok(()) } }
明示的な flush の呼び出しは厳密には必要ありません。out がドロップされると、自動的に flush が行われるためです。しかし、その場合 flush 時に発生したエラーは無視されます。一方、明示的に flush すれば、そのエラーが明示的になります。
バッファリングを忘れることは、書き込み時によく起こります。バッファリングされていない writer とバッファリングされた writer はどちらも Write トレイトを実装しているため、バッファリングされていない writer へ書き込むコードとバッファリングされた writer へ書き込むコードはほとんど同じです。対照的に、バッファリングされていない reader は Read トレイトを実装していますが、バッファリングされた reader は BufRead トレイトを実装しています。つまり、バッファリングされていない reader から読み取るコードとバッファリングされた reader から読み取るコードは異なります。たとえば、バッファリングされていない reader でファイルを 1 行ずつ読み取るのは難しいですが、バッファリングされた reader では BufRead::read_line または BufRead::lines を使用することで簡単にできます。このため、writer について上で示したような、変更前と変更後が非常によく似ている reader の例を書くのは困難です。
最後に、バッファリングは stdout でも機能することに注意してください。そのため、stdout に対して多数の書き込みを行う場合は、手動ロックとバッファリングを組み合わせるとよいかもしれません。
ファイルから行を読み取る
このセクションでは、BufRead を使用してファイルを 1 行ずつ読み取る際に、過剰なアロケーションを避ける方法を説明します。
入力を生バイトとして読み取る
組み込みの String 型は内部的に UTF-8 を使用しているため、入力を読み込む際に UTF-8 検証による小さいながらもゼロではないオーバーヘッドが発生します。UTF-8 を気にせず入力バイトを処理したいだけの場合(たとえば ASCII テキストを扱う場合)は、BufRead::read_until を使用できます。
バイト指向のデータ行を読み取るための専用クレートや、バイト文字列を扱うための専用クレートもあります。
ロギングとデバッグ
ロギングコードやデバッグコードが、プログラムを大幅に遅くすることがあります。 ロギング/デバッグコード自体が遅い場合もあれば、ロギング/デバッグコードに 供給するデータ収集コードが遅い場合もあります。ロギング/デバッグが有効になっていないときに、 ロギング/デバッグ目的で不要な作業が行われないようにしてください。 例 1, 例 2, 例 3.
assert! の呼び出しは常に実行されますが、debug_assert! の呼び出しは
dev ビルドでのみ実行されることに注意してください。頻繁に実行されるものの、
安全性のためには必要ではないアサーションがある場合は、それを debug_assert! にすることを検討してください。
例 1,
例 2.
ラッパー型
Rust には、RefCell や Mutex など、値に特別な振る舞いを提供するさまざまな「ラッパー」型があります。これらの値へのアクセスには、無視できない時間がかかる場合があります。このような値が複数、通常一緒にアクセスされる場合は、それらを単一のラッパー内に入れるほうがよいかもしれません。
たとえば、次のような構造体は:
#![allow(unused)] fn main() { use std::sync::{Arc, Mutex}; struct S { x: Arc<Mutex<u32>>, y: Arc<Mutex<u32>>, } }
次のように表現したほうがよいかもしれません:
#![allow(unused)] fn main() { use std::sync::{Arc, Mutex}; struct S { xy: Arc<Mutex<(u32, u32)>>, } }
これがパフォーマンスの向上に役立つかどうかは、値の正確なアクセスパターンに依存します。 例.
マシンコード
非常に頻繁に実行される小さなコード片がある場合、生成されたマシンコードを調べて、削除可能な
bounds checks などの非効率がないか確認する価値があるかもしれません。小さなスニペットでこれを行う場合、Compiler Explorer のウェブサイトは非常に優れたリソースです。cargo-show-asm は、Rust プロジェクト全体で使用できる代替ツールです。
関連して、core::arch モジュールはアーキテクチャ固有の組み込み関数へのアクセスを提供しており、その多くは SIMD 命令に関連しています。
並列性
Rust は安全な並列プログラミングを非常によくサポートしており、大きな性能向上につながる可能性があります。プログラムに並列性を導入する方法はさまざまであり、どの方法が最適かは、そのプログラムの設計に大きく依存します。
とはいえ、並列性について詳しく扱うことは本書の範囲外です。
スレッドベースの並列性に関心がある場合は、rayon クレートと crossbeam クレートのドキュメントがよい出発点になります。Rust Atomics and Locks も優れたリソースです。
細粒度のデータ並列性に関心がある場合は、この ブログ記事 が、2025年11月時点における Rust の SIMD サポートの状況を概観するのに適しています。
一般的なヒント
本書のこれまでのセクションでは、Rust 固有のテクニックについて説明してきました。 このセクションでは、一般的なパフォーマンス原則の概要を簡単に説明します。
明らかな落とし穴(例: 非リリースビルドを使用する)を避けている限り、 Rust コードは一般に高速で、メモリ使用量も少なくなります。特に、Python や Ruby のような 動的型付け言語、あるいは Java や C# のようなガベージコレクターを備えた 静的型付け言語に慣れている場合はそう感じるでしょう。
最適化されたコードは、最適化されていないコードよりも複雑で、書くのに手間がかかることがよくあります。 このため、最適化する価値があるのはホットなコードだけです。
最大のパフォーマンス改善は、低レベルの最適化ではなく、アルゴリズムや データ構造の変更によってもたらされることがよくあります。 例 1, 例 2.
現代のハードウェアとうまく連携するコードを書くことは、必ずしも簡単ではありませんが、 取り組む価値があります。たとえば、可能な場合はキャッシュミスや分岐予測ミスを 最小限に抑えるようにしてください。
ほとんどの最適化は小さな速度向上をもたらします。個々の小さな速度向上は 目に見えるものではありませんが、十分な数を積み重ねることができれば、大きな効果になります。
プロファイラーによって得意分野は異なります。複数を使用するのがよいでしょう。
プロファイリングによって関数がホットであることが示された場合、速度を上げる一般的な方法は 2 つあります: (a) その関数を高速化する、および/または (b) その関数の呼び出し回数を できるだけ減らす、です。
巧妙な速度向上を導入するよりも、ばかげた速度低下を取り除くほうが簡単なことがよくあります。
必要でない限り、計算を避けてください。遅延/オンデマンドの計算は 多くの場合、有利に働きます。 例 1, 例 2.
複雑な一般ケースは、より単純な一般的な特殊ケースを楽観的にチェックすることで 避けられることがよくあります。 例 1, 例 2, 例 3. 特に、小さいサイズが支配的な場合、0、1、または 2 個の要素を持つコレクションを 特別に扱うことは有利に働くことがよくあります。 例 1, 例 2, 例 3, 例 4.
同様に、反復的なデータを扱う場合、一般的な値にはコンパクトな表現を使用し、 通常とは異なる値にはセカンダリテーブルへフォールバックすることで、単純な形式の データ圧縮を使用できることがよくあります。 例 1, 例 2, 例 3.
コードが複数のケースを扱う場合は、ケースの頻度を測定し、最も一般的なものを最初に処理してください。
局所性の高い検索を扱う場合、データ構造の前段に小さなキャッシュを置くことが 有利に働くことがあります。
最適化されたコードは、構造が明らかでないことがよくあります。つまり、説明コメントには価値があり、 特にプロファイリング測定値を参照するコメントは有用です。「このベクターは 99% の時間で 0 個または 1 個の要素を持つため、それらのケースを先に処理する」といったコメントは、 理解を助けるものになり得ます。
コンパイル時間
この本は主に Rust プログラムの性能を向上させることについて扱っていますが、このセクションでは Rust プログラムのコンパイル時間を短縮することについて扱います。これは、多くの人にとって関心のある関連トピックだからです。
コンパイル時間の最小化セクションでは、ビルド設定の選択によってコンパイル時間を短縮する方法について説明しました。このセクションの残りでは、プログラムのコードを変更する必要があるコンパイル時間の短縮方法について説明します。
追加のコンパイル時間短縮テクニックについては、Corrode の包括的なリストである Tips for Faster Rust Compile Times を参照してください。
可視化
Cargo には、プログラムのコンパイルを可視化できる機能があります。次のコマンドでビルドします。
cargo build --timings
完了すると、HTML ファイルの名前が出力されます。そのファイルを Web ブラウザーで開いてください。このファイルには、プログラム内のさまざまなクレート間の依存関係を示すガントチャートが含まれています。これにより、クレートグラフにどれだけの並列性があるかが分かり、コンパイルを直列化してしまう大きなクレートを分割すべきかどうかを判断する手がかりになります。グラフの読み方の詳細については、ドキュメントを参照してください。
マクロ
一部のマクロは大量のコードを生成します。そのコードのコンパイルには時間がかかります。Rust コンパイラの -Zmacro-stats フラグは、そのようなケースを特定するのに役立ちます。
たとえば、プロジェクトのリーフクレートだけを測定したい場合は、次のようにします。
cargo +nightly rustc -- -Zmacro-stats
コンパイラは、手続き型マクロと宣言型マクロの両方によって生成されたコード量に関する情報を出力します。通常、前者の方がより注目に値します。
また、プロジェクト内のすべてのクレートを測定したい場合は、次のようにします。
RUSTFLAGS="-Zmacro-stats" cargo +nightly build
生成されたコード自体を見るには、cargo-expand を使用できます。
少量のコードしか生成しないマクロについて心配する価値はありませんが、マクロが手書きのコード量に匹敵する量のコードを生成している場合は、そのマクロの使用を完全に取り除くか、より低コストな代替手段に置き換えられる可能性があります。 例。
あるいは、生成するコードが少なくなるようにマクロを変更できる可能性もあります。 例 1、 例 2。
LLVM IR
Rust コンパイラはバックエンドに LLVM を使用しています。LLVM の実行はコンパイル時間の大きな部分を占めることがあり、特に Rust コンパイラのフロントエンドが大量の IR を生成し、それを LLVM が最適化するのに長い時間がかかる場合に顕著です。
これらの問題は cargo llvm-lines で診断できます。これは、どの Rust 関数が最も多くの LLVM IR を生成しているかを示します。ジェネリック関数は、大規模なプログラムでは数十回、あるいは数百回もインスタンス化されることがあるため、多くの場合で最も重要です。
ジェネリック関数が IR の肥大化を引き起こしている場合、それを修正する方法はいくつかあります。最も単純なのは、その関数を小さくすることです。 例 1、 例 2。
別の方法は、関数の非ジェネリックな部分を、別の非ジェネリック関数に移すことです。そうすれば、その関数は一度だけインスタンス化されます。これが可能かどうかは、ジェネリック関数の詳細によって異なります。可能な場合、非ジェネリック関数は多くの場合、std::fs::read のコードで示されているように、ジェネリック関数内の内部関数としてきれいに書けます。
pub fn read<P: AsRef<Path>>(path: P) -> io::Result<Vec<u8>> {
fn inner(path: &Path) -> io::Result<Vec<u8>> {
let mut file = File::open(path)?;
let size = file.metadata().map(|m| m.len()).unwrap_or(0);
let mut bytes = Vec::with_capacity(size as usize);
io::default_read_to_end(&mut file, &mut bytes)?;
Ok(bytes)
}
inner(path.as_ref())
}
例。
Option::map や Result::map_err のような一般的なユーティリティ関数が何度もインスタンス化されることがあります。これらを同等の match 式に置き換えると、コンパイル時間の短縮に役立つことがあります。
この種の変更がコンパイル時間に与える影響は通常は小さいものですが、ときには大きくなることもあります。 例。
このような変更は、バイナリサイズの削減にもつながります。