Rustのパフォーマンス
Rust は「速い言語」として知られていますし、それは本当です。でも「Rust は速い」というのは、Rust で書いたプログラムがどれも速いという意味ではありません。遅いアルゴリズムはどの言語で書いても遅いですし、ちょっとした書き方のクセで、知らないうちに大きく損をしていることもあります。
うれしいことに、効果の大きい改善のほとんどはかんたんです。リリースモードでビルドする。変更する前に計測する。読むだけのデータはコピーしない。適切なコレクションを選ぶ。このページでは、それぞれを順番に、必要なところでは実際の計測結果も交えて見ていきます。自転車の調整にたとえるなら、まずタイヤに空気が入っているか確かめて(リリースモード)、軽い部品のことを考えるのはその後です。
まずは計測から:Instant
Section titled “まずは計測から:Instant”何かを速くする前に、まずどれくらい遅いのかを知る必要があります。std::time::Instant は、標準ライブラリに入っているストップウォッチです。処理の前に Instant::now() を呼び、処理の後に .elapsed() を呼びます。
use std::time::Instant;
fn main() { let start = Instant::now();
// シンプルな xorshift で、疑似乱数を500万個作る let mut x: u64 = 12345; let mut numbers = Vec::with_capacity(5_000_000); for _ in 0..5_000_000 { x ^= x << 13; x ^= x >> 7; x ^= x << 17; numbers.push(x % 1_000_000); }
numbers.sort(); let sum: u64 = numbers.iter().take(1000).sum();
println!("小さいほうから1000個の合計: {sum}"); println!("かかった時間: {:?}", start.elapsed());}{:?} を使うと、Duration が 250.271745ms のように読みやすく表示されます。ここに「Try it」ボタンがないのはわざとです。かかる時間は実行するたびに、またマシンごとに変わるので、決まった実行結果と比べることができません。代わりに、自分のコンピュータで cargo run して試してみてください。
デバッグビルドとリリースビルド
Section titled “デバッグビルドとリリースビルド”これは Rust のパフォーマンスでいちばん大事なコツです。しかも、フラグを1つ付けるだけです。
cargo run # デバッグビルド:コンパイルは速いが、実行は遅いcargo run --release # リリースビルド:コンパイルは遅いが、実行はずっと速いデバッグビルドは、すばやくコンパイルできてデバッグしやすいように、最適化のほとんどを省きます。整数のオーバーフローのチェックも行います。リリースビルドでは、最適化が全開になります。
上のプログラムを、このページを書くのに使ったマシン(Intel Xeon CPU のクラウドサーバー)でそれぞれ3回実行したときの計測例です。あなたの環境では数字が違うはずです。大事なのは両者の差だけです。
デバッグビルド: 5.02s, 4.27s, 4.83sリリースビルド: 250ms, 297ms, 265msフラグ1つで、だいたい15〜20倍速くなりました。どれくらい差が出るかはコードによって大きく変わり、ループやイテレータがいちばん得をします。それでも、無視できるほど小さな差になることはありません。
というわけで、ルールはシンプルです。デバッグビルドで速さを判断しないこと。cargo run で時間を計って「Rust は遅い」と言っている人がいたら、ほとんどの場合これが原因です。
clone せずに借用する
Section titled “clone せずに借用する”.clone() は完全なコピーを作ります。String や Vec なら、ヒープに新しいメモリを確保して、すべてのバイトをコピーするということです。コピーが必要なときもあります。でも、多くの場合はデータを読むだけでよく、それなら借用(borrowing)はタダです。
いちばんよくあるのは関数の引数です。関数が文字列を読むだけなら、String ではなく &str で受け取りましょう。
fn count_long_words(text: &str, min_len: usize) -> usize { text.split_whitespace().filter(|w| w.len() >= min_len).count()}
fn main() { let owned = String::from("the quick brown fox jumps over the lazy dog");
// &String は自動で &str になるので、コピーは不要 println!("{}", count_long_words(&owned, 5));
// 文字列リテラルは最初から &str println!("{}", count_long_words("borrowing is cheap and cloning is not", 6));
// `owned` は手放していないので、まだ使える println!("{owned}");}実行結果:
32the quick brown fox jumps over the lazy dogこの例の文字列が英語のままなのは、単語の長さ(.len()、バイト数)を数えているからです。日本語の文字は UTF-8 で1文字3バイトになるので、結果が変わってしまいます。
&str の引数は、String(& で借用したもの)と文字列リテラルのどちらも受け取れます。引数が String だと、呼び出す側は自分の文字列を手放すか、clone するしかありません。リストでも考え方は同じで、Vec<T> ではなく &[T] で受け取ります。くわしくは借用とスライスを見てください。
ループの中の clone にも気をつけましょう。for name in names.clone() は、中身を見るだけのためにリスト全体をコピーします。for name in &names なら、タダで借用できます。
メモリの確保は何度もせず、一度で
Section titled “メモリの確保は何度もせず、一度で”Vec は容量が足りなくなると、もっと大きなメモリを確保して、中身をすべてそこに移します。最初からサイズがわかっているなら、Vec::with_capacity で一度に確保できます。次のプログラムは、1000個の数を push する間に、それぞれの Vec が何回拡張されたかを数えます。
fn count_growths(mut v: Vec<u32>, n: u32) -> u32 { let mut growths = 0; let mut cap = v.capacity(); for i in 0..n { v.push(i); if v.capacity() != cap { growths += 1; cap = v.capacity(); } } growths}
fn main() { println!("Vec::new(): 拡張 {} 回", count_growths(Vec::new(), 1000)); println!("Vec::with_capacity(): 拡張 {} 回", count_growths(Vec::with_capacity(1000), 1000));}実行結果:
Vec::new(): 拡張 9 回Vec::with_capacity(): 拡張 0 回9回の拡張は大したことではありません。Vec は毎回容量を倍にするので、コストは小さく保たれます。でも、何百万個ものベクタを作るような、何度も実行されるループの中では積み重なっていきます。(拡張のしかたの細かいところは標準ライブラリの内部の話なので、将来変わるかもしれません。)
もっとかんたんなのは、collect() や join() に任せることです。これらは最終的なサイズがわかっていることが多く、一度で確保してくれます。
fn main() { // collect() はベクタを一度に作る let squares: Vec<u64> = (1..=5).map(|n| n * n).collect(); println!("{squares:?}");
// join() は先に全体の長さを計算してから、一度だけコピーする let colors = ["red", "green", "blue"]; println!("{}", colors.join(", "));}実行結果:
[1, 4, 9, 16, 25]red, green, blueイテレータはゼロコスト
Section titled “イテレータはゼロコスト”.iter().filter().map().sum() は関数呼び出しがたくさんあるように見えるので、ふつうのループより遅いのでは、と心配になるかもしれません。でも、そんなことはありません。リリースビルドでは、コンパイラがこうした小さなクロージャをすべてインライン化するので、手で書いたループと同じくらい無駄のない機械語になります。Rust ではこれをゼロコスト抽象化(zero-cost abstraction)と呼びます。読みやすい書き方をしても、その分のコストは払わなくてよいのです。
fn sum_even_squares_loop(nums: &[u64]) -> u64 { let mut total = 0; for i in 0..nums.len() { if nums[i] % 2 == 0 { total += nums[i] * nums[i]; } } total}
fn sum_even_squares_iter(nums: &[u64]) -> u64 { nums.iter().filter(|&&n| n % 2 == 0).map(|&n| n * n).sum()}
fn main() { let nums: Vec<u64> = (1..=10).collect(); println!("ループ: {}", sum_even_squares_loop(&nums)); println!("イテレータ: {}", sum_even_squares_iter(&nums));}実行結果:
ループ: 220イテレータ: 220イテレータ版のほうが少し速くなることさえあります。nums[i] と書くと、Rust はアクセスのたびに i がスライスの範囲内かをチェックします。イテレータは範囲の外に出ないことを最初から知っているので、そのチェックが消えるのです。読みやすいほうを使いましょう。たいていはイテレータです。
適切なコレクションを選ぶ
Section titled “適切なコレクションを選ぶ”適切なデータ構造を選ぶほうが、細かいチューニングをいくら重ねるより効果的なことがよくあります。典型的な例は、「この要素はリストに入っている?」を何度も調べる場合です。
Vec::containsは要素を1つずつ全部見ていきます。リストが大きくなるほど、調べるのが遅くなります。HashSet::contains(やHashMap::get)は、ほぼ一直線に答えにたどり着きます。サイズに関係なく、速いままです。
use std::collections::HashSet;
fn main() { let banned: Vec<u32> = (0..20_000).map(|n| n * 3).collect(); let queries: Vec<u32> = (0..20_000).collect();
// 大きなリストでは遅い:contains() のたびに Vec 全体を調べる let hits_vec = queries.iter().filter(|q| banned.contains(q)).count();
// 速い:HashSet を一度作れば、あとは毎回すぐに調べられる let banned_set: HashSet<u32> = banned.iter().copied().collect(); let hits_set = queries.iter().filter(|q| banned_set.contains(q)).count();
println!("Vec の結果: {hits_vec}、HashSet の結果: {hits_set}");}実行結果:
Vec の結果: 6667、HashSet の結果: 6667答えは同じでも、コストはまったく違います。それぞれの部分を Instant で計ってリリースビルドで実行したときの、私たちのマシンでの計測例です。
Vec: 6667 件 27.97msHashSet: 6667 件 551.60µsおよそ50倍速く、しかもこれはセットを作る時間込みです。ただし、要素が10個くらいのリストなら Vec でまったく問題なく、むしろ速いこともあります。くわしくは HashSet と HashMap のページを見てください。
最適化の前にプロファイリング
Section titled “最適化の前にプロファイリング”プログラマーは、どこで時間がかかっているかを当てるのがとても苦手なことで有名です。だから、推測はやめてプロファイリングしましょう。プロファイラはプログラムの実行を観察して、どの関数がいちばん時間を使っているかを教えてくれます。たいていは1〜2か所のホットスポットだけが問題で、残りのコードはまったく関係ありません。
Rust で使える、人気の無料プロファイラを2つ紹介します。
# flamegraph:どこで時間を使っているかを図にしてくれる(Linux、macOS)cargo install flamegraphcargo flamegraph
# samply:結果をブラウザの Firefox Profiler で開くcargo install samplycargo build --releasesamply record ./target/release/my_appプロファイラが関数名を表示するには、デバッグシンボルが必要です。リリースビルドにもデバッグシンボルを残すよう、Cargo.toml に次を追加しましょう(コードが遅くなることはなく、ファイルが大きくなるだけです)。
[profile.release]debug = truecriterion でベンチマーク
Section titled “criterion でベンチマーク”Instant で1回だけ計った時間には、ばらつきがあります。ベンチマークはコードを何度も実行し、統計を使って信頼できる数字を出してくれます。Rust に組み込みの #[bench] は nightly でしか使えないので、ほとんどの人は criterion(英語)というクレートを使います。次のように設定します。
[dev-dependencies]criterion = "0.8"
[[bench]]name = "fib_bench"harness = falseテストしたいコードをライブラリ(src/lib.rs)に置いて、ベンチマークを benches/fib_bench.rs に書きます。
use criterion::{criterion_group, criterion_main, Criterion};use my_crate::fibonacci;use std::hint::black_box;
fn bench_fib(c: &mut Criterion) { c.bench_function("fibonacci 20", |b| b.iter(|| fibonacci(black_box(20))));}
criterion_group!(benches, bench_fib);criterion_main!(benches);black_box は、コンパイラが気を利かせて答えを前もって計算してしまうのを防ぎます。そうなると、ベンチマークの意味がなくなってしまうからです。次のコマンドで実行します。
cargo bench私たちのマシンでの結果の例です(criterion は、低めの推定値、最も確からしい推定値、高めの推定値を表示します)。
fibonacci 20 time: [24.417 µs 25.079 µs 25.737 µs]コードを変更してからもう一度実行すると、criterion は速くなったのか遅くなったのか、そしてその差が本物なのか、ただのばらつきなのかを教えてくれます。
リリースプロファイルの設定
Section titled “リリースプロファイルの設定”リリースビルドのデフォルト設定はよくできています。もう少しだけ速くしたいときは、Cargo.toml で次のような設定をよく使います。
[profile.release]opt-level = 3 # リリースのデフォルト。"s" や "z" にするとサイズを優先して最適化するlto = true # リンク時最適化:すべてのクレートをまたいで最適化する(ビルドは遅くなる)codegen-units = 1 # 1つにすると最適化の機会が増えるが、コンパイルは遅くなるpanic = "abort" # 巻き戻し(unwind)のコードを省く。バイナリは小さくなるが、パニックを捕まえられないこれらで得られるのはたいてい数パーセントで、魔法のような効果はありません。しかもビルドは遅くなります。1つずつ試して、それぞれ計測しましょう。lto = "thin" は、lto = true よりビルドが速い、人気のある中間的な選択肢です。
よくある間違い
Section titled “よくある間違い”デバッグビルドで時間を計る。 すでに説明しましたが、これがいちばん多い間違いです。計測するときは必ず --release を付けましょう。
コンパイラを黙らせるために clone する。 たとえば、String を受け取る関数を、借用で呼び出したとします。
fn shout(text: String) -> String { text.to_uppercase()}
fn main() { let name = String::from("ferris"); let loud = shout(&name); println!("{loud} {name}");}コンパイラはこう言います。
error[E0308]: mismatched types --> src/main.rs:7:22 |7 | let loud = shout(&name); | ----- ^^^^^ expected `String`, found `&String` | | | arguments to this function are incorrectこれは「型が合わない。String を期待していたのに &String が渡された」という意味です。つい shout(name.clone()) と書きたくなります。それでも動きますが、呼び出すたびに文字列がコピーされます。よりよい直し方は、引数を text: &str に変えることです。そうすれば shout(&name) がコンパイルでき、何もコピーされません。
何度も実行されるループの中で println! する。 println! は呼ぶたびに標準出力をロックして、その行をすぐに書き出します。数行なら問題ありません。でも100万行となると遅くなります。標準出力を BufWriter で包めば、大きなかたまりでまとめて書き出してくれます。
use std::io::{self, BufWriter, Write};
fn main() { let mut out = BufWriter::new(io::stdout().lock()); for i in 1..=5 { writeln!(out, "{i}行目").unwrap(); }}実行結果:
1行目2行目3行目4行目5行目私たちの計測例では、100万行をファイルに書き出すのに、println! だと約570〜670ms、BufWriter だと約40〜60msかかりました(リリースビルド)。
- 速さは必ず
cargo run --releaseで計りましょう。デバッグビルドは何倍も遅いことがあります。 - ざっと時間を計るなら
Instant::now()と.elapsed()、信頼できるベンチマークには criterion を使います。 - データを読むだけなら、clone せずに借用(
&str、&[T])しましょう。 - メモリの確保は一度で:
Vec::with_capacity、collect()、join()を使えば、何度も拡張せずに済みます。 - イテレータはゼロコストです。何度も検索するなら
HashSet/HashMapを選びましょう。 - まずプロファイリングして、プロファイラが示したホットスポットを最適化しましょう。
理解度チェック
Section titled “理解度チェック”理解度チェック
1. cargo run で実行したプログラムが遅く感じます。最初に試すべきことはどれですか?
2. ある関数は文字列を読むだけです。無駄なコピーを避けられる引数の型はどれですか?
3. 50,000個のIDが、50,000個の禁止IDのリストに入っているかを調べたいです。禁止リストにはどれを使うのがよいですか?
4. 「イテレータはゼロコスト」とはどういう意味ですか?
5. criterion のベンチマークで black_box を使うのはなぜですか?
スコア: 0 / 5
さらに詳しく
Section titled “さらに詳しく”- The Rust Performance Book(英語):ビルド設定、プロファイリング、よくある高速化の方法をまとめた、短くて実践的なガイドです。
- パフォーマンス比較:ループVSイテレータ — The Rust Programming Language 日本語版:イテレータが速いコードにコンパイルされる理由。
- Profiles — The Cargo Book(英語):
[profile]のすべての設定とそのデフォルト値。 std::time::Instant(英語):標準ライブラリのストップウォッチです。- criterion のドキュメント(英語):上で使ったベンチマーク用のクレートです。