**Single instruction, multiple data(SIMD)**は、ハードウェアレベルでの並列性の一形態です。 CPUは同じ計算を、複数の要素に対して同時に実行します。
たとえば、32ビットの浮動小数点数4つからなる2つの配列を、要素ごとに足し合わせることを考えてみましょう。
スカラーでの実装では、要素ごとにaddss命令を4つ別々に発行することになります。
SIMDでの実装では、1つのaddpsで4つの加算をまとめて並行に行います。
これにより、1命令あたり4倍の仕事をこなせます。
x86-64アセンブリでは、SIMD命令は通常xmmレジスタを使って実行されます。
これらの命令には、整数を扱うものもあれば、浮動小数点数を扱うものもあります。
こうした整数や浮動小数点数は、パックドと呼ばれます。 それぞれは、同じレジスタ内でレーンと呼ばれる区画を1つ占めます。 SIMD命令は通常、2つのソースオペランドをまたいで、同じレーン位置にある値を並行して処理します。 各演算の結果は、デスティネーションオペランドの同じ位置にあるレーンに格納されます。
レーンは、あらかじめ決まった区切りではありません。 命令それぞれが、バイトの並びに与える解釈です。 128ビットのオペランドには16バイトが入っています。 命令によって、これらのバイトは16個の_1バイト_レーン、8個の_2バイト_レーン、4個の_4バイト_レーン、2個の_8バイト_レーン、あるいは1個の_16バイト_レーンとして解釈できます。
たとえば、命令addpsはパックド単精度(4バイト)の浮動小数点数を加算します。
この命令では、オペランド内の4バイトごとのまとまりがそれぞれ別のレーンになります。
128ビット幅(16バイト)のオペランドでは、次のようになります。
| オペランド | 4番目のレーン | 3番目のレーン | 2番目のレーン | 1番目のレーン |
|---|---|---|---|---|
| ソース1 | 1.5 | 2.1 | 6.2 | 3.4 |
| + | + | + | + | + |
| ソース2 | 0.5 | 8.26 | -13.4 | 22.9 |
| = | = | = | = | = |
| 結果 | 2.0 | 10.36 | -7.2 | 26.3 |
一方、mulpdの場合、128ビットのオペランドは2つのレーンを持つものとして解釈されます。
この命令は、パックド倍精度(8バイト)の値に対して乗算を行うからです。
| オペランド | 2番目のレーン | 1番目のレーン |
|---|---|---|
| ソース1 | 4.2 | 2.1 |
| * | * | * |
| ソース2 | 1.0 | 92.4 |
| = | = | = |
| 結果 | 4.2 | 194.04 |
このコンセプトでは、SIMD命令の中からいくつかを選んで詳しく見ていきます。
それらはxmmレジスタを使い、パックド浮動小数点数を扱います。
おおよその目安として、浮動小数点数を扱うSIMD命令は2オペランド形式をとります。 その名前は、次のような構造に従っています。
add、mulなど)。pです。これは、以前にs(_スカラー_データを表す)が置かれていたのと同じ位置に入ります。sは単精度、dは倍精度)。したがって、パックド版の名前は、スカラー版の名前のニーモニック中央にあるs(スカラー)をp(パックド)に置き換えることで得られます。
演算名とサイズの接尾辞は変わりません。
| スカラー | パックド | 意味 |
|---|---|---|
addss |
addps |
加算、単精度(32ビットのレーン) |
addsd |
addpd |
加算、倍精度(64ビットのレーン) |
mulss |
mulps |
乗算、単精度 |
mulsd |
mulpd |
乗算、倍精度 |
算術演算のSIMD命令は、上で説明した規則に従います。 いくつか例を示します。
addps xmm0, xmm1 ; xmm0 = xmm0 + xmm1 (each 32-bit float at the same position)
subpd xmm2, xmm3 ; xmm2 = xmm2 - xmm3 (each 64-bit float at the same position)
mulps xmm4, xmm5 ; xmm4 = xmm4 * xmm5 (each 32-bit float at the same position)
divpd xmm6, xmm7 ; xmm6 = xmm6 / xmm7 (each 64-bit float at the same position)
パックド単精度とパックド倍精度の浮動小数点数を相互に変換するSIMD命令もあります。
これらも同じ命名規則に従い、_スカラー_を表すsがパックドを表すpに置き換わります。
| スカラー | パックド | 意味 |
|---|---|---|
cvtss2sd |
cvtps2pd |
単精度から倍精度へ変換 |
cvtsd2ss |
cvtpd2ps |
倍精度から単精度へ変換 |
これらの命令は、先ほど述べた「レーンは解釈であり、固定された区切りではない」という点をよく示しています。 ソースオペランドとデスティネーションオペランドではレーンのサイズが異なるため、レーンの数も異なります。
cvtps2pdでは、ソース(ps)は4つの単精度レーンを持ちます。
デスティネーション(pd)は2つの倍精度レーンを持ちます。
ソースの下位2レーンだけが読み取られ、上位2レーンは無視されます。
| オペランド | 4番目のレーン | 3番目のレーン | 2番目のレーン | 1番目のレーン |
|---|---|---|---|---|
psとしてのソース |
無視 | 無視 | 2.5 | 1.5 |
pdとしてのデスティネーション |
2.5 | 1.5 |
cvtpd2psでは、ソース(pd)は2つの倍精度レーンを持ちます。
デスティネーション(ps)は4つの単精度レーンを持ちます。
ソースの2つのレーンはどちらも変換され、デスティネーションの下位2レーンに書き込まれます。
デスティネーションの上位2レーンは0に設定されます。
| オペランド | 4番目のレーン | 3番目のレーン | 2番目のレーン | 1番目のレーン |
|---|---|---|---|---|
pdとしてのソース |
4.0 | 3.0 | ||
psとしてのデスティネーション |
0 | 0 | 4.0 | 3.0 |
いくつか例を示します。
cvtps2pd xmm0, xmm1 ; xmm0 = xmm1 as 2 doubles (low 2 lanes of xmm1 are read)
cvtpd2ps xmm0, xmm1 ; xmm0 = xmm1 as 2 floats (in low 2 lanes; upper 2 zeroed)
これまでに見てきたSIMD命令は、すべてレジスタ間のオペランドを使っていました。 これらの命令は、ソースとしてメモリオペランドを取ることもできます(ただし、デスティネーションにはできません)。
メモリから読み取るバイト数は、命令によって決まります。
たとえばaddpsは16バイト(単精度の浮動小数点数4つ)を読み取ります。
一方cvtps2pdは、8バイト(単精度の浮動小数点数2つ)だけを読み取ります。必要な入力がそれだけだからです。
addps xmm0, [rsi] ; xmm0 = xmm0 + 4 floats at [rsi]
mulpd xmm1, [rsi + 16] ; xmm1 = xmm1 * 2 doubles at [rsi + 16]
cvtps2pd xmm2, [rsi + 8] ; xmm2 = (2 floats at [rsi + 8]) converted to 2 doubles
メモリオペランドの幅が16バイトの場合、そのアドレスは_16バイト境界に整列_していなければなりません。 整列したメモリ位置とは、アドレスが演算のサイズ(この場合は16バイト)の倍数になっている位置のことです。
整列したメモリを宣言するには、.dataセクションと.rodataセクションではalignディレクティブを、.bssセクションではalignbを使います。
section .rodata
align 16
mem1: dd 4.0, 3.5, 2.0, -1.0
section .data
align 16
mem2: dq 6.0, 2.1
section .bss
alignb 16
mem3: resb 16
section .text
fn:
movaps xmm0, oword [rel mem1] ; loads 4 packed 32-bit floats in xmm0
movapd xmm1, oword [rel mem2] ; loads 2 packed 64-bit floats in xmm1
movaps oword [rel mem3], xmm0 ; stores 4 packed 32-bit floats in mem3
ret
cvtps2pdの8バイトのソースのように、16バイトより小さいメモリオペランドには整列の要件はありません。
パックド浮動小数点数を128ビット分コピーする命令が4つあります。
これらは2つのxmmレジスタ間、または1つのxmmレジスタとメモリ位置の間で動作します。
| 命令 | 説明 |
|---|---|
movaps |
パックド単精度データを、_整列した_メモリ位置との間でコピーします |
movups |
パックド単精度データを、_整列していない_メモリ位置との間でコピーします |
movapd |
パックド倍精度データを、_整列した_メモリ位置との間でコピーします |
movupd |
パックド倍精度データを、_整列していない_メモリ位置との間でコピーします |
これらの命令はすべて、2つのxmmレジスタ間で値をコピーするのにも使えます。
演算がメモリ位置を伴う場合、movapsとmovapdはそのアドレスが_整列_していることを要求します。
movapsとmovapdは、整列していないアドレスが渡されるとフォールトを発生させます。
これは、データの配置が思い描いていたとおりになっているかを確認する安全チェックも兼ねています。
一方、movupsとmovupdは、整列していてもいなくても、どのメモリ位置に対しても使えます。
NASMで16バイトの値に付けるサイズ接頭辞はowordです。
SIMDは、何十年にもわたってx86-64エコシステムで活発に開発が続けられてきた領域です。 アーキテクチャへの拡張はそれぞれ、新しい命令や、既存の命令の新しい使い方をもたらします。
とくに、AVXとAVX2という拡張では、いくつか重要な変更が導入されています。
第一に、ymmと呼ばれる256ビットのSIMDレジスタがサポートされました。
これらは対応するxmmレジスタを拡張したもので、ちょうどraxがeaxを含んでいるのと同じ関係です。
第二に、xmm(およびymm)レジスタを扱うほとんどの命令に、3オペランドの変種が加わりました。
これはSIMD命令だけでなく、スカラーの浮動小数点数を扱う命令にも当てはまります。
これらの変種は、対応するSSEの命令と同じ表記に従い、先頭にvが付きます。
vaddsd xmm0, xmm1, xmm2 ; xmm0 = xmm1 + xmm2 (scalar sum of 64-bit floats)
vmulps xmm1, xmm3, xmm4 ; xmm1 = xmm3 * xmm4 (packed multiplication of 32-bit floats)
vmovupd ymm5, yword [mem] ; this copies 32 bytes from the location at mem to ymm5
第三に、SIMDのメモリオペランドに求められる整列が緩和されました。
この整列を要求するのは、vmovapsのように明示的に整列を指定する移動命令だけです。
このトラックでは、扱う命令とレジスタを、以前のSSE拡張までのものに限定しています。 これは基礎を教えるのに十分であり、幅広いCPUとの互換性も確保できます。
ただし、テストランナーはAVXとAVX2、さらにはAVX-512に含まれる新しい拡張まで完全にサポートしています。 自由に使ってかまいません。
SIMDの変種を使うときは、一貫性を保つのがよい習慣です。 同じ命令に対してSSEとAVXの変種を混ぜないようにしましょう。
コードベースでこれらの変種を混在させる場合は、AVXを使った作業を終えたあと、SSEを使う前にvzeroupperを使うのがよい習慣です。
これにより依存関係の連鎖が断ち切られ、CPUがAVXとSSEの状態を切り替えるときの高コストなストールを避けられます。
自動車部品工場の現場で動くデータシステムのコードを書いていきます。 エンジンやシャシーの部品は、いくつもの加工ステーションを通っていきます。 各ステーションは測定値を出力し、次の工程に進む前に、それを足し合わせたり、比べたり、校正したり、正規化したりする必要があります。 ここで実装する関数は、ステーションごとに1つずつです。
この演習の計算には、スカラー演算ではなくSIMD命令を使います。
工場の現場では、2本の組立ラインが並行して動いています。 各シフトの終わりに、両方のラインが4つの部品ビンごとの歩留まりを単精度の数値で報告します。 監督者のダッシュボードにはビンごとの合計歩留まりが表示されるので、2つのレポートをビン単位で足し合わせます。 3つのバッファーはすべてレポートシステムが管理していて、常に16バイト境界に整列しています。
line_aとline_bのビンごとの歩留まりを受け取り、ビンごとの合計をresultに書き込む関数sum_yieldsを定義します。
引数は、順に次のとおりです。
line_a: 4つの32ビット浮動小数点数を保持する、16バイト境界に整列したメモリアドレスline_b: 4つの32ビット浮動小数点数を保持する、16バイト境界に整列したメモリアドレスresult: 関数が4つの32ビット浮動小数点数を書き込む、16バイト境界に整列したメモリアドレスこの関数は戻り値を返しません。その代わり、結果は渡されたバッファー(result)に格納されます。
line_a = {10.0, 20.0, 30.0, 40.0};
line_b = { 5.0, 15.0, 25.0, 35.0};
sum_yields(line_a, line_b, result);
// => result == {15.0, 35.0, 55.0, 75.0};
工程と工程の間で、サンプリングステーションが加工済みの部品を測定し、目標寸法と比べます。 測定値は精密ゲージから、常に16バイト境界に整列したバッファーに流れ込みます。 目標値は作業指示パケットから来ますが、こちらは整列が保証されていません。 偏差は、下流の公差チェッカーに渡される前に、軸ごとの感度係数を掛けられます。 この段階では、下流のチェッカーは偏差をメモリからではなくSIMDレジスタから直接読み取ります。
measuredとtarget、そしてsensitivityを受け取り、スケーリングした偏差をresultに書き込む関数scaled_deviationを定義します。
引数は、順に次のとおりです。
measured: 2つの64ビット浮動小数点数を保持する、16バイト境界に整列したメモリアドレスtarget: 2つの64ビット浮動小数点数を保持するメモリアドレス(整列は保証されていません)sensitivity: 2つの64ビット浮動小数点数を保持するメモリアドレス(整列は保証されていません)result: 関数が2つの64ビット浮動小数点数を書き込むバッファー用のメモリアドレス(整列は保証されていません)スケーリングした偏差は、レーンごとに(measured - target) * sensitivityです。
この関数は戻り値を返しません。その代わり、結果は渡されたバッファー(result)に、2つの64ビット浮動小数点数として格納されます。
measured = {10.5, 20.5};
target = {10.0, 20.0};
sensitivity = { 2.0, 4.0};
scaled_deviation(measured, target, sensitivity, result);
// => result == {1.0, 2.0};
部品が最終検査ベイに入る前に、4つの部品からなるバッチの寸法測定値が倍精度にスケールし直され、既知の温度ドリフトが補正されます。
4つの生の測定値は単精度の数値として届きます。
測定値2つを1行として、校正ではreference / (raw - offset)を計算し、その結果に一定の変換係数0.5を掛けます。
基準値とオフセットは、バッチの両方の行で同じであることに注意してください。 変換係数はどの校正でも同じなので、引数として受け取るのではなく、メモリ上にパックされた16バイトの定数として宣言します。
生のバッチraw、reference、offset、そしてresultバッファーを受け取り、校正した値をresultに書き込む関数calibrate_batchを定義します。
引数は、順に次のとおりです。
raw: 4つの32ビット浮動小数点数を保持するメモリアドレス(整列は保証されていません)reference: 2つの64ビット浮動小数点数を保持する、16バイト境界に整列したメモリアドレスoffset: 2つの64ビット浮動小数点数を保持する、16バイト境界に整列したメモリアドレスresult: 関数が4つの64ビット浮動小数点数(合計32バイト)を書き込む、16バイト境界に整列したメモリアドレスこの関数は、バッチの2つの行それぞれについて、次の処理を行います。
offsetを引きます。referenceをその結果で割ります。0.5をレーンごとに掛けます。resultの対応する16バイトに格納します。この関数は戻り値を返しません。その代わり、結果は渡されたバッファー(result)に格納されます。
raw = {10.0, 20.0, 25.0, 50.0};
reference = {100.0, 100.0};
offset = { 5.0, 10.0};
calibrate_batch(raw, reference, offset, result);
// => result == {10.0, 5.0, 2.5, 1.25};
ラインの終わりでは、どの部品にも品質スコアがあり、報告される前に正規化する必要があります。 各スコアは部品ごとのゲインを掛けられ、次に軸ごとの生産スケールで割られます。 ゲインバッファーには、部品ごとに1つのゲインが入っています。
生産スケールは実行全体を通して同じで、2つの64ビット浮動小数点数を保持するメモリアドレスとして渡されます。 スコアはその場で更新されます。生のスコアが入っていたのと同じバッファーに、正規化したスコアが入ります。
scores配列、gains配列、scale、そして個数nを受け取り、スコアをその場で正規化する関数normalize_scoresを定義します。
引数は、順に次のとおりです。
scores: n個の64ビット浮動小数点数を保持する、16バイト境界に整列したメモリアドレス(その場で変更されます)gains: n個の64ビット浮動小数点数を保持する、16バイト境界に整列したメモリアドレスscale: 2つの64ビット浮動小数点数を保持する、16バイト境界に整列したメモリアドレスn: 正規化するスコアの個数で、64ビットの正の偶数(最小値は2)スケールの2つのレーンは、各ペアの2つの要素にそれぞれ適用されます。
1つ目のレーンは各ペアの1つ目の要素(偶数位置のレーン)をスケーリングし、2つ目のレーンは2つ目の要素(奇数位置のレーン)をスケーリングします。
各スコアは対応するゲインを掛けられ、次に適切なスケールのレーンで割られます。つまりscore * gain / scale_laneです。
この関数は戻り値を返しません。その代わり、結果は入力バッファー(scores)の、正規化された対応するscoreと同じ位置に格納されます。
n = 4
scores = {10.0, 20.0, 30.0, 40.0};
gains = { 2.0, 1.5, 1.0, 0.5};
scale = { 2.0, 4.0};
normalize_scores(scores, gains, scale, n);
// => scores == {10.0, 7.5, 15.0, 5.0};
Exercismに登録すれば、22個のコンセプト130個の演習、そして本物の人間によるメンタリングとともに、x86-64 Assemblyを学んでマスターできます。すべて無料です。