SSE/AVX について

SSE/AVX
「SIMD (Single Instruction/Multiple Data)」は、一つの命令で、複数のデータを同時に処理する方法です。

MMX 命令でも、64bit レジスタを使って複数の整数値を処理することができましたが、SSE や AVX 命令を使うと、128bit、256bit のレジスタを使うことで、さらに多くのデータを同時に処理することができます。

SSE、SSE2、SSE3、SSSE3、SSE4.1、SSE4.2 は、128bit レジスタを使います。
これらは総称して「レガシー SSE」と呼ばれ、x86 でも使用できます。

AVX、AVX2、FMA などは、128/256bit レジスタを使います。
x64 で追加されたサブセット命令のため、「拡張 SSE」と呼ばれます。

※SSE4A、XOP、FMA4 は、AMD が独自に追加したもので、Intel プロセッサでは使用できません。
CPUID
x64 に明確に含まれているのは、SSE と SSE2 のみです。
それ以外の命令のサポートはプロセッサによって異なるので、SSE3 以降の命令を使いたい場合は、CPUID 命令でフラグをチェックする必要があります。

SSEEAX = 0000_0001h > EDX (bit 25)
SSE2EAX = 0000_0001h > EDX (bit 26)
SSE3EAX = 0000_0001h > ECX (bit 0)
SSSE3EAX = 0000_0001h > ECX (bit 9)
SSE4.1EAX = 0000_0001h > ECX (bit 19)
SSE4.2EAX = 0000_0001h > ECX (bit 20)
AVXEAX = 0000_0001h > ECX (bit 28)
AVX2EAX = 0000_0007h, ECX = 0 > EBX (bit 5)
FMAEAX = 0000_0001h > ECX (bit 12)
レジスタと命令
レガシー SSE では、128bit の XMM0〜XMM15 レジスタが使用できます。

拡張 SSE では、XMM0〜XMM15 に加えて、256bit の YMM0〜YMM15 レジスタが使用できます。
ただし、XMM レジスタは、YMM レジスタの下位 128bit として使用されます。
AVX/AVX2 命令
AVX/AVX2 のほとんどの命令は、レガシー SSE と同じ動作を行います。

レガシー SSE 命令のニーモニックの先頭に V を付けると、同じ動作の AVX/AVX2 命令になります。

例えば、パックされた 8bit 整数の加算命令は、SSE2/AVX/AVX2 で、以下の3通りあります。

PADDB xmm1, xmm2/mem128        | SSE2
VPADDB xmm1, xmm2, xmm3/mem128 | AVX (128bit)
VPADDB ymm1, ymm2, ymm3/mem256 | AVX2 (256bit)
オペランドの数
レガシー SSE では、ソースと宛先は基本的に2つのオペランドで指定するため、宛先オペランドと1つ目のソースオペランドは同じになります。

一方、拡張 SSE では、宛先と1つ目のソースを別々に指定することができるため、多くの場合で、オペランドを3つ指定することになります。

上記の PADDB を例にすると、SSE2 命令では、operand1 + operand2 = operand1 という形になりますが、
AVX/AVX2 命令では、operand2 + operand3 = operand1 という形になります。

これにより、結果を別のレジスタに格納することができます。
YMM レジスタの扱い
XMM レジスタは、YMM レジスタの下位 128bit として使用されます。

そのため、XMM レジスタが宛先のオペランドとして指定されている場合、それに対応する YMM レジスタの下位 128bit に値が格納された時に、YMM レジスタの上位 128bit がどうなるかを認識しておく必要があります。

PADDB の例にあるように、SSE/AVX 命令は、基本的に以下の3通りのオペランド形式がある場合が多いです。

  • レガシー SSE 命令で、XMM レジスタを指定する。
    この場合、対応する YMM レジスタの上位 128bit は変更されません。
  • 拡張 SSE 命令で、XMM レジスタを指定する。
    この場合、対応する YMM レジスタの上位 128bit は、0 にクリアされます。
  • 拡張 SSE 命令で、YMM レジスタを指定する。
    この場合、YMM レジスタの全体が使用されます。
rFLAGS
ほとんどの SSE/AVX 命令は、rFLAGS レジスタのフラグを変更しません。
ただし、一部の比較命令などでは、比較の結果として、rFLAGS のビットを変更します。
エンコーディング
レガシー SSE 命令の場合は、汎用命令と同じようなエンコーディングが使用されますが、拡張 SSE 命令では、VEX プレフィックス (全体で 2〜3 byte) を使って追加情報を指定します。

なお、REX プレフィックスで指定するのと同じ情報が、ここには含まれているので、REX プレフィックスは使用されません。

詳細は、前回紹介した エンコーディング の方で確認してください。

* VEX 3byte 形式

|- byte0 -|--- byte1 --------------|--- byte2 ----------|
|         | 7 | 6 | 5 | 4        0 | 7 | 6  3 | 2 | 1 0 |
| C4      | R | X | B | map_select | W | vvvv | L |  pp |

* VEX 2byte 形式

|- byte0 -|-- byte1 -----------|
|         | 7 | 6  3 | 2 | 1 0 |
| C5      | R | vvvv | L |  pp |

X = 1, B = 1, W = 0, map_select = 00001b

* vvvv の値

0000: XMM15/YMM15 | 1000: XMM7/YMM7
0001: XMM14/YMM14 | 1001: XMM6/YMM6
0010: XMM13/YMM13 | 1010: XMM5/YMM5
0011: XMM12/YMM12 | 1011: XMM4/YMM4
0100: XMM11/YMM11 | 1100: XMM3/YMM3
0101: XMM10/YMM10 | 1101: XMM2/YMM2
0110: XMM9/YMM9   | 1110: XMM1/YMM1
0111: XMM8/YMM8   | 1111: XMM0/YMM0
例えば、PADDB と VPADDB 命令の場合、以下のようなエンコーディングが指定されています。

PADDB xmm1, xmm2/mem128        | 66 0F FC /r | SSE2
VPADDB xmm1, xmm2, xmm3/mem128 | C4 RXB.01 X.src1.0.01 FC /r | AVX
VPADDB ymm1, ymm2, ymm3/mem256 | C4 RXB.01 X.src1.1.01 FC /r | AVX2

最後の「FC /r」(オペコードと ModRM バイト) は、3つの命令で同じです。

VPADDB 命令の場合は、先頭のバイトが C4 なので、VEX プレフィックスが付いていることになります。

空白で区切った、次の2つのフィールドは、それぞれ、VEX プレフィックスの2番目と3番目のバイトの値を示しています。
各バイトは、'.' (カンマ) で値のフィールドが区切られています。

RXB は、VEX の R, X, B の各ビットが、1 かどうかを示します (記述されていれば 1)。
次の 01 は、map_select の値 (ビット値) です。

3番目のバイトも、同じように [ W, vvvv, L, pp ] の4つの値を示しています。
W のフラグは、命令によって、使用される場合と使用されない場合があるので、使用されない (どちらでもよい) 場合は、X が指定されます。よって、通常は 0 になります。

vvvv は、3つ目または4つ目のオペランド (XMM or YMM) を指定する値です。
ここでは src1 になっているので、第1ソースのオペランドを指定することになります (VPADDB 命令では、2つ目のオペランド)。
実際のエンコーディング
エンコーディングは、VEX プレフィックスの 3 byte 形式を使った場合の値が示されています。
しかし、特定のフィールドが、ある値の組み合わせになっている場合は、コンパクトな 2 byte の形式でエンコードされる場合があります。

例えば、アセンブラで以下のように記述した場合、3 byte 形式の各バイト値は、「C4 | 111_00001b | 0_1110_0_01b」となります。

vpaddb xmm0, xmm1, xmm2

[byte2] R,X,B = 1, map_select = 01b
[byte3] W = 0, vvvv = 1110b (xmm1), L = 0 (128bit), pp = 01 (66h prefix)

この場合、2 byte 形式の条件である、X = 1, B = 1, W = 0, map_select = 00001b の値に一致しているため、2 byte 形式に圧縮することができます。

結果、「C5 | 1_1110_0_01b」となり、VEX プレフィックスは「C5 F1」の 2 byte になります。
補足
VPBLENDVB xmm1, xmm2, xmm3/mem128, xmm4 | C4 RXB.03 0.src1.0.01 4C /r is4

上記のように、エンコーディングの表記に「is4」という指定がある場合は、8bit 即値で、4つ目のオペランドのレジスタを指定します。

1つ目のオペランドは ModRM.reg で指定され、2つ目のオペランドは VEX.vvvv で、3つ目のオペランドは ModRM.r/m で指定されますが、この場合、4つ目のレジスタオペランドを指定する方法がないため、最後に即値バイトを追加して、レジスタを指定することになります。
数値
SSE/AVX 命令での浮動小数点数の演算では、単精度 (32bit) と倍精度 (64bit) がサポートされています。
半精度 (16bit) は、CPUID で F16C がサポートされている場合のみ、単精度との相互変換が行えます。

パック整数は、BYTE, WORD, DWORD, QWORD (64bit), Double QWORD (128bit) がサポートされています。
DQ (double quadword) は、演算は行えませんが、ビットシフトなどのみ行えます。
不定値
浮動小数点データ型と整数データ型にはそれぞれ、不定値を示す値があります。

例えば、浮動小数点数を整数に変換する場合、ソース値が NaN (非数値) や無限大の場合や、変換後の結果が、指定された整数値の範囲で表現できない場合、結果として、整数の不定値が返されます (MXCSR の例外マスク IM が 1 の場合)。

データ型による各不定値は、以下の値になります。

単精度FFC0_0000h
倍精度FFF8_0000_0000_0000h
16bit 整数8000h
32bit 整数8000_0000h
64bit 整数8000_0000_0000_0000h

整数の場合は、符号付きとして見た場合、負の最大値となります。数値としては正しい値となるので、注意してください。
MXCSR レジスタ
SSE/AVX 命令に関する、例外フラグや例外マスク、丸めの設定などは、
Media eXtension Control and Status Register (MXCSR)」によって設定されます。

|31 -  18|17|16|15|14,13|12|11|10| 9| 8| 7|  6| 5| 4| 3| 2| 1| 0| bit
|reversed|MM|--|FZ| RC  |PM|UM|OM|ZM|DM|IM|DAZ|PE|UE|OE|ZE|DE|IE|

リセット時は、例外マスク (bit 12:7) がすべて 1 となり、それ以外は 0 になります。

STMXCSR 命令で、メモリに MXCSR の値を保存でき、LDMXCSR 命令で、メモリから MXCSR に値をロードできます。
MXCSR の値を変更したい場合は、メモリに 32bit 値を保存してから、それをロードする形になります。
詳細
  • 無効な操作例外 (IE)
    無効な操作の例外が発生すると、1 になります。
  • 非正規化オペランド例外 (DE)
    命令のソースオペランドの 1 つの値が、非正規化形式である場合、1 になります。
    ただし、DAZ ビットが 1 の場合、DE ビットを設定しません。
  • ゼロ除算例外 (ZE)
    ゼロ以外の数値を、ゼロで除算するときに、1 になります。
  • オーバーフロー例外 (OE)
    丸められた結果の絶対値が、宛先フォーマットで表現可能な、最大の正規化浮動小数点数より大きい場合、1 になります。
  • アンダーフロー例外 (UE)
    丸められた非ゼロの結果の絶対値が小さすぎて、宛先フォーマットの正規化浮動小数点数として表現できない場合、1 になります。
    UM ビットが 1 の場合、UE が精度例外 (PE) とともに発生した場合にのみ、UE 例外を報告します。
  • 精度例外 (PE)
    丸め後の浮動小数点の結果が、無限に正確な結果と異なるため、指定された宛先フォーマットで正確に表現できない場合、1 になります。
  • 非正規化はゼロ (DAZ)
    ハードウェア実装がこのモードをサポートしている場合、ソフトウェアはこのビットを 1 に設定して、DAZ モードを有効にすることができます。
    DAZ モードでは、非正規化のソースオペランドの場合、演算前に、ソース値を符号付き 0 (元のソースと同じ符号) に変換します。
    DAZ モードは、ANSI/IEEE Std 754 に準拠していません。
  • 例外マスク (PM、UM、OM、ZM、DM、IM)
    対応する6つの SIMD 浮動小数点例外 (PE、UE、OE、ZE、DE、IE) を、マスク、またはマスク解除できます。
    ビットを 1 に設定するとマスクし、0 にクリアするとマスクを解除します (初期値は 1)。
    マスクを解除すると、例外が発生したときに、SIMD 浮動小数点例外サービスルーチンに分岐します。
  • 浮動小数点丸め制御 (RC) - bit 13,14
    浮動小数点演算の丸め方法を指定します。

    00 = 近い方向に丸め [default]
    01 = 負の無限大方向に丸め
    10 = 正の無限大方向に丸め
    11 = 0 方向に丸め
  • フラッシュゼロ (FZ)
    丸められた結果が小さく、UM マスクが設定されている場合、このビットにより、結果がゼロにフラッシュされます。
    その結果、値は不正確になり、PE と UE の両方が設定されることになります。
  • 不整列例外マスク (MM) [AMD]
    AMD プロセッサのみで使用され、1 の場合、アライメントされていないメモリをサポートします。
    CPUID: EAX = 8000_0001h > ECX(bit7) で、この機能がサポートされているかを確認できます。
データ型の違い
例えば、MOVAPS 命令は、パックされた単精度浮動小数点として、128bit 値 (32bit x 4) をコピーし、
MOVAPD 命令は、パックされた倍精度浮動小数点として、128bit 値 (64bit x 2) をコピーします。

MOVAPS xmm1, xmm2/mem128 | 0F 28 /r    | パック単精度
MOVAPD xmm1, xmm2/mem128 | 66 0F 28 /r | パック倍精度

この2つの命令は、全体で 128bit 値をコピーするという意味では同じなので、レジスタに格納される値は、どちらも変わりません。

また、値をコピーする時に、命令に一致するデータ型かどうかをチェックするようなことはないため、仮に不正確な値だったとしても、例外が発生するようなことはありません。

ただし、プロセッサ内部の実装では、各命令のデータ型を明確に記憶し、現在レジスタに格納されているデータ型と、命令で実行されるデータ型が一致しているかどうかを判断する場合があります。

この場合、例えば、パック単精度として格納したデータを使って、パック倍精度の演算を実行しようとした場合、型の不一致により、ペナルティとして遅延が起こる場合があります。

そのため、たとえ命令の実行結果が同じになるとしても、常に使用するデータ型に合わせた命令を使う必要があります。
丸め
浮動小数点数の丸め方法は4通りあり、MXCSR レジスタの RC で、丸め方法を指定することができます。

0近い方向に丸め (デフォルト)
1負の無限大に近い方向に丸め
2正の無限大に近い方向に丸め
3ゼロに近い方向に丸め

実際にどのような値に丸められるかを、SSE4.1 の ROUNDPS 命令を使って確認してみます。

> SSE/AVX 変換

ROUNDPS xmm1, xmm2/mem128, imm8  | 66 0F 3A 08 /r ib | SSE4.1

パックされた単精度浮動小数点数を、指定方法で整数に丸めて、パック単精度として格納します。
即値の下位 2bit で、丸めモードを指定できます。
サンプルコード
<28_round.c>
#include <stdio.h>

void testfunc(void);

int main(void)
{
    testfunc();

    return 0;
}

void putval(float *p)
{
    int i;

    for(i = 0; i < 4; i++)
        printf("%.1f, ", p[i]);

    printf("\n");
}

<28_round.asm>
default rel
global testfunc
extern putval

section .text

testfunc:
    push rbp
    mov rbp, rsp
    sub rsp, 24 ; 結果の値の格納
    ; push rbx 後の位置を 16byte 境界にする
    push rbx

    lea rbx, [rbp - 16] ; 値の格納先のアドレス

    roundps xmm0, [data], 0 ; 近い方に丸め
    movaps [rbx], xmm0      ; スタック領域に格納
    mov rdi, rbx            ; arg1 (ポインタ)
    call putval

    roundps xmm0, [data], 1 ; 負の無限大方向
    movaps [rbx], xmm0
    mov rdi, rbx
    call putval

    roundps xmm0, [data], 2 ; 正の無限大方向
    movaps [rbx], xmm0
    mov rdi, rbx
    call putval

    roundps xmm0, [data], 3 ; 0 に近い方に丸め
    movaps [rbx], xmm0
    mov rdi, rbx
    call putval

    pop rbx
    mov rsp, rbp
    pop rbp
    ret

    align 16
data:
    dd 2.2, 2.8, -2.2, -2.8

$ nasm -f elf64 28_round.asm
$ cc -o test 28_round.c 28_round.o

$ ./test
2.0, 3.0, -2.0, -3.0, 
2.0, 2.0, -3.0, -3.0, 
3.0, 3.0, -2.0, -2.0, 
2.0, 2.0, -2.0, -2.0,
解説
CALL 命令で C の関数を呼び出す場合、現在の RSP が 16 byte 境界である必要があります。

PUSH RBP の後、ローカル変数領域を確保して、さらに PUSH RBX を行うため、それぞれのアライメントを合わせる必要があります。

-------- (-48) <--
 RBX
-------- (-40)
 余白 [8byte]
 結果を書き込む位置 [16byte] <--
-------- (-16)
 RBP
-------- (-8)
 RIP
-------- testfunc が呼び出される前

<-- の位置は、16 byte 境界であること

PUSH RBX の後 (CALL 命令を実行する時の RSP) が 16 byte 境界になるようにするためには、8 byte の余白が必要になります。
そのため、ローカル変数を確保する時に、余分に 8 byte を追加して、減算しています。

RBX には、結果の値を格納するアドレス (スタック内位置) を入れておきます。
RBX レジスタの値は、呼び出し元に属しているので、CALL 命令で関数が呼び出された後でも、元の値が常に維持されています。
※そのため、RBX の値は、関数が戻る時に、呼び出された直後の状態に戻す必要があります。

第1引数 (RDI) に、結果の値が格納されているポインタを入れて、C で定義されている putval 関数を呼び出します。
RDI は、呼び出した関数内で値が変更される可能性があるため、使いまわすことはできず、常に CALL 命令を実行する前に、値を設定する必要があります。

putval 関数内では、パックされた4つの単精度値を printf で表示しています。
結果
//2.2, 2.8, -2.2, -2.8
2.0, 3.0, -2.0, -3.0, //近い方向
2.0, 2.0, -3.0, -3.0, //負の無限大に近い方向
3.0, 3.0, -2.0, -2.0, //正の無限大に近い方向
2.0, 2.0, -2.0, -2.0, //ゼロに近い方向

デフォルトの RC = 0 は、要するに四捨五入です。
RC = 1 は、値が小さい方の整数に切り捨て、RC = 2 は、値が大きい方の整数に切り上げです。
RC = 3 は、正なら値が小さい方、負なら値が大きい方に丸められます。