「RustコードのSIMD高速化のための9つのルール(パート2)」
「RustコードのSIMD高速化のための9つのルール(パート2)を活用して、効果的なプログラミングを実現!」
レンジセットブレイズのデータインジェクションを7倍に向上させるための一般的な教訓
シアトルラストミートアップのBen Lichtman (B3NNY) さんにSIMDに関する正しい方向性を教えていただきました。
これはRustでSIMDコードを作成する記事のPart 2です(Part 1はこちらを参照してください)。ルール7から9を見ていきます:
- 7. クリテリオンベンチマークを使用してアルゴリズムを選択し、LANESはほぼ常に32または64であることを発見する。
- 8.
as_simd
を使用した自分の最善のSIMDアルゴリズムをプロジェクトに統合する、i128
/u128
のための特別なコードと追加のコンテキストベンチマークを使用する。 - 9. オプションのcargoフィーチャーを使用して、現時点で自分の最善のSIMDアルゴリズムをプロジェクトから取り外す。
規則1から6を思い出してみましょう:
- ナイトリーラストと
core::simd
を使用する。Rustの実験的な標準SIMDモジュールです。 - CCC:自分のコンピュータのSIMDの能力を確認、制御、選択する。
core::simd
を学ぶが、選択的に使用する。- 候補アルゴリズムを考え出す。
- GodboltとAIを使用して、アセンブリ言語を知らなくてもコードのアセンブリを理解する。
- すべての型とLANESに対してインラインのジェネリクス、(それが機能しない場合)マクロ、(それが機能しない場合)トレイトで一般化する。
これらのルールは、「clumpy(塊状の)」整数のセットを操作するためのRustクレートであるrange-set-blaze
のスピードアップを試みる経験に基づいています。
ルール6、Part 1から、RustのSIMDアルゴリズムを型とLANESにわたって完全にジェネリックにする方法は既に説明しました。次に、自分のアルゴリズムを選択し、LANESを設定する必要があります。
ルール7: クリテリオンベンチマークを使用してアルゴリズムを選択し、LANESはほぼ常に32または64であることを発見する。
このルールでは、人気のある<criterionクレートを使用して、アルゴリズムとオプションをベンチマークおよび評価する方法を見ていきます。range-set-blaze
のコンテキストでは、以下の項目を評価します:</criterion
- 5つのアルゴリズム — Regular, Splat0, Splat1, Splat2, Rotate
- 3つのSIMD拡張レベル —
sse2
(128ビット),avx2
(256ビット),avx512f
(512ビット) - 10つの要素タイプ —
i8
,u8
,i16
,u16
,i32
,u32
,i64
,u64
,isize
,usize
- 5つのレーン数 — 4, 8, 16, 32, 64
- 4つの入力長 — 1024, 10,240, 102,400, 1,024,000
- 2つのCPU —
avx512f
を搭載したAMD 7950X,avx2
を搭載したIntel i5–8250U
ベンチマークは、各組み合わせの実行時間の平均を測定します。そして、スループットをMbytes/秒で計算します。
この新しい関連記事では、Criterionの始め方について紹介しています。その記事では、SIMD拡張レベルなどのコンパイラの設定の効果を測定するためにCriterionをどのように利用するかも示しています。
ベンチマークを実行すると、5000行の*.csv
ファイルが生成されます。ファイルの先頭は以下のようになります:
Group,Id,Parameter,Mean(ns),StdErr(ns)...
このファイルは、スプレッドシートのピボットテーブルや、Polarsなどのデータフレームツールを使用して分析するのに適しています。
アルゴリズムとレーン
以下のExcelピボットテーブルには、各アルゴリズムに対するスループット(MBytes/秒)とSIMDレーン数の関係が示されています。このテーブルでは、SIMD拡張レベル、要素の種類、入力の長さにわたってスループットが平均されています。
私のAMDデスクトップマシン上では:
インテルのラップトップマシン上では:
これらのテーブルからは、Splat1とSplat2が最も優れていることがわかります。また、32または64までレーン数が増えるほど、より多くのレーンが常に良い結果を示しています。
例えば、
sse2
(幅128ビット)は、64レーンのi64
(幅4096ビット)をどのように処理するのでしょうか?Rustのcore::simd
モジュールは、4096ビットを自動的かつ効率的に32個の128ビットのチャンクに分割することで、このマジックを可能にしています。32個の128ビットのチャンクを一緒に処理することで(明らかに)、128ビットのチャンクを個別に処理するよりも最適化が可能になります。
SIMD拡張レベル
64レーンとして、AMDマシンで異なるSIMD拡張レベルを比較してみましょう。このテーブルでは、要素の種類と入力の長さにわたってスループットが平均されています。
私のAMDマシンでは、64レーンを使用する場合、sse2
が最も遅いです。avx2
とavx512f
を比較すると、結果はまちまちです。再び、アルゴリズムの中でSplat1とSplat2が最も優れています。
要素の種類
次に、SIMD拡張レベルをavx512f
に設定し、異なる要素の種類を比較してみましょう。ここでは、64レーンを使用し、入力の長さにわたってスループットが平均されています。
ビット単位、32ビット、64ビットの要素が最も速く処理されていることがわかります。(ただし、要素ごとには、より小さな型の方が速いです。)Splat1とSplat2が最も速いアルゴリズムであり、Splat1の方がわずかに優れています。
入力の長さ
最後に、要素のタイプを<i32に設定して、入力の長さとスループットを比較しましょう。</i32
100万件の入力では、すべてのSIMDアルゴリズムがほぼ同じ結果となっています。Short1アルゴリズムは、短い入力の場合に他のアルゴリズムよりも優れているようです。
また、短い方が長いよりも高速なようです。これはキャッシュの結果か、ベンチマークで非アラインデータを処理した結果かもしれません。
ベンチマークの結論
これらのベンチマークに基づいて、Short1アルゴリズムを使用します。現時点では、LANESを32または64に設定しますが、次のルールでは少し複雑な状況について説明します。最後に、ユーザーにはSIMD拡張レベルを少なくともavx2
に設定するようにアドバイスします。
ルール8:最良のSIMDアルゴリズムをas_simd
を使用してプロジェクトに統合し、i128
/u128
用の特別なコード、さらにコンテキスト内のベンチマーキングを追加します。
as_simd
SIMDサポートを追加する前のRangeSetBlaze
のメインコンストラクタはfrom_iter
でした:
let a = RangeSetBlaze::from_iter([1, 2, 3]);
しかしながら、SIMD操作はイテレータよりも配列でより良く動作します。さらに、配列からRangeSetBlaze
を構築することはしばしば自然なプロセスであるため、新しいfrom_slice
コンストラクタを追加しました:
#[inline] pub fn from_slice(slice: impl AsRef<[T]>) -> Self { T::from_slice(slice) }
新しいコンストラクタは、各整数のfrom_slice
メソッドに直接呼び出しを行います。整数型では、ただしi128
/u128
を除くすべてに対して、次のようになります:
let (prefix, middle, suffix) = slice.as_simd();
Rustの夜間ビルドでのas_simd
メソッドは、スライスを安全かつ迅速に以下のものに変換します:
- アンアライント
prefix
— これは以前と同様にfrom_iter
で処理します。 middle
は、Simd
構造体のチャンクからなるアライメントされた配列です。- アンアライント
suffix
— これは以前と同様にfrom_iter
で処理します。
middle
は、入力の整数をサイズ16のチャンク(またはLANESに設定される任意のサイズ)に分割することをイメージしてください。それから私たちはis_consecutive
関数を使ってチャンクを走査し、true
の範囲を検索します。各範囲は1つの連続範囲として扱われます。例えば、1000から1159の160個の個々の連続した整数の範囲は単一のRustのRangeInclusive
1000..=1159
に置き換えられます。この範囲は、160個の個別の整数を処理するよりも遥かに速くfrom_iter
で処理されます。もしis_consecutive
がfalse
を返す場合、チャンク内の個々の整数をfrom_iter
で処理します。
i128
/u128
core::simd
がハンドルしないi128
/u128
の配列をどのように扱いますか?現時点では、遅いfrom_iter
で処理しています。
コンテキスト内ベンチマーク
最後のステップとして、SIMDコードをメインコードのコンテキストで、理想的には代表的なデータでベンチマークしてください。
range-set-blaze
のクレートは、すでにベンチマークを含んでいます。1つのベンチマークでは、さまざまな密度で1,000,000の整数を取り込むパフォーマンスを測定しています。平均的な密度のクラムプサイズは1(クラムプなし)から100,000クラムプまでの範囲です。LANESを4、8、16、32、および64で実行してみましょう。アルゴリズムにはSplat1およびSIMDの拡張レベルavx512f
を使用します。
各クラムプサイズについて、バーは1,000,000の整数を取り込む相対的な速度を示しています。各クラムプサイズにおいて、最速のLANES
は100%に設定されます。
クラムプサイズが10と100の場合、LANES
=4が最適です。ただし、クラムプサイズが100,000の場合、LANES
=4は最適解から4倍も遅いです。逆に、クラムプサイズが100,000の場合、LANES=64は良さそうですが、クラムプサイズが100および1000の場合、最適解よりもそれぞれ1.8倍および1.5倍遅いです。
私はLANES
を16に設定することにしました。これはクラムプサイズ1000に対して最も効果的です。さらに、最も良い解と比べても1.25倍以上遅くなることはありません。
この設定で他のベンチマークを実行できます。以下のチャートは、さまざまな範囲セットライブラリ(range-set-blaze
を含む)が同じタスクで動作する様子を示しています-さまざまな密度の1,000,000の整数を取り込む。 y
-軸はミリ秒で、値が小さいほど良いです。
クラムプサイズが1000の場合、既存のRangeSetBlaze::into_iter
メソッド(赤)はすでにHashSet(オレンジ)よりも30倍速いです。スケールが対数です。 avx512f
を使用する場合、新しいSIMD対応のRangeSetBlaze::into_slice
アルゴリズム(ライトブルー)はHashSetよりも230倍速いです。 sse2
(ダークブルー)を使用する場合は220倍速いです。 avx2
(黄色)を使用する場合は180倍速いです。このベンチマークでは、RangeSetBlaze::into_iter
と比較して、avx512f
RangeSetBlaze::into_slice
は7倍速いです。
最悪の場合も考慮する必要があります。つまり、クラムプがないデータを取り込む場合です。そのベンチマークを実行しました。既存のRangeSetBlaze::into_iter
はHashSetよりも約2.2倍遅いことがわかりました。新しいRangeSetBlaze::into_slice
はHashSetよりも2.4倍遅いです。
したがって、バランスを考えると、新しいSIMDコードはクラムプのあるデータにとって非常に有益です。仮定が間違っている場合は遅くなりますが、致命的ではありません。
SIMDコードをプロジェクトに統合したら、出荷の準備が整ったはずですね?残念ながら、そうではありません。なぜなら、コードがRust Nightlyに依存しているため、オプションにする必要があるからです。次のルールでその方法を見ていきましょう。
ルール9:最良のSIMDアルゴリズムをプロジェクトから(現時点では)オプションのCargoフィーチャとして分離する。
私たちの美しい新しいSIMDコードは、Rust Nightlyに依存しており、定期的に変更されます。Rust Nightlyに依存することをユーザーに要求するのは冷酷です。(また、何かが壊れたときに苦情を受けるのは迷惑です。)解決策は、SIMDコードをCargoのフィーチャの背後に隠すことです。
Feature, Feature, Feature — SIMDとRustの作業の文脈において、「feature(機能)」という単語は3つの異なる意味で使用されます。まず、「CPU/target features(CPUの機能)」- これは、CPUの機能を表し、どのSIMD拡張をサポートしているかを含みます。
target-feature
とis_x86_feature_detected!
を参照してください。次に、「nightly feature gates(夜間の機能ゲート)」- Rustは、Rust nightlyでの新しい言語機能の可視性を feature gatesで制御します。例えば、#![feature(portable_simd)]
です。3つ目は、「cargo features(cargoの機能)」- これにより、任意のRustクレートやライブラリが自身の機能の一部へのアクセスを提供/制限することができます。例えば、itertools/use_std
への依存関係を追加する場合に、Cargo.toml
で見ることができます。
ナイトリーに依存するSIMDコードをオプションにするために、range-set-blaze
クレートが取る手順は次のとおりです:
Cargo.toml
で、SIMDコードに関連するcargo featureを定義します:
[features]from_slice = []
- トップの
lib.rs
ファイルで、ナイトリーのportable_simd
フィーチャーゲートがfrom_slice
のcargo featureに依存するようにします:
#![cfg_attr(feature = "from_slice", feature(portable_simd))]
- 条件付きコンパイル属性を使用し、たとえば
#[cfg(feature = “from_slice”)]
を使って、SIMDコードを選択的に含めます。これにはテストも含まれます。
/// 整数のコレクションから[`RangeSetBlaze`]を作成します。これは通常、[`from_iter`][1]/[`collect`][1]よりも多くの場合に高速です。/// 代表的なベンチマークでは、スピードアップ倍率は6倍でした。////// **注意:ナイトリーのコンパイラが必要です。また、`Cargo.toml`で`from_slice`フィーチャーを有効にする必要があります。たとえば、次のコマンドで:**/// ```bash/// cargo add range-set-blaze --features "from_slice"/// ```////// **注意**: `-C target-cpu=native`オプションでコンパイルすると、バイナリが現在のCPUアーキテクチャに最適化され、/// 異なるアーキテクチャを持つ他のマシンでの互換性の問題を引き起こす可能性があります。これは特にバイナリの配布や様々な環境での実行において重要です。/// [1]: struct.RangeSetBlaze.html#impl-FromIterator<T>-for-RangeSetBlaze<T>#[cfg(feature = "from_slice")]#[inline]pub fn from_slice(slice: impl AsRef<[T]>) -> Self { T::from_slice(slice)}
- 上記のドキュメントに示されているように、ドキュメントに警告や注意を追加します。
- 自分のSIMDコードをチェックまたはテストするために、
--features from_slice
を使用します。
cargo check --features from_slicecargo test --features from_slice
--all-features
を使用して、すべてのテストを実行し、すべてのドキュメントを生成し、すべてのcargo featureを公開します:
cargo test --all-features --doccargo doc --no-deps --all-features --opencargo publish --all-features --dry-run
結論
以上が、RustコードにSIMD演算を追加するための9つのルールです。このプロセスの容易さは、core::simd
ライブラリの優れた設計を反映しています。適用可能な場所では常にSIMDを使用するべきでしょうか?いずれにせよ、Rustのnightlyからstableに移行するまでは、パフォーマンスの利点が重要な場合にのみSIMDを使用するか、その使用をオプション化してください。
RustでSIMDのエクスペリエンスを改善するためのアイデアはありますか?core::simd
の品質は既に高いですが、安定させる必要があります。
SIMDプログラミングの旅にご参加いただきありがとうございました。SIMD適用可能な問題がある場合は、これらの手順が助けになることを願っています。
CarlをVoAGIでフォローしてください。私はRustとPythonでの科学的プログラミング、機械学習、統計について書いています。通常、月に1つの記事を書いています。
We will continue to update VoAGI; if you have any questions or suggestions, please contact us!
Was this article helpful?
93 out of 132 found this helpful
Related articles