🦉

F#でアセンブリプログラミング(基礎知識編)

2023/12/24に公開

データ並列

処理の高速化には並列計算が欠かせません。非同期などタスク並列はよく使われていますが、それとは別にデータ並列があります。例えば画像処理なら1pixelずつ全てのpixelを処理する場合など、異なるデータに対して同じ処理を繰り返すシーンはよくありますが、このような場合データ並列が有効です。
データ並列を扱う方法として多くのプロセッサでSIMDがサポートされており、IntelプロセッサでもストリーミングSIMD拡張命令が用意されています。

IntelのストリーミングSIMD拡張命令

Intelは毎回のように命令追加を行っていますが、特筆すべき点をいくつか説明します。

AVX

3引数

8086からずっとIntelの命令セットはSRCDSTの2引数でした。つまり、AND DST, SRCはC言語で言うDST &= SRCとなっており、引数の一方は結果を格納するために上書きされていました。元の値を残す必要がある場合は事前に複製しておく必要があります。

AVXにて新規にAVEエンコーディングが登場し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命令が追加されました。一度で複数アドレスのメモリに書き出します。
  • COMPRESSEXPAND命令はSIMDレジスタ内の指定された位置の値を寄せ集めたり、分散配置します。
  • 3項演算子命令は3項に対してANDORXORNOTを自由に組み合わせて一気に演算させることができます。
  • VPMULTISHIFTQB命令は64bit値の中から特定bit複数同時に取り出します。とても期待できる命令なのですが、.NET 8への搭載が間に合わず見送られてしましました。残念。
  • VDBPSADBW命令はもはや何をするのかよくわからない命令です。.NET 8でもAvx512BW.SumAbsoluteDifferencesInBlock32メソッドとして用意されています。.NETではメソッド名で内容を説明するのを放棄してInBlock32と名前を付けています。

問題はこれらの命令が一度に追加されたのではなく、順々に追加されたため、どのプロセッサでどの命令が使えるか、てんでばらばらなことです。この表を見て察してください。
Wikipedia AVX-512より

.NET 8ではAvx512FAvx512F.VLAvx512F.X64Avx512BWAvx512BW.VLAvx512DQAvx512DQ.VLAvx512CDAvx512CD.VLAvx512VbmiAvx512Vbmi.VLと実装クラスを分割し、それぞれにIsSupportedプロパティで判別する設計となっています。

クライアントプロセッサ

AVX-512は当初サーバー向けプロセッサにしか搭載されていませんでした。クライアント向けには第9世代辺りから搭載されました。ところが第12世代ではEコアが対応できていないためPコア側も無効化されAVX-512未搭載という扱いになっています。そのため、使える環境は割と限られています。前掲の表を見て察してください。(AMD?知らない子ですね。)

AVX10

AVX-512でのごたごたを整理するためか、AVX10として仕切り直すことが発表されています。まだ搭載プロセッサが発売されていませんが、.NET 9で対応予定とされています。


準備編へ続く。

脚注
  1. 128bit境界を跨ぐ命令をサポートする実行ユニット数が少ないため、先行する命令の完了を待つ必要があり、スループットが低下します。 ↩︎

  2. 内部的に256bit命令×2に分解しているのか256bit命令と比べてレイテンシーが増加します。更に512bitをサポートする実行ユニットも少ないため、先行する命令の完了を待つ必要がありスループットも低下します。 ↩︎

Discussion