ヒープ割り当て

(原文)

ヒープ割り当て (heap allocation) には中程度にコストがかかります。その詳細は使用するアロケータに依存しますが、それぞれの割り当て(そして割り当て解除)はグローバルロックの取得、いくつかの些細でないデータ構造の操作、そして(場合によっては)システムコールを実行します。小さな割り当ては大きなものよりコストがかからないとは限りません。割り当てを避けることは大きくパフォーマンスを改善できることがあるため、どの Rust のデータ構造と命令が割り当てを引き起こすのか理解する価値があります。

Rust Container チートシートは一般的な Rust の型を視覚化しており、続くセクションの理解を助けてくれます。

プロファイリング

一般用途向けのプロファイラーが mallocfree、その他の関連する関数がホットであると示した場合には、割り当ての割合を減らし、代替となるアロケータを利用することを試してみる価値が大いにあります。

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 は最もシンプルなヒープに置かれる (heap-allocated) 型です。Box<T> の値はヒープに置かれた T の値です。

structenum の 1 つあるいはそれ以上のフィールドを box 化することで、型を小さくできる場合があります(この詳細については 型のサイズ というチャプターを参照してください)。

それ以外では Box は簡潔で最適化の余地はあまりありません。

Rc/Arc

Rc/ArcBox と似ていますが、ヒープ上の値は 2 つの参照カウントを持っています。これらの型は値の共有を可能にし、メモリ使用量を削減するための効果的な手段になり得ます。

しかし、めったに共有されないような値に使われると、ヒープに置かれないような値までヒープに置くことで割り当ての割合を大きくしてしまう恐れがあります。

Box と異なり、Rc/Arc 上で clone を呼び出しても割り当ては行われません。代わりに、参照カウントをインクリメントします。

Vec

Vec はヒープに置かれる型で、割り当ての数を最適化したり、無駄なスペースの量を最小化したりする大きな余地があります。このためにはその要素がどのように保持されるかについて理解しなければなりません。

Vec には 3 つの要素があります。長さ、容量、そしてポインタです。ポインタは容量と要素の大きさが 0 でない場合にはヒープに置かれたメモリを指します。そうでない場合は、割り当てられたメモリを指すことはありません。

Vec 自体がヒープに置かれないとしても、その要素(存在していてサイズが0でない場合)はいつもヒープに置かれます。もしサイズが0でない要素が存在する場合、それらの要素を保持するメモリは、追加の要素のためのスペースを開けておくことにより、必要より大きくなっている場合があります。現在ある要素の数が長さとなり、再度割り当てることなく要素を置ける数というのが容量になります。

ベクタが現在の容量を増やす必要が出てきた場合、その要素はより大きなヒープ割り当てにコピーされ、古いヒープ割り当ては解放されます。

Vec の増大

一般的な方法で (vec![]Vec::new あるいは Vec::default) 作られた新しい空の Vec の長さと容量は 0 であり、ヒープ割り当てを必要としません。もし個々の要素をその Vec の最後に繰り返し追加した場合、定期的に再割り当てが発生します。増大させる方法 (strategy) は指定されていませんが、執筆時では quasi-doubling strategy が採用されています。この方法では 0、4、8、16、32、64…、のように容量が遷移していきます(多くの割り当てを避けるため、これは 1 と 2 を飛ばして 0 から 4 へと容量を増やします)。ベクタが増大するにつれ、再割り当ての頻度は指数関数的に減っていきますが、無駄になる可能性のある余分な容量は指数関数的に増えていきます。

この増大の仕組みは要素を増やしていける一般用途向けのデータ構造では理にかなっているものですが、事前にベクタの長さが分かっている場合には、より効率的なことを行えます。ホットなベクタの割り当て場所(例:ホットな Vec::push の呼び出しなど)がある場合は、そこでベクタの長さを出力するために eprintln! を使い、その後長さの分布を見定めるために(counts などを使って)後処理をする価値があります。例えば、たくさんの短いベクタがあったり、それよりすくない数のとても長いベクタがあったりするでしょう。割り当て場所を最適化する一番良い方法はその状況に大きく依存します。

短い Vec

もし短いベクタがたくさんある場合には、smallvec クレートの SmallVec 型を使うことができます。SmallVec<[T; N]>Vec の代替であり、N 個の要素を SmallVec 自身に保持できます。要素数がそれを越えた場合はヒープ割り当てに切り替わります(SmallVec を使う場合、vec![]smallvec![] に置き換える必要があることに注意してください)。

SmallVec は適切に使えば割り当ての割合を確実に下げますが、その使用自体がパフォーマンスの向上を保証するものではありません。それは要素がヒープに置かれているかどうかを必ず確認するため、一般的な処理では Vec よりもわずかに遅いです。また、NT が大きい場合、SmallVec<[T; N]> 自身は Vec<T> よりも大きくなることがあり、SmallVec のコピーは Vec より遅くなります。いつも通り、最適化に効果があるのか確認するためのベンチマークが必須です。

短いベクタがたくさんあり 加えて その最大長を正確に把握している場合、arrayvecArrayVec 型は SmallVec よりも優れた選択肢です。ヒープ割り当てへのフォールバックを必要とせず、SmallVec よりもわずかに高速に動作します。

長い Vec

ベクタの最小の、あるいは実際の大きさをしっている場合、Vec::with_capacityVec::reserve、あるいは Vec::reserve_exact を使って特定の容量を確保できます。例えば、あるベクタが少なくとも20個の要素を持つことが分かっている場合、これらの関数は直ちに1回の割り当てで少なくとも 20 の容量を持つベクタを生成します。対して一度に1つずつアイテムを挿入した場合には4回割り当てをします(容量について、4、8、16、そして 32)。

ベクタの最大長を知っている場合、上記の関数は不必要に余分なスペースを割り当てないでいいようにしてくれます。同様に、Vec::shrink_to_fit は余分なスペースを最小化するために使えますが、再割り当てが必要となる場合があることに注意してください。

String

String はヒープに置かれるバイト列を持ちます。String の表現と操作は Vec<u8> によく似ています。増大と容量に関する多くの Vec メソッドと同等なものが String にもあります。例えば String::with_capacity などです。

smallstr クレートの SmallString 型は SmallVec 型に似ています。

smartstring クレートの String 型は std の String の代替であり、3 語分以下の文字列についてヒープ割り当てを回避します。64-bit プラットフォーム上では、これは 23 以下の ASCII 文字を合わせたすべての文字列を含む、24 bytes 以下の任意の文字列となります。

format! マクロは String を生成する、つまり、割り当てを必要とすることに注意してください。文字列リテラルを使うことで format! の呼び出しを避けられる場合、この割り当てを回避できます。std::format_argslazy_format はこの問題を解決するのに役立つ可能性があります。

ハッシュテーブル

HashSetHashMap はハッシュテーブルです。割り当てという文脈では、それらの表現や操作は Vec のそれに似ています。それらはキーや値を保持しつつ単一の継続したヒープを割り当て、テーブルの拡大に必要となる限り再割り当てします。増大と容量に関する多くの Vec のメソッドと同様のものが HashSet/HashMap にもあります。例えば、HashSet::with_capacity などです。

Cow

時々、&str のような、何らかの借用されたデータを使いたい場合があります。これは殆どの場合読み取り専用ですが、たまに変更する必要が出てきます。毎回そのデータをクローンするのは無駄が多いです。その代わりにCow 型を通して、借用/所有された両方のデータを表現できる、"clone-on-write" なセマンティクスを使うことができます。

通常、借用された値 x から始めるときは、Cow::Borrowed(x) を使って Cow の中に x を包みます。CowDeref を実装しているので、Cow が持っているデータに対して不変 (non-mutating) なメソッドを直接呼び出せます。もし可変である必要がある場合には、Cow::to_mut によって、必要に応じてクローンしつつ、所有されている値への可変参照を取得できます。

Cow をうまく動かすのは難しいですが、試行錯誤する価値は十分あります。

clone

ヒープに置かれたメモリを含む値に対して clone を呼び出すと、通常は追加の割り当てが発生します。例えば、空でない Vec に対して clone を呼び出すと、その要素の新しい割り当てが必要になります(新しい Vec の容量は元のものと異なる可能性があることに注意してください)。例外は Rc/Arc で、clone 呼び出しは参照カウントをインクリメントするだけです。

clone_fromclone の代替です。a.clone_from(&b)a = b.clone() と同等ですが、不必要な割り当てを避ける場合があります。例えば既存の Vec をもとに Vec を1つクローンしたい場合、可能であれば既存の 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's allocation is reused
assert_eq!(v1.capacity(), 99);
}

clone は通常割り当てを発生させますが、多くの状況では利用することは理にかなっておりコードをシンプルにすることも多いです。プロファイリングデータを使ってどの clone の呼び出しがホットで避けるために工夫する価値があるか確認してください。

時々、Rust コードには、(a) プログラマーのミス、(b) コードの変更により以前必要だった clone 呼び出しが不必要になった、などの理由で不必要な clone 呼び出しが含まれることもあります。もし必要そうでないホットな clone の呼び出しを見つけた場合には、単純に削除できることもあります。

to_owned

ToOwned::to_owned は多くの一般的な型に対して実装されています。これは借用されたデータから所有されたデータを作成します。一般的にはクローンが必要で、それによりしばしばヒープ割り当てが発生します。例えば、これは &str から String を作成するときに使われます。

時々、所有されたコピーでなく構造体にある借用されたデータへの参照を保持することで、to_owned 呼び出しを回避できることがあります。これは構造体にライフタイム注釈を必要とし、コードを複雑にします。そのため、プロファイリングやベンチマークでそうする価値があると分かった場合にのみ適用されるべきです。

コレクションを再利用する

Vec のようなコレクションを段階的に作成しなければならない場合があります。この場合、複数の Vec をつくってそれらを組み合わせるよりも、1つの 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 の使用がその他のイテレーションに関係がないということが分かりづらくなることと引き換えに、割り当てを避けることができます。

同様に、構造体の中に "workhorse" なコレクションを保持しておく価値がある場合もあります。これは繰り返し呼び出される 1 つ以上のメソッドの中で再利用されます。

代替となるアロケータを使用する

割り当ての多い Rust プログラムのパフォーマンスを向上させる別の手段として、デフォルトの(システム)アロケータを代替のものに置き換える、というものがあります。正確な影響は個々のプログラムと選ばれるアロケータに依存しますが、一般に大きな速度改善とメモリ使用量の削減をもたらします。各プラットフォームのシステムアロケータはそれぞれ長所短所を持っており、プラットフォームによってもその影響は異なります。また、異なるアロケータの使用はバイナリサイズにも影響します。

人気のある Linux/Mac 向け代替アロケータの 1 つに tikv-jemallocator クレートを通して使える jemalloc があります。使用するには依存関係を Cargo.toml に追記します:

[dependencies]
tikv-jemallocator = "0.4.0"

それから次の行を Rust コードのどこかに追記します:

#[global_allocator]
static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;

tikv-jemallocatorjemallocator クレートのフォークです。tikv-jemallocator は2021年12月現在、jemallocator より新しいバージョンの jemalloc を使用しており、パフォーマンスがわずかに向上しています

多くのプラットフォームで利用可能な代替アロケータとしてもう1つ挙げるとすれば、 mimalloc を通して使える mimalloc があります。

リグレッションを避ける

コード上のアロケーション回数・サイズが意図せず増加していないことを保証するためには、dhat-rs のヒープ使用量テスト機能が便利です。これを使うことで、特定のコードスニペットが想定通りのヒープメモリ量を割り当てているかをテストで確かめられるようになります。