F#でアセンブリプログラミング(基礎知識編)
データ並列
処理の高速化には並列計算が欠かせません。非同期などタスク並列はよく使われていますが、それとは別にデータ並列があります。例えば画像処理なら1pixelずつ全てのpixelを処理する場合など、異なるデータに対して同じ処理を繰り返すシーンはよくありますが、このような場合データ並列が有効です。
データ並列を扱う方法として多くのプロセッサでSIMDがサポートされており、IntelプロセッサでもストリーミングSIMD拡張命令が用意されています。
IntelのストリーミングSIMD拡張命令
Intelは毎回のように命令追加を行っていますが、特筆すべき点をいくつか説明します。
AVX
3引数
8086からずっとIntelの命令セットはSRC
とDST
の2引数でした。つまり、AND DST, SRC
はC言語で言うDST &= SRC
となっており、引数の一方は結果を格納するために上書きされていました。元の値を残す必要がある場合は事前に複製しておく必要があります。
AVXにて新規にVEXエンコーディングが登場し3引数となりました。つまりVPAND DST, SRC1, SRC2
となり、C言語で言うDST = SRC1 & SRC2
が可能になりました。F#言語のように不変な値を基本とする場合、AVEエンコーディングがほぼ必須と言えるでしょう。
256bit
AVXで256bitに拡張されました。これにより
- 64bit整数/倍精度浮動小数点数 × 4
- 32bit整数/単精度浮動小数点数 × 8
- 16bit整数 × 16
- 8bit整数 × 32
が並列計算できます。ただし、内部設計として128bitが基本単位となっているため、128bit境界を跨って操作できる命令は一部に限られています。また128bit境界を跨ぐ命令を使用するとパフォーマンスが低下します[1]。アルゴリズムを工夫し、128bit境界を跨ぐ操作を極力避けることがコツになります。
レジスタ数倍増
AVEエンコーディングにより、レジスタ数も倍増しました。これまでレジスタに収まりきらずメモリに退避していた処理もレジスタ内でやりくりできるようになりました。
ただし、追加されたレジスタはABIにより不揮発となっています。つまり、各関数は追加レジスタの値が保持されている前提で使います。逆に言うと関数内で追加レジスタを使用する場合、その値をメモリに退避してから使用し、リターン前にレジスタ値を復元する必要があります。
短い関数では退避コストがかかるためあまり有用ではありません。F#言語ではinline
を使い関数を積極的にインライン展開させることで、1つの大きな関数にまとめ、レジスタ退避回数を削減することが有効です。
AVX2
GATHER
従来はメモリ上のデータを扱う際、整列されたデータしか操作できませんでした。そのため、整列されていないデータは非SIMD命令を使って1つずつ処理する必要があり、並列化を阻害していました。
AVX2で新規に登場したVPGATHER
がこれを改善します。1命令で複数個所のメモリをかき集めてくることが可能になりました。ただし、AVX2初期のプロセッサは非常にパフォーマンスが悪く、非SIMD命令を使って1つずつ処理する方が早く使い物になりませんでした。その後、パフォーマンスが改善されました…が、脆弱性(CVE-2022-40982;Downfall)が見つかり、再び安全な動作をするようパッチがあてられています。結局、VPGATHER
は極端に遅いプロセッサがあるため、避けざるを得ない状況です。
AVX-512
512bit
AVX-512では新たにEVEXエンコーディングが登場しました。これにはいくつも特徴があります。わかりやすい点はレジスタ幅が512bitに拡張されています。ただし、引き続き128bitが基本単位です。またプロセッサにもよりますが、AVX2までのパフォーマンスが出ない処理もあります[2]。つまり、AVX-512を使い512bitを一度に処理するより、AVX2を使い256bitを2回に分けて処理した方が早くなることがあります。
レジスタ数倍増
AVXで倍増しましたが、AVX-512で更に倍増しました。しかも追加されたレジスタはABIにより揮発となっています。つまり、各関数は値が保持されていることを保証する必要がなく、使いたければ自由に使えます。逆に、外部の関数呼び出しを行う際は値が破壊され得ることを考慮し、必要であればメモリに退避することになります。データ並列を使った処理の最中に外部呼出しすることはあまりないため、特に問題にならないと思われます。
.NETではどのレジスタを使うかは実行時にJITコンパイラが自動的に判断するため、AVX-512に対応した.NET 8を使うことで既存のコードでもレジスタ数増加の恩恵に与れます。F#言語でも.NET 8に移行するのがよいでしょう。
マスクレジスタ
レジスタ幅が広くなると今度は処理したい箇所と処理したくない箇所が混在するようになり、並列化を阻害する可能性があります。AVX-512で登場したマスクレジスタはこの問題に対処します。各命令においてその命令を実行するか、元の値を残すか、ゼロクリアするか、などが選べるようになります。
残念ながら.NET 8の段階では対応していません。引き続きアルゴリズムで工夫する必要があります。
命令の拡充
AVX2までは使わないだろうと判断されたのか搭載されていなかった命令に出くわすことが多かったのですが、AVX-512で需要に合わせて、ところどころ命令が追加され、使えるようになっています。
-
MOVQ
命令で64bitレジスタ値をSIMDレジスタにコピーでき、MOVD
命令で32bitレジスタ値をSIMDレジスタにコピーできましたが、MOVW
命令はなかったので、16bit値をコピーするためには上位ビットをゼロクリアしてからMOVD
/MOVQ
を使う必要がありました。AVX-512でMOVW
命令が追加されたため使えるようになりました。ただし、MOVB
命令は追加されませんでした。 - データ型変換(拡大・縮小)も充実しました。
- bit rotate命令が追加されました。
-
GATHER
命令と対になるSCATTER
命令が追加されました。一度で複数アドレスのメモリに書き出します。 -
COMPRESS
/EXPAND
命令はSIMDレジスタ内の指定された位置の値を寄せ集めたり、分散配置します。 - 3項演算子命令は3項に対して
AND
、OR
、XOR
、NOT
を自由に組み合わせて一気に演算させることができます。 -
VPMULTISHIFTQB
命令は64bit値の中から特定bit複数同時に取り出します。とても期待できる命令なのですが、.NET 8への搭載が間に合わず見送られてしましました。残念。 -
VDBPSADBW
命令はもはや何をするのかよくわからない命令です。.NET 8でもAvx512BW.SumAbsoluteDifferencesInBlock32
メソッドとして用意されています。.NETではメソッド名で内容を説明するのを放棄してInBlock32
と名前を付けています。
問題はこれらの命令が一度に追加されたのではなく、順々に追加されたため、どのプロセッサでどの命令が使えるか、てんでばらばらなことです。この表を見て察してください。
Wikipedia AVX-512より
.NET 8ではAvx512F
、Avx512F.VL
、Avx512F.X64
、Avx512BW
、Avx512BW.VL
、Avx512DQ
、Avx512DQ.VL
、Avx512CD
、Avx512CD.VL
、Avx512Vbmi
、Avx512Vbmi.VL
と実装クラスを分割し、それぞれにIsSupported
プロパティで判別する設計となっています。
クライアントプロセッサ
AVX-512は当初サーバー向けプロセッサにしか搭載されていませんでした。クライアント向けには第9世代辺りから搭載されました。ところが第12世代ではEコアが対応できていないためPコア側も無効化されAVX-512未搭載という扱いになっています。そのため、使える環境は割と限られています。前掲の表を見て察してください。(AMD?知らない子ですね。)
AVX10
AVX-512でのごたごたを整理するためか、AVX10として仕切り直すことが発表されています。まだ搭載プロセッサが発売されていませんが、.NET 9で対応予定とされています。
準備編へ続く。
Discussion