インテルコンパイラで対応していない命令セットの組み込み関数を使った場合のアセンブリ

はじめに

結果は正しいけど、なぜか実行速度が極めて遅いコードがあって、アセンブリ見たら明らかに変な処理をしていたんだけれど、どうやらコンパイルオプションが悪かったせいだということがわかった。それはそれとして、コンパイラがなぜその「変な処理」を吐いたか、その気持ちを理解してみたい、という話。

現象

こんなコードを書く。

#include <immintrin.h>
extern __attribute__((aligned(64))) double z[1000000];
typedef double v8df __attribute__((vector_size(64)));
typedef double v4df __attribute__((vector_size(32)));

void
func(int i, v4df &zl, v4df &zh){
  v8df zi = _mm512_load_pd((double*)(z + i));
  zl = _mm512_extractf64x4_pd(zi,0);
  zh = _mm512_extractf64x4_pd(zi,1);
}

要するに配列から512bitをごそっと持ってきて、その上位256bitと下位256bitをそれぞれ256bitレジスタに対応する変数に格納して返す、という処理。

これは、素直にアセンブリにするならこうなるだろう。

        movslq    %edi, %rdi
        vmovups   z(,%rdi,8), %zmm16
        vextractf64x4 $0, %zmm16, %ymm0
        vmovupd   %ymm0, (%rsi)       
        vextractf64x4 $1, %zmm16, %ymm1
        vmovupd   %ymm1, (%rdx)
        ret    

もしくは、zmmの下位がymmであることを使ってこうしても良い。

        movslq  %edi, %rdi
        vmovupd z(,%rdi,8), %zmm0
        vmovapd %ymm0, (%rsi)
        vextractf64x4   $0x1, %zmm0, (%rdx)
        ret

しかし、Xeon(Haswell)上で、icpc -O3 -xHOST -S test.cpp としてコンパイルするとこんなコードを吐く。

        movslq    %edi, %rdi
        vmovups   z(,%rdi,8), %zmm0
        vmovups   %zmm0, (%rsp)
        vmovupd   (%rsp), %ymm3
        vmovupd   32(%rsp), %ymm4
        vmovupd   %ymm3, 64(%rsp)
        vmovupd   %ymm4, 96(%rsp)
        vmovupd   %ymm3, 128(%rsp)
        vmovupd   %ymm4, 160(%rsp)
        vmovups   64(%rsp), %zmm1
        vmovups   128(%rsp), %zmm5
        vextractf64x4 $0, %zmm1, %ymm2
        vextractf64x4 $1, %zmm5, %ymm6
        vmovupd   %ymm2, (%rsi)
        vmovupd   %ymm6, (%rdx)
        vzeroupper
        movq      %rbp, %rsp
        popq      %rbp
        ret

どうやら

  1. まず配列の中身をzmmに落とす
  2. zmmの中身をスタックに書き戻す
  3. zmmの上位256bitと下位256bitに対応するデータをymm3,4に読み込む
  4. ymmを使ってzmmの中身をスタックに個別にコピー
  5. メモリからzmm1とzmm5にデータを読み込む
  6. zmmの上位、下位256bitをymmにコピー
  7. 結果をメモリに書き戻す

ということをやっているらしい。

原因

これは、コンパイルオプションが悪いのが原因。vextractf64x4はAVX-512なのに、その命令セットを実装していないHaswell上で-xHOSTをつけてコンパイルしたためにおかしくなった。ちゃんと、-xMIC-AVX51をつけてコンパイルすれば所望のアセンブリを吐く。

なぜこうなったか?

ここまでが事実で、これからは「なぜこういうコードを吐いたか」かの推測(憶測)である。

コンパイルしたいのはこんなコードだった。

  v8df zi = _mm512_load_pd((double*)(z + i));
  zl = _mm512_extractf64x4_pd(zi,0);
  zh = _mm512_extractf64x4_pd(zi,1);

コンパイラは、まず組み込み関数を機械的にアセンブリに変換してしまう。この時、レジスタ番号は仮のものを振る。

        vmovups   z(,%rdi,8), %zmmA
;        (zmmB=zmmA)
;        (zmmC=zmmA)
        vextractf64x4 $0, %zmmB, %ymmD
        vextractf64x4 $1, %zmmC, %ymmE

この時、プログラム的にはzmmBとzmmCはzmmAと同じ内容を指すことがわかっているから、そこをなんとかしないといけない。

最適化無しで、かつAVX-512に対応している場合(-O0 -xMIC-AVX512)、zmmBとzmmCへのコピーをメモリ経由でやる。

        vmovups   %zmmA, -440(%rbp)   
        vmovups   -440(%rbp), %zmmB 
        vextractf64x4 $0, %zmmB, %ymmD
        vmovups   %zmmA, -344(%rbp)   
        vmovups   -344(%rbp), %zmmC 
        vextractf64x4 $1, %zmmC, %ymmE        

本当はもっとごちゃごちゃやってるけど、まぁエッセンスはこんなことをする。

最適化レベルを上げる(-O3 -xMIC-AVX512)と、値のコピーをレジスタのコピーでやろうとする。

        vmovups   z(,%rdi,8), %zmmA
        vmovaps   zmmA, zmmB
        vmovaps   zmmA, zmmC
        vextractf64x4 $0, %zmmB, %ymmD
        vextractf64x4 $1, %zmmC, %ymmE

その後の最適化プロセスで、A,B,Cに同じレジスタ番号が振られる。

        vmovups   z(,%rdi,8), %zmm16
        vmovaps   zmm16, zmm16
        vmovaps   zmm16, zmm16
        vextractf64x4 $0, %zmm16, %ymm0
        vextractf64x4 $1, %zmm16, %ymm1

その後、無駄なvmovapsが消えて完成。

        vmovups   z(,%rdi,8), %zmm16
        vextractf64x4 $0, %zmm16, %ymm0
        vextractf64x4 $1, %zmm16, %ymm1

さて問題は、レジスタzmmを持っていない命令セットを指定した場合である。Haswellマシンで-xHOST -O3を指定した場合、-xCORE-AVX2 と解釈されているものと思われる。

まず、組み込み関数を機械的に置き換えるところまでは同じ。

        vmovups   z(,%rdi,8), %zmmA
;        (zmmB=zmmA)
;        (zmmC=zmmA)
        vextractf64x4 $0, %zmmB, %ymmD
        vextractf64x4 $1, %zmmC, %ymmE

さて、コンパイラは、zmmがどういうものかは知っているが、Haswellマシンで-xHOSTが指定されたため、自分が使って良いレジスタはymmまでだと思っている。この条件でzmmAの中身をzmmBやzmmCにコピーしないといけない。

この時、

  1. 既に出力されたzmmは使って良い
  2. しかし新たに使って良いレジスタはymmまで
  3. AVX-512の命令セットも使ってはならない

という条件がある。この条件でzmmBとzmmCにzmmAの中身をコピーするにはメモリ経由でやるしかない。

というわけで、冒頭に述べたようなymmを使ったメモリコピーのコードが吐かれたっぽい。

疑問

吐かれたコードを見ている限り、インテルコンパイラは-xCORE-AVX2を指定した場合でも、組み込み関数で吐かれたzmmをメモリに書き込む、メモリからzmmへ読み込むコードは許しているように見える。

それなら、

        vmovups   z(,%rdi,8), %zmmA
        vmovups   %zmmA, (%rsp)
        vmovups   (%rsp), %zmmB
        vmovups   (%rsp), %zmmC
        vextractf64x4 $0, %zmmB, %ymmD
        vextractf64x4 $1, %zmmC, %ymmE

でいいじゃん、という気がするし、そもそも「新たにzmmを使ってはならない」「zmm間のコピーも許さない」という条件でも、いきなり

        vmovups   z(,%rdi,8), %zmmA
        vextractf64x4 $0, %zmmA, %ymmD
        vextractf64x4 $1, %zmmA, %ymmE

としてくれても良い気もする。他のコードのアセンブリを見ている限り、レジスタの値をスタックに積み、それを別のレジスタに読み込む、という処理があれば最適化で消えるので、組み込み関数を使った場合に最適化の振る舞いがおかしくなるのかなぁ、という気がした。

ちなみに

最適化レベルを最高にした場合、インテルコンパイラはvextractf64x4を二個吐いたが、GCCは片方しか吐かず、zmmの下位256bitがymmであることを使っていた。

また、v8dfではなく、組み込み型__m512dを使って

#include <immintrin.h>
extern __attribute__((aligned(64))) double z[1000000];
typedef double v8df __attribute__((vector_size(64)));
typedef double v4df __attribute__((vector_size(32)));
void
func(int i, v4df &zl, v4df &zh){
  __m512d zi = _mm512_load_pd((double*)(z + i));
  zl = _mm512_extractf64x4_pd(zi,0);
  zh = _mm512_extractf64x4_pd(zi,1);
}

とすると、-xCORE-AVX2を指定しても所望のアセンブリを吐く。

        movslq    %edi, %rdi
        vmovups   z(,%rdi,8), %zmm0
        vextractf64x4 $0, %zmm0, %ymm1
        vextractf64x4 $1, %zmm0, %ymm2
        vmovupd   %ymm1, (%rsi)
        vmovupd   %ymm2, (%rdx)       
        vzeroupper             
        ret     

謎。