Rustの公式フォーラムを眺めていたら、ちょっと気になる話を見つけました。
Buffer initialization does not get optimized out
どうやら、Rustで配列をゼロ初期化してから全部上書きしても最初のゼロ初期化が消えずに残ってしまうことがあるらしいです。
え、そこはコンパイラがいい感じにしてくれるんじゃないの?
と思ったんですがどうもそう簡単な話でもないようで。
しかもこれ、普段のコードの書き方をちょっと見直すだけで性能改善につながる可能性もありそうです。
そんなわけで、少し調べてみました。
先に結論
今回の話をものすごく簡単にすると、
あとで全部上書きするなら、最初からゼロで埋めなくてもいいんじゃない?
というものです。
例えばこんなコード。
let mut result = [0u64; N];
for i in 0..N {
result[i] = u64::from_be_bytes(chunks[i]);
}
最初にゼロで埋めた配列を用意してあとから全部書き換えています。
個人的にはまぁよくある初期化という印象です。
もちろん、コンパイラがこの無駄を見つけて削除してくれる場合もあるそうです。
ところが、条件によっては削除してくれないこともあるらしい。
そこで、最初から必要な値を生成する書き方にすれば、余計な初期化を避けられるかもしれないという話のようです。
ただし、書き方を変えたからといって必ず速くなるわけではありません。
なんだか当たり前の話をしているだけのような気もしますが、どうやらコンパイラが必ずしも期待どおりに最適化してくれるわけではないというところが今回の面白いところのようです。
ちなみに、今回紹介する書き方は最近登場した新機能というわけではなく以前から使えるものです。
同じような処理でも、書き方やコンパイラの設定、配列の大きさによって生成されるコードに違いが出るらしい。
今回はそんなお話。
具体的には何が起きていたのか
元の投稿ではバイト列をu64の配列に変換する処理が紹介されていました。
投稿にあったコードがこちらです。
const N: usize = 32;
fn read(block: &[u8; 8 * N]) -> [u64; N] {
let (chunks, tail) = block.as_chunks();
assert!(tail.is_empty());
let mut block = [0u64; N];
for i in 0..N {
block[i] = u64::from_be_bytes(chunks[i]);
}
block
}
出典:Rust Forum - Buffer initialization does not get optimized out
配列をゼロで初期化してからループで全部書き換えるというものです。
ところが投稿者がコンパイラの出力を確認したところゼロ初期化の処理が残っていたそうです。
ちなみに実際のコンパイル結果も公開されています。
こちらはRISC-V向けの例で生成されたコードにmemsetが残っている様子を確認できるそうです。
memsetというのはメモリを指定した値で埋める処理のやーつですね。
つまり今回の例では、
- 配列をゼロで埋める
- 全部別の値で上書きする
- 最初に書いたゼロは一度も使われない
ということが起きていたようです。
ん?
コンパイラには使われない書き込みを削除する最適化もあるそうですがループの中で全部上書きされることを判断するのは思っているほど簡単ではないようです。
人間から見ると「いや、全部上書きしてますやん」と思うところですがコンパイラにもいろいろ事情があるようで。
ちなみにLLVMにも以前から似たような問題が報告されていました。
こちらは2020年6月25日に報告されたもので2026年10月18日時点でもOpenのままです。
同じコードの問題ではありませんがループで上書きされる不要な書き込みを削除できないという点では似た話のようです。
なかなか根の深い問題なのかもしれませんね。
フォーラムでは実際に試している人も
この話、フォーラムでのやり取りもなかなか面白かったです。
参加者たちがCompiler Explorerというサイトを使い、CPU向けの設定や最適化レベルを変更してコンパイラが生成するコードを比較していました。
どうやら、元のゼロ初期化の話とは別にfrom_fnを使った書き方についても議論されていたようです。
from_fn版ではコピー処理が残ることも
まず、ゼロ初期化を避ける方法としてstd::array::from_fnという書き方が挙げられています。
元の投稿者がCompiler Explorerで試していたコードがこちらです。
const N: usize = 32;
fn read(block: &[u8; 8 * N]) -> [u64; N] {
let (chunks, tail) = block.as_chunks();
assert!(tail.is_empty());
core::array::from_fn(|i| {
u64::from_be_bytes(chunks[i])
})
}
こちらは最初から必要な値を生成する方法なので、ゼロ初期化は必要ありません。
ところが投稿者によると、これでもmemcpyというメモリのコピー処理が残ってしまうことがあったそうです。
実際のコードとコンパイル結果はこちら。
Compiler Explorer:投稿者のfrom_fn版(memcpyが残る例)
ゼロ初期化をなくしても今度はコピー処理が残ってしまう。
うーむ、なかなか思いどおりにはいかないようです。
そこでscottmcm氏が、同じコードをx64-v3向けに設定すると、memcpyもallocaも出てこない例を紹介しています。
Compiler Explorer:scottmcm氏のx64-v3向け設定
おーちゃんと最適化されてる。
つまりRustのコード自体は同じでもCPU向けの設定によって生成されるコードが変わっているわけですねー。
さらに、x64-v3全体を指定しなくてもAVX2を有効にするだけで同じように最適化されるという話も出ていました。
ところが、投稿者が今度は配列サイズをN = 32からN = 256に変更すると、またmemcpyが出てきたそうです。
Compiler Explorer:N = 256に変更した例
おかえりなさい。いや、帰ってこなくていいんですが。
どうやら、YMMレジスタというCPUの仕組みが使えるかどうかやループを完全に展開できるかどうかが関係していそうだという話になっていました。
コードの書き方だけでなくCPUの設定や配列の大きさでも結果が変わるんですね。
なんかもう、結局どうすればいいのか整理が追っつかなくなってきました。
元のゼロ初期化版でも設定によって変わる
一方、元のゼロ初期化するコードについては、-C opt-level=3にすると、うまく最適化されるという報告がありました。ちなみに、元スレッドで紹介されているCompiler Explorerの検証例はopt-level=2で行われていたそうです。
よかったよかった。よかった?
ところが、配列を大きくしてループが展開されなくなると、再びmemsetが現れるとの指摘が…あぁもう!!
ちなみに、Rustのcargo build --releaseでは、デフォルトでopt-level=3が使われます。
なので、結局のところ、普段からリリースビルドしている人のコードでは、最初から問題なく最適化されていることもありそう?な雰囲気。
結局のところ、コードの書き方だけでなく配列サイズやコンパイラの設定によっても結果が変わるということらしいです。
めんどうだなー。
コンパイラに全部お任せしておけば必ず大丈夫、というわけでもないんですね。
いや、はい。当然でしたね。
じゃあ、どう書けばいいのか
せっかくなので普段の開発でも使えそうな方法を少し調べてみました。
固定長配列ならfrom_fn
まずは元の投稿と同じ、固定長配列の場合です。
let result: [u64; N] = std::array::from_fn(|i| {
u64::from_be_bytes(chunks[i])
});
こちらは最初から必要な値を生成するのでわざわざゼロで埋める必要がありません。
わりとすっきりしていますね。
ただし、元の投稿者は実際のコードでは前に計算した要素に次の要素が依存するため単純に置き換えられないと説明しています。
ちなみにfrom_fnは先頭から順番に値を作るので直前の値を変数で持ち回すくらいならできるそうです。ただ作っている途中の配列そのものは参照できないので何個か前の要素を使うような計算だとやはり難しいようです。
また、先ほどの話のようにこの書き方でも余計なコピーが残る場合があるようです。
うーん、なかなか一筋縄ではいきません。
Vecでも似た考え方ができそうなので試してみました
ここからは元のフォーラムで検証された話ではなく応用として考えた例です。
今回のような話ならVecでも似たようなことができるんじゃね?ということで。
例えばこんなコード。
let mut result = vec![0u64; data.len()];
for (i, &value) in data.iter().enumerate() {
result[i] = value.wrapping_mul(2);
}
最初にゼロで埋めたVecを作って、そのあと全部上書きしています。
これならこんな書き方もできそうです。
let result: Vec<u64> = data
.iter()
.map(|&value| value.wrapping_mul(2))
.collect();
最初から必要な値を作って並べていく方法ですね。
また、次のように容量だけ確保してあとから追加する方法もあります。
let mut result = Vec::with_capacity(data.len());
for &value in data {
result.push(value.wrapping_mul(2));
}
こちらも最初からゼロで埋める必要はありません。
ちなみに、どれも同じ計算になるようにwrapping_mul(2)を使っています。整数があふれた場合でも同じ結果にするためです。
さて、これも実際に生成されるコードに違いが出るんでしょうか。
気になったのでそのままCompiler Explorerで試してみました。
っていうかおでも使ってみたい!という好奇心です。
比較したのは先ほどの3種類で、Rust 1.99.0を使いコンパイラの設定を変えています。
実際に使用したコードと生成結果はこちらです。
- Compiler Explorer:最適化なし(opt-level=0)
- Compiler Explorer:通常の最適化(opt-level=3)
- Compiler Explorer:x86-64-v3向けの最適化
今回は実行速度を測るのではなく生成されたコードを比較しています。
結果、opt-level=3ではこんな違いがありました。
| 書き方 | 確認できたこと |
|---|---|
| ゼロ初期化して上書き | ループはSIMD化されたが、ゼロ初期化付きのメモリ確保が残った |
map + collect |
通常のメモリ確保になり、ループもSIMD化された |
with_capacity + push |
通常のメモリ確保になったが、SIMD化されず、要素ごとの容量チェックが残った |
お、ちゃんと違いが出ましたね。
……とはいえ、生成されたコードを見ても私には詳しい説明ができませんw
というわけで結果をAI君に説明してもらいました。
検証条件
- コンパイラ:Rust 1.99.0
- 比較対象:3種類の
Vec<u64>生成処理 - 最適化設定:
opt-level=0、opt-level=3、opt-level=3 + x86-64-v3
1. メモリ確保処理の違い
opt-level=3の生成コードでは、以下の違いが確認された。
-
ゼロ初期化後に上書き:
__rust_alloc_zeroedを使用。ゼロ初期化付きメモリ確保が残存している。 -
map + collect:__rust_allocを使用。ゼロ初期化を伴わないメモリ確保になっている。 -
with_capacity + push:__rust_allocを使用。こちらもゼロ初期化を伴わないメモリ確保になっている。
2. SIMD最適化の違い
zero_then_overwriteとmap_collectの両方で、SIMD命令が確認された。
- 通常の
opt-level=3:両者とも128bitのpaddq xmmを使用。 - x86-64-v3向け:両者とも256bitの
vpaddq ymmを使用。 -
with_capacity + push:今回の条件ではSIMD化されず、容量チェックとgrow_oneへの経路が残存している。
ゼロ初期化版とmap + collect版は、計算ループがほぼ同じ形に最適化されており、主な違いはメモリ確保処理にある。
一方、with_capacity + push版は、同じ計算を実行するにもかかわらず、異なる生成コードとなった。
3. 最適化なしの場合
opt-level=0では関数呼び出しや各種チェック処理が多く残存するため、最適化後のコードと単純に比較することは適切ではない。
4. 結果の解釈と注意点
- 今回の
Vecはヒープ領域を使用しており、元フォーラムの固定長配列とはメモリ確保の仕組みが異なる。 -
__rust_alloc_zeroedはゼロ初期化付きメモリ確保を要求するが、実際のゼロ初期化はOSやアロケータによって効率化される場合がある。 - SIMD命令の有無や生成コードの違いだけでは、実際の処理速度を確定できない。
- 性能上の優劣を判断するには、同一条件での実行時間測定が必要。
結論
今回の検証では、ゼロ初期化版とmap + collect版の両方でSIMD最適化が確認されたが、メモリ確保方式には違いが残った。
また、with_capacity + push版ではSIMD化されず、容量チェックが残存していた。
ただし、これらは生成コードの解析結果であり、実行性能の改善を実証するものではない。
なるほど。
同じ結果を作るのに書き方でこんなに変わるのねー。
しかしコンパイラは深淵を覗くような世界ですね。
もっと細かく制御する方法もあるらしい
RustにはMaybeUninitという初期化前のメモリを扱う仕組みもあります。
こちらを使えば初期化のタイミングを自分で管理することもできるようです。
ただし、使い方を間違えると未定義動作につながる可能性があるため慎重に扱う必要があります。
性能を改善しようとして安全性を失ってしまっては何をしているのかわかりません。
できることならまずは安全なRustの範囲でどうにかしたいところ。
どんな開発で効果がありそう?
今回の話はどういう開発に応用する?とまたAI君に相談してみました。
| 分野 | 見直せそうなところ |
|---|---|
| 画像・動画処理 | 画像データやフレームの生成・変換 |
| ゲーム開発 | 毎フレーム作られる一時的な配列 |
| AI・機械学習 | 数値データや中間バッファ |
| データベース | レコードの変換やデコード |
| ネットワーク処理 | パケット生成やデータ変換 |
| ファイル処理 | 大量データの読み込み・変換 |
だそうです。
んー なんか身近だなー。
大きな配列を毎回ゼロで埋めてから全部書き換えるような処理があれば見直してみる価値はありそうです。
それが何千回何万回と繰り返されているなら余計な書き込みを減らす効果も積み重なるかもしれません。
ただし、コンパイラが最初から無駄を削除している可能性もあります。
また、最適化によって別の処理が増えてしまうこともあるので書き換えれば必ず速くなるという話ではありません。
そもそも小さな配列をたまに使う程度なら気にするほどの差は出ないかもしれませんしね。
そしてもうひとつ。
今回の話は主にメモリへの書き込み量や処理時間を減らせるかもしれないというものです。
RAMの使用容量そのものが減るとは限りません。
メモリの使用量とメモリへの書き込み量は別の話なので、そこは分けて考えたほうがよさそうです。(とAI君に刺されました)
Rustの最適化テクニックをまとめた資料
こういう基本的な処理にもまだ最適化の余地が残ってるんですねー。いや毎度のことですがそりゃそうですよね。いやはやいやはや。
どうやら、コンパイラにも得意不得意があるようで。
人間の側もちょっと気を遣ってあげればいいのかもしれません。
ちなみに、そういうテクニック的な物をまとめた情報をちょっと紹介。
Rustの性能改善に特化した資料です。メモリ確保の削減、データ構造の選び方、細かな最適化の工夫などがまとめられています。
2026年にも更新が確認できました。
Rustらしいコードの書き方や設計パターン、避けたほうがいい実装などをまとめた資料です。
性能改善に限らず、普段のコードを見直すヒントになりそうです。
こちらはRustの公式ツールです。
コードの改善点を自動で指摘してくれるもので、性能に関係するclippy::perfという分類もあります。
ただし、今回のようなコンパイラの最適化の取りこぼしを、すべて発見してくれるわけではないようです。
Rustでよりよいコードを書くための、35項目の実践的なアドバイスをまとめた資料です。
性能最適化専門ではありませんが、イテレータの使い方や実装上の工夫など、参考になりそうな話がいろいろあります。
こちらはRustコンパイラの公式資料です。
最適化オプションやコンパイラの設定などを確認できます。テクニック集というより、現在の設定や仕様を調べるための資料ですね。
いろいろありますなー。
ただ、今回の話でもそうだったように、コンパイラのバージョンや設定によって最適化の結果が変わることもあるようです。
古くから知られているテクニックが、今でも同じように効果を発揮するとは限らないんでしょうね。
もちろん、やみくもにコードを書き換えても意味はありません。
ただ、今回のように難しいアルゴリズムを導入しなくても普段のコードの書き方をちょっと見直すだけで改善できる可能性がある、というところがあるらしいというのは知ってはいるものの常に最適を選ぶのは難しい。細かい最適化の話って調べ始めるとキリがなさそうで辛い。
しかしこういう地味な改善、結構好きなんですよね。
まとめ
今回の話をまとめると
- Rustでも不要なゼロ初期化が残ってしまうことがあるらしい
- 配列サイズやコンパイラの設定によって結果が変わるらしい
-
from_fnなど、最初から必要な値を生成する方法もある -
Vecでも似た考え方を応用できそう - 大量のデータを扱う処理では、性能改善につながるかもしれない
- ただし、実際に速くなるかどうかは測ってみないとわからない
という感じです。
コンパイラに無駄を消してもらうより、最初から無駄を作らない。
と言ってしまったらそりゃそうだという話なんですが。
こういう地味なところにも改善の余地があると思うと、普段何となく書いているコードもちょっと違って見えてきますね。
とりあえず今後は「その初期化、本当に必要かね?」と一度くらい考えてみようと思います。
ということで今日の学習は終わりという事にして何か大きな肉を食べようと思います。