【翻訳】Rustにおけるパフォーマンスの落とし穴

Public Domain Mark 1.0

Rust Performance Pitfalls — Llogiq on stuff

全体的に見ると、Rustのパフォーマンスはとてもよいと言える。最も単純な書きかたでコードを書いても、最適化されたC/C++プログラムより2倍遅くなることはないだろう。しかしながら、Rustは速度をなんらかの理由により犠牲(tradeoff)にすることがある。ここでは、あなたを困らせるかもしれないいくつかの点をリストにして、それについてどのようにしたら速度を改善できるかを示す。

本題に入る前に、この記事は単に一般的なアドバイスにすぎず、特定の場面で当てはまるかどうかは分からないという点に留意していただきたい。カーク・ペパーディーンが常に忠告している言葉に従えば「測れ!推測するな!」ということだ。

--releaseを使おう

Rustにおける最初のつまずきの石は、rustcコンパイル(と実行)がデフォルトではデバッグモードで行われるということである。デバッグモードのコンパイルは速いが、最適化は行わず、実行されるコードは糖蜜のように遅い。

ときおり、誰かがIRCrust-users/r/rustにやってきて「なんでRustってすごく遅いの?」と質問し、Rustがどれほど速いかを思い知るためだけに墓穴を掘っていたりする。というわけで、高速化したいときはまずcargo run --releaseを使ってコードを実行していただきたい。

実用上はデフォルト(のデバッグモード)のほうが良いことを言い添えておく。あれこれ面倒に悩まされずコードを素早くテストできるし、重たい最適化は十分に正しいコードが書けていると確信するまで保留しておくことができる。正しく動作しないプログラムの最適化のために、時間を無駄にする必要はない。

非バッファIO(Unbuffered IO)

デフォルトでは、Rustは非バッファIOを使う。悪いことに、デフォルトのprintln!マクロはそれぞれの書き込み操作ごとにSTDOUTをロックする。もし大きな入力(またはSTDINからの入力)を受け取るときは、高速化を諦めるなんらかの理由がない限り(e.g. メモリの制約, カスタムバッファ、特定の用途に対する粒度の書き込みを必要とするとき)、BufWriter/BufReaderでそれらを手動でロックしてラップしてやらなければならない。

以下のコード:

println!("{}", header);
for line in lines {
    println!("{}", line);
}
println!("{}", footer);

io::stdoutのロックとアンロックを多数行い、行数に応じて線形的に増加する(一つ一つはおそらく小さな)書き込みを行う。これを以下のように高速化する:

{
    let mut lock = io::stdout().lock();
    let mut buf = io::BufWriter::new(lock);
    writeln!(buf, "{}", header);
    for line in lines {
        writeln!(buf, "{}", line);
    }
    writeln!(buf, "{}", footer);
}   // end scope to unlock stdout

これはロックを一度だけ行い、バッファがいっぱいになったとき(あるいはbufが閉じられたとき)に書き込みを行うので、最初のコードより十分に速くなる。

同様に、ファイル操作やネットワークIOについても、バッファIOを使いたくなる場面があるだろう(ロックが必要かは場合による)。

行ごとの読み込み

前項と似たような話になるが、Read::lines()イテレータも非常に使いやすいものの、欠点が一つある:行ごとにStringのメモリ割り当てを行うのだ。Stringを手動で割り当て再利用すれば、メモリー・チャーン(memory churn)を減らし、多少パフォーマンスに資するところがあるだろう。

以下のコード

for line in buf.lines() {
    let line = line.unwrap();
    // do something with line
}

は、次のようにして行ごとの余分なアロケーションを除去するべきだ:

let mut line = String::new(); // may also use with_capacity if you can guess
while buf.read_line(&mut line).unwrap() > 0 {
    // do something with line
    line.clear(); // clear to reuse the buffer
}

str vs. [u8]

大部分の操作は文字列のエンコーディングに関係なく実行できる。しかし、今のRustはUTF-8エンコーディングを使うことを要求し、このことを文字列の作成時にチェックする。これは正しいUTF-8データであることを当てにすることができるので、非常に良いことであるが、これはRustが一度UTF-8チェックのために入場料を払い、そのあとで高速で正しい文字列処理メソッドを実装することを許しているということを意味する。

このチェックをお払い箱にするためには、byte列を直接使う(通常はVec<u8>/&[u8]を使う)か、あるいは入力値が絶対に正しいUTF-8文字列であるという絶対的確信があるならば、str::from_utf8_unchecked(_)を使うとよい(留意点として、この関数はunsafeを要求する。もし入力が正しくないUTF-8文字列であった場合はプログラムがどんな壊れ方をするか分からない)。

regex crateはregex::bytesというサブモジュールがあり、regex&strで行うすべてのことをbyteスライス上でも行えるように関数を取り揃えている。

多くの構文解析を行うcrateは、UTF-8チェックを避けるためにbyteスライス上で開発されている。

このテクニックはあまりに広く使われているので、ここで実例を挙げることはしない。

自作のハッシュ法を使う

多くの意見がハッシュ法について書かれてきた(私のも含む)

UTF-8文字列のように、これについてもRustの選択は思慮深くなされてきた。安全なデフォルトに対して、もし望むならそれを無効化することができるということである。

例として、RustコンパイラはFNVハッシュ関数を使うためにfnv crateを利用しており、短い文字列に対しては素早くよい結果を得ている。

デフォルトのハッシュ法をいじりたい場合、測定を行い、ハッシュキーの分布について自分なりの意見を持っているべきであり、そうでない場合はなにもするべきではない。こうした努力に応じて、それに見合ったなにかしがのご利益を得ることができるかもしれないが、注意深くハッシュ関数を選び、よいベンチマークの体制を整えることは、他の[改善]提案に比べるとより険しい道のりとなるだろう。

基準線として、(もしキーにOrdが実装されているならば)自作のhash map/hash setをBtreeMap/BtreeSetで置き換えてパフォーマンスの違いをみるとよい。またいくつかのケースでは、ordermapは多少のメモリを支払って、メモリの局所性を改善するマップを提供する(これは高速なイテレーションをしたいときに効果的である)。

添え字を使わず、イテレートせよ(単純なループでは)

複雑なイテレータの連鎖は今のところ最適化が十分でないために、大部分の単純なケースでは、イテレータを使うほうが添え字つきループを使うより高速である。以下の添え字ループはダメで:

for i in 0..(xs.len()) {
    let x = xs[i];
    // do something with x
}

次のようにするべきである:

for x in &xs {
    // do something with x
}

いくつかの注意点がある:

  • ループ内で添え字(例えばi)を使いたい場合は、for (i, x) in xs.iter().enumerate()を使おう。
  • イテレータxsをborrowするのに対して、添え字付きループは添え字を回す操作を行うのみである。よって、borrowチェッカーを素通りするために添え字ループを必要とする場面があるかもしれない。とはいえ、いくつかの値の読み出しパターンはイテレータのメソッドとしてサポートされている(例:xs.iter().windows(2)イテレータの隣りあった2つの要素を読み出すのに使える)。
  • 留意すべきは、borrowが働くということは、xs上でイテレーティングしている時は変更もできないということを意味する。しかしながら、そういった変更についても特化したイテレータが存在する(例:in-placeで要素を削除するDrain

不要なcollect()

同じくイテレータに関して、もしFromIterator::collect()を使っているなら、それが新しいコレクションをメモリ上に割り当て、それぞれのイテレーションステップごとに評価を強制していることに注意してもらいたい。collectという文字列を書き出す前に、それが本当に必要なのか自分の胸に聞いてみて欲しい。次のコード:

let nopes : Vec<_> = bleeps.iter().map(boop).collect();
let frungies : Vec<_> = nopes.iter().filter(|x| x > MIN_THRESHOLD).collect();

は、以下のようにするべきである。

let frungies : Vec<_> = bleeps.iter()
                              .map(boop)
                              .filter(|x| x > MIN_THRESHOLD)
                              .collect();

このあとfrungiesで何をするかにもよるが、2番目のcollectも取り除くことができるかもしれない。collect()を削除した場合、関連のありそうな処理に注意を払い評価順序を変更するべきである。

不要なアロケーションを避ける

Rustは、不要なアロケーションを避けるためのツールを多く提供している。しかしながら、それは我々が不要なアロケーションを避けたいと強く望まないことには得難いものである。特に駆け出しのRustプログラマがはじめてborrowチェッカと見解を違えたとき、しばしば簡単な応急処置として.to_owned()clone()を呼び出す。これはコードを単純さを保つが、おそらくパフォーマンスの劣化を招くだろう。

アロケーションを避けるためのテクニックは多数あるので、ここでは私が便利でかつ実装が十分に容易であると判断したものに限りリスト化した:

  • &Stringの代わりに&strを使うべき(Stringも同様。明らかに必要なとき以外は避けるべき)
  • 同様に、&Vec<T>(またはVec<T>)の代わりに&[T](スライス)を使うべき。またリサイズ操作を行わない限りは&mut Vec<T>の代わりに&mut [T]を使うべきである。
  • 静的な値の場合、多くの場合Vecの代用として配列を使うことができる。参照はスライスから得ることができる。
  • もしownedな値をborrowで置き換えられないときは、Cowの利用を検討して欲しい。例として、すべてではないものの一部をborrowできるとき、StringCow<'static str>で、Vec<T>Cow<'static str>でたいてい置き換え可能である。
  • ときおり、enumのデータを変更したとき、古い値の一部を保持したいときがある。mem::replaceを使うと不要なcloneを避けることができる

不要な仕事を避ける(遅延性)

場合により、プログラムにわずかな遅延性を付加することが有益な効果をもたらす。例として、可読性の名において、以下のコード

match my_option {
    Some(foo) -> frobnicate(foo),
    None -> calculate_default_frob(),
}

my_option.map_or(calculate_default_frob(), frobnicate)に書き換えたいと思うかも知れない。だが、このコードはfooが存在する場合でもcalculate_default_frobを実行してしまうので得策ではない。my_option.map_or_else(calculate_default_frob, frobnicate)を呼ぶのがこの場合の正解である。ここでは関数の名前を直接書いているが、いくつかの引数をキャプチャする場合(例:Result::ok_or_else(..))や自動デリファレンス(auto-dereference)すろとき(例:&Boxはその中身がrefにデリファレンスされる)はクロージャが必要になることにも留意されたい。最も簡単なのは、普段はクロージャを使い、それを削除するときclippyを使うことである。

RustはLLVMを使っており、LLVMはdead-store分析をするので、不要なデフォルト計算を最適化により除去するかもしれない。しかしながら、この分析は常に動くとは限らない。常に効果を測定するべきである。

もっと?

今持ってるネタはこれで終わり。もしあなたが落とし穴の地図を持っているなら、/r/rustrust-usersで議論してくれ!