コンテンツにスキップ

Rustのパフォーマンス

Rust は「速い言語」として知られていますし、それは本当です。でも「Rust は速い」というのは、Rust で書いたプログラムがどれも速いという意味ではありません。遅いアルゴリズムはどの言語で書いても遅いですし、ちょっとした書き方のクセで、知らないうちに大きく損をしていることもあります。

うれしいことに、効果の大きい改善のほとんどはかんたんです。リリースモードでビルドする。変更する前に計測する。読むだけのデータはコピーしない。適切なコレクションを選ぶ。このページでは、それぞれを順番に、必要なところでは実際の計測結果も交えて見ていきます。自転車の調整にたとえるなら、まずタイヤに空気が入っているか確かめて(リリースモード)、軽い部品のことを考えるのはその後です。

何かを速くする前に、まずどれくらい遅いのかを知る必要があります。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つ付けるだけです。

Terminal window
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() は完全なコピーを作ります。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}");
}

実行結果:

3
2
the 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
▶ 実際に試してみよう

.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 がスライスの範囲内かをチェックします。イテレータは範囲の外に出ないことを最初から知っているので、そのチェックが消えるのです。読みやすいほうを使いましょう。たいていはイテレータです。

適切なデータ構造を選ぶほうが、細かいチューニングをいくら重ねるより効果的なことがよくあります。典型的な例は、「この要素はリストに入っている?」を何度も調べる場合です。

  • 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.97ms
HashSet: 6667 件 551.60µs

およそ50倍速く、しかもこれはセットを作る時間込みです。ただし、要素が10個くらいのリストなら Vec でまったく問題なく、むしろ速いこともあります。くわしくは HashSet と HashMap のページを見てください。

最適化の前にプロファイリング

Section titled “最適化の前にプロファイリング”

プログラマーは、どこで時間がかかっているかを当てるのがとても苦手なことで有名です。だから、推測はやめてプロファイリングしましょう。プロファイラはプログラムの実行を観察して、どの関数がいちばん時間を使っているかを教えてくれます。たいていは1〜2か所のホットスポットだけが問題で、残りのコードはまったく関係ありません。

Rust で使える、人気の無料プロファイラを2つ紹介します。

Terminal window
# flamegraph:どこで時間を使っているかを図にしてくれる(Linux、macOS)
cargo install flamegraph
cargo flamegraph
# samply:結果をブラウザの Firefox Profiler で開く
cargo install samply
cargo build --release
samply record ./target/release/my_app

プロファイラが関数名を表示するには、デバッグシンボルが必要です。リリースビルドにもデバッグシンボルを残すよう、Cargo.toml に次を追加しましょう(コードが遅くなることはなく、ファイルが大きくなるだけです)。

[profile.release]
debug = true

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 は、コンパイラが気を利かせて答えを前もって計算してしまうのを防ぎます。そうなると、ベンチマークの意味がなくなってしまうからです。次のコマンドで実行します。

Terminal window
cargo bench

私たちのマシンでの結果の例です(criterion は、低めの推定値、最も確からしい推定値、高めの推定値を表示します)。

fibonacci 20 time: [24.417 µs 25.079 µs 25.737 µs]

コードを変更してからもう一度実行すると、criterion は速くなったのか遅くなったのか、そしてその差が本物なのか、ただのばらつきなのかを教えてくれます。

リリースビルドのデフォルト設定はよくできています。もう少しだけ速くしたいときは、Cargo.toml で次のような設定をよく使います。

[profile.release]
opt-level = 3 # リリースのデフォルト。"s" や "z" にするとサイズを優先して最適化する
lto = true # リンク時最適化:すべてのクレートをまたいで最適化する(ビルドは遅くなる)
codegen-units = 1 # 1つにすると最適化の機会が増えるが、コンパイルは遅くなる
panic = "abort" # 巻き戻し(unwind)のコードを省く。バイナリは小さくなるが、パニックを捕まえられない

これらで得られるのはたいてい数パーセントで、魔法のような効果はありません。しかもビルドは遅くなります。1つずつ試して、それぞれ計測しましょう。lto = "thin" は、lto = true よりビルドが速い、人気のある中間的な選択肢です。

デバッグビルドで時間を計る。 すでに説明しましたが、これがいちばん多い間違いです。計測するときは必ず --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 を選びましょう。
  • まずプロファイリングして、プロファイラが示したホットスポットを最適化しましょう。

理解度チェック

1. cargo run で実行したプログラムが遅く感じます。最初に試すべきことはどれですか?

2. ある関数は文字列を読むだけです。無駄なコピーを避けられる引数の型はどれですか?

3. 50,000個のIDが、50,000個の禁止IDのリストに入っているかを調べたいです。禁止リストにはどれを使うのがよいですか?

4. 「イテレータはゼロコスト」とはどういう意味ですか?

5. criterion のベンチマークで black_box を使うのはなぜですか?

スコア: 0 / 5