命令単体の性能を計測する

目次

概要

その昔に命令単位の性能を計測するhttps://github.com/tanakamura/instruction-benchというのを作ったので、それの紹介と、このinstruction-benchを支える技術みたいなのを紹介していく。

前提知識

asmが読み書きができて、命令のスループットとレイテンシの違いがわかるとよいです。

instruction-bench

http://instlatx64.atw.hu/ instlatx64 という謎(?)のページがあって、 このページを見れば、色々なアーキの命令のスループット/レイテンシが見られるようになっている。

他に、Agner先生が http://www.agner.org/optimize/instruction_tables.pdf に公開してたりとか、そもそもIntelもAMDも公式ドキュメントで出してるので、資料はたくさんあるのだけど、いくつか問題があって、

と、いった問題があって、結局いつも自分でasm書いて手で計測したい命令並べて実測してたのだけど、手で毎回修正するのも無駄だな、と思ったので、命令性能計測ツールを自作した

他の命令性能資料と比べた場合、

といったメリットがある

実際、instlatx64 は、Zen 登場時、256bit FMA のスループットを 2 と出していて、物議を醸したことがあった。どうやって計測した値なのかは不明で、再現方法はなかった。しばらくして、スループットは1に修正された。

あと、もともと exe になってれば Windows でも使いやすいと思って作ってたはずだけど、Windows は CPU_CLK_UNHALTED 取るの難しくてrdtscで計測してて信頼性かなり低いので、Linux で計測したほうがいいです。

あと、今思えば、ベンチマークをbenchと略す文化はあまり見ないので、 instruction-mark とかにしたほうが良かったかもしれない。(そうか?markってそれはそれで意味わかんなくね?)

鑑賞タイム

興味深いな、と思った結果を並べておきます

結果は、https://github.com/tanakamura/instruction-bench の、*.log で見られるので、興味ある人は色々見てください

読み方は、

 オペランドのレジスタサイズ (m128:sse, m256:avx, m512:avx512)
 |
 |     命令     L or T      CPI (cycles per instruction)
 |     |        |           |
 |     |        |           |           IPC (instructions per cycle)
 |     |        |           |           |
 m128: blendvps:   latency: CPI= 10.10, IPC= 0.10
 m128: blendvps:throughput: CPI= 12.06, IPC= 0.08

と、なっている。CPI と IPC は単に逆数を表示してるだけで、違いはないです。レイテンシを見るときは CPI、スループットを見るときは、IPC を見るのが推奨されます。(Intelの最適化マニュアルはひねくれているので、全部CPIで書いてあるので読みづらい)

昔のCPUは環境再現が難しいので、、ベンチマークが更新されてもログを更新しない場合が多いので、命令が少なかったり、ちょっと誤差が残ってたりします。注意。

gather

(bdw)
    m256:                              vpgatherdd:   latency: CPI=   21.15, IPC=    0.05
    m256:                              vpgatherdd:throughput: CPI=    6.07, IPC=    0.16
    m256:             gather32(<ld+ins>x8 + perm):   latency: CPI=   17.03, IPC=    0.06
    m256:             gather32(<ld+ins>x8 + perm):throughput: CPI=    8.02, IPC=    0.12

(skl)
    m256:                              vpgatherdd:   latency: CPI=   22.02, IPC=    0.05
    m256:                              vpgatherdd:throughput: CPI=    5.00, IPC=    0.20
    m256:             gather32(<ld+ins>x8 + perm):   latency: CPI=   18.03, IPC=    0.06
    m256:             gather32(<ld+ins>x8 + perm):throughput: CPI=    7.00, IPC=    0.14

(zen)
    m256:                              vpgatherdd:   latency: CPI=   20.81, IPC=    0.05
    m256:                              vpgatherdd:throughput: CPI=   20.01, IPC=    0.05
    m256:             gather32(<ld+ins>x8 + perm):   latency: CPI=   17.40, IPC=    0.06
    m256:             gather32(<ld+ins>x8 + perm):throughput: CPI=    5.03, IPC=    0.20

(knl)
    m256:                              vpgatherdd:   latency: CPI=   19.13, IPC=    0.05
    m256:                              vpgatherdd:throughput: CPI=    9.09, IPC=    0.11
    m256:             gather32(<ld+ins>x8 + perm):   latency: CPI=   24.16, IPC=    0.04
    m256:             gather32(<ld+ins>x8 + perm):throughput: CPI=    8.06, IPC=    0.12

<ld + ins> x8 + perm は gather と同等の処理をしていて、xmm に 4回pinsrdするのを2レジスタ分やって、それを最後permして ymm にする。

bdw,skl は、レイテンシは手で命令並べたほうが短いけど、スループットはgatherのほうがよい。

knl は、レイテンシはgatherのほうが短いけど、スループットは手で並べたほうがよい

zen の gather はひどい。手で並べると、Skylakeのgatherとスループット一緒、レイテンシはbdwと同じになって、最速。

(この比較は、gatherするインデクスがメモリに入っている場合。インデクスがレジスタに乗ってる場合はldがpextrになるので条件変わる。そっちはまだ計測してない。bdwとかもう手元にないので、多分やらない)

境界をまたぐロード

(skl)
    m128:                            movaps [mem]:   latency: CPI=   10.01, IPC=    0.10
    m128:                            movaps [mem]:throughput: CPI=    0.50, IPC=    1.98
    m128:           movdqu [mem+63] (cross cache):   latency: CPI=   15.01, IPC=    0.07
    m128:           movdqu [mem+63] (cross cache):throughput: CPI=    1.01, IPC=    0.99
    m128:         movdqu [mem+2MB-1] (cross page):   latency: CPI=   17.01, IPC=    0.06
    m128:         movdqu [mem+2MB-1] (cross page):throughput: CPI=    3.90, IPC=    0.26
    m256:                            movaps [mem]:   latency: CPI=    1.00, IPC=    1.00
    m256:                            movaps [mem]:throughput: CPI=    0.50, IPC=    2.00
    m256:                         vmovdqu [mem+1]:   latency: CPI=    1.01, IPC=    0.99
    m256:                         vmovdqu [mem+1]:throughput: CPI=    0.50, IPC=    2.00
    m256:          vmovdqu [mem+63] (cross cache):   latency: CPI=    1.01, IPC=    0.99
    m256:          vmovdqu [mem+63] (cross cache):throughput: CPI=    1.00, IPC=    1.00
    m256:        vmovdqu [mem+2MB-1] (cross page):   latency: CPI=    3.91, IPC=    0.26
    m256:        vmovdqu [mem+2MB-1] (cross page):throughput: CPI=    3.94, IPC=    0.25

(bdw)
    m256:                            movaps [mem]:   latency: CPI=    1.00, IPC=    1.00
    m256:                            movaps [mem]:throughput: CPI=    0.50, IPC=    2.00
    m256:                         vmovdqu [mem+1]:   latency: CPI=    1.00, IPC=    1.00
    m256:                         vmovdqu [mem+1]:throughput: CPI=    0.50, IPC=    2.00
    m256:          vmovdqu [mem+63] (cross cache):   latency: CPI=    1.00, IPC=    1.00
    m256:          vmovdqu [mem+63] (cross cache):throughput: CPI=    1.00, IPC=    1.00
    m256:        vmovdqu [mem+2MB-1] (cross page):   latency: CPI=   31.01, IPC=    0.03
    m256:        vmovdqu [mem+2MB-1] (cross page):throughput: CPI=   31.01, IPC=    0.03

(knl)
    m256:                            movaps [mem]:   latency: CPI=    1.06, IPC=    0.94
    m256:                            movaps [mem]:throughput: CPI=    0.57, IPC=    1.77
    m256:                         vmovdqu [mem+1]:   latency: CPI=    1.07, IPC=    0.94
    m256:                         vmovdqu [mem+1]:throughput: CPI=    0.57, IPC=    1.77
    m256:          vmovdqu [mem+63] (cross cache):   latency: CPI=    1.06, IPC=    0.94
    m256:          vmovdqu [mem+63] (cross cache):throughput: CPI=    1.01, IPC=    0.99
    m256:        vmovdqu [mem+2MB-1] (cross page):   latency: CPI=   14.13, IPC=    0.07
    m256:        vmovdqu [mem+2MB-1] (cross page):throughput: CPI=   14.13, IPC=    0.07

(slm)
    m128:                            movaps [mem]:   latency: CPI=    8.06, IPC=    0.12
    m128:                            movaps [mem]:throughput: CPI=    1.02, IPC=    0.99
    m128:                          movdqu [mem+1]:   latency: CPI=    8.05, IPC=    0.12
    m128:                          movdqu [mem+1]:throughput: CPI=    1.03, IPC=    0.97
    m128:           movdqu [mem+63] (cross cache):   latency: CPI=   14.10, IPC=    0.07
    m128:           movdqu [mem+63] (cross cache):throughput: CPI=    3.02, IPC=    0.33
    m128:         movdqu [mem+2MB-1] (cross page):   latency: CPI=   17.13, IPC=    0.06
    m128:         movdqu [mem+2MB-1] (cross page):throughput: CPI=    9.59, IPC=    0.10

(znver1)
    m128:                            movaps [mem]:   latency: CPI=    9.02, IPC=    0.11
    m128:                            movaps [mem]:throughput: CPI=    0.50, IPC=    1.99
    m128:                          movdqu [mem+1]:   latency: CPI=   10.02, IPC=    0.10
    m128:                          movdqu [mem+1]:throughput: CPI=    0.50, IPC=    2.00
    m128:           movdqu [mem+63] (cross cache):   latency: CPI=   11.02, IPC=    0.09
    m128:           movdqu [mem+63] (cross cache):throughput: CPI=    1.00, IPC=    1.00
    m128:         movdqu [mem+2MB-1] (cross page):   latency: CPI=   11.02, IPC=    0.09
    m128:         movdqu [mem+2MB-1] (cross page):throughput: CPI=    1.00, IPC=    1.00
    m256:                            movaps [mem]:   latency: CPI=    1.00, IPC=    1.00
    m256:                            movaps [mem]:throughput: CPI=    1.00, IPC=    1.00
    m256:                         vmovdqu [mem+1]:   latency: CPI=    1.50, IPC=    0.67
    m256:                         vmovdqu [mem+1]:throughput: CPI=    1.50, IPC=    0.67
    m256:          vmovdqu [mem+63] (cross cache):   latency: CPI=    1.50, IPC=    0.67
    m256:          vmovdqu [mem+63] (cross cache):throughput: CPI=    1.50, IPC=    0.67
    m256:        vmovdqu [mem+2MB-1] (cross page):   latency: CPI=    1.50, IPC=    0.67
    m256:        vmovdqu [mem+2MB-1] (cross page):throughput: CPI=    1.51, IPC=    0.66

2MB アラインしたメモリの先頭から、 +2MBl-1 byteした位置をロードした場合の性能は、Intel が結構悪い感じがある、が、Skylakeでかなり改善している

今計測できた znver1 と skl では、128bit load のほうがレイテンシがでかい。(これはさすがに何かミスってる気がする…)

Zen の謎のpopcnt性能

   reg64:     popcnt:   latency: CPI=    1.00, IPC=    1.00
   reg64:     popcnt:throughput: CPI=    0.24, IPC=    4.23

無駄に4もある。AMDのモジュールは多分コピペなのでところどころこういう、「あんまり使わないけど他とスループット同じ命令」が出てくるんじゃないかな。lea も 4あるし。(Skylake の lea は IPC 2)

store forwarding

(slm)
   reg64:              store [mem+0]->load[mem+0]:   latency: CPI=    4.04, IPC=    0.25
   reg64:              store [mem+0]->load[mem+0]:throughput: CPI=    2.04, IPC=    0.49
   reg64:              store [mem+0]->load[mem+1]:   latency: CPI=   14.09, IPC=    0.07
   reg64:              store [mem+0]->load[mem+1]:throughput: CPI=   11.52, IPC=    0.09

(skl)
   reg64:              store [mem+0]->load[mem+0]:   latency: CPI=    7.75, IPC=    0.13
   reg64:              store [mem+0]->load[mem+0]:throughput: CPI=    1.35, IPC=    0.74
   reg64:              store [mem+0]->load[mem+1]:   latency: CPI=   19.01, IPC=    0.05
   reg64:              store [mem+0]->load[mem+1]:throughput: CPI=   13.00, IPC=    0.08

(znver1)
   reg64:              store [mem+0]->load[mem+0]:   latency: CPI=   38.19, IPC=    0.03
   reg64:              store [mem+0]->load[mem+0]:throughput: CPI=    3.22, IPC=    0.31
   reg64:              store [mem+0]->load[mem+1]:   latency: CPI=   37.14, IPC=    0.03
   reg64:              store [mem+0]->load[mem+1]:throughput: CPI=   14.02, IPC=    0.07

(今さっき追加したテストなのでbdwとかの結果は無いです)

Zen は、dependency chain の長い load->store のループを入れると、ストアフォワーディングが死んでるように見える。

KNL

 m128: blendvps:   latency: CPI= 10.10, IPC= 0.10
 m128: blendvps:throughput: CPI= 12.06, IPC= 0.08
 m128: pshufb:     latency: CPI= 13.09, IPC= 0.08
 m128: pshufb:  throughput: CPI= 11.11, IPC= 0.09
 m128: pinsrd:  throughput: CPI=  4.17, IPC= 0.24

blendvps 、pshub がクソ遅い。こういうレイテンシが長くて、スループットとレイテンシのCPIがあんまり変わらない命令は、マイクロコード実行されてる気がする。

pinsrd も遅い。正しい意味でSIMDマシンになってる。要素単位の処理はSIMDじゃないので。(blendvpsはSIMDでは…?)

KNL のFPUスループット

reg64: add:   latency: CPI= 1.08, IPC= 0.93
reg64: add:throughput: CPI= 0.51, IPC= 1.96
m128: padd:   latency: CPI= 2.01, IPC= 0.50
m128: padd:throughput: CPI= 0.59, IPC= 1.68

「KNL は FPU を使うとクロックが落ちる」というような雑な説明をされてることが多く、これを見るかぎり、確かに、FPUを使うと、整数に比べると、性能が落ちているように見える。

しかし、これはおかしい。と、いうのは、instructon-bench では、あとで解説するように、時間は、CPU_CLK_UNHALTEDで計測していて、クロックが変動しても、結果はその影響を受けないはず。

もうちょっと個別に調べた時の感想が https://twitter.com/tanakmura/status/807219256576262144 にあるのだけど、KNL は、どうやら、2つあるFPU(VP0,VP1)のうち、一個が、一定クロック毎に停止するような実装になってるらしい。

VP0 でしか実行できないvpermpsを5つ、VP0,1 両方で実行できる vpaddd を 4 つ並べると、vpermps 5つが、5clockで実行できるのが確認できていた

  vpermps ; vaddps
  vpermps ; vaddps
  vpermps ; 
  vpermps ; vaddps
  vpermps ; vaddps

これは、「FPU使うと、クロックが落ちる」という雑な説明と整合性がとれない。「FPUを使ってクロックが落ちて、かつ、CPU_CLK_UNHALTEDが正しい値が取れていない」なら、vpermpsのスループットは 1 未満になるのが正しいはず。

ちなみに、

  vpermps ; vaddps
  vpermps ; vaddps
  vpermps ; vaddps
  vpermps ; vaddps
  vpermps ; vaddps

こんな風に、VP0, VP1 両方埋めてしまうと、スループットは1.68に落ちる。VP1の負荷が高まると、VP0のスループットも落ちる。

これは推測なのだけど、VP1 は、発熱を抑えるために、5cycle に 1cycle 停止していて、その実装がad hocに作られていて、スケジューラがその点を考慮しないで、VP0 と VP1 に均等に投げるから、なんか変な詰まりかたをして、1.68 という数字が出てくるんじゃないかと思う。(これ調べてた時は、真面目に計算したいと思ってたけど、結局やってないなぁ…)

instruction-bench を支える技術

レイテンシとスループット

基本的には、出力レジスタを次の命令の入力に渡すと、依存が発生して並列実行されなくなるので、レイテンシが計測できる。出力レジスタと入力レジスタを分ければ、スループットが出せる。

レイテンシを出すときは

  add r8, r8
  add r8, r8
  add r8, r8
  add r8, r8
  ...

これを L1I キャッシュから命令があふれない程度に並べる。ループ処理の影響を減らすために、なるべく大きいほうがいい。今はスカラが64命令、ベクタが36命令。(これは、KNL のfmaレイテンシが6あって、それを埋めるためには、命令数が12命令の倍数になってると都合がよかったので)

そのループの時間を計測して、命令数で割れば、レイテンシが出る。

スループットを出すときは

  add r8,r8
  add r9,r9
  add r10,r10
  add r11,r11
  add r12,r12
  add r13,r13
  add r14,r14
  add r15,r15
  add r8,r8
  add r9,r9
  ...

レジスタを変えて命令を並べる。こうすると、依存が無くなって、パイプラインほぼ埋められるはず。

ただ、これだと、fma のスループットだけうまく計測できない。というのは、fma は KNLで6のレイテンシがあって、これが2wayで実行されるので、最低12並列はつっこまないとフル性能出せない。なのでベクタ命令は、レジスタ12個使っている。スカラはレジスタをポインタ等に使っているので、12個もレジスタ空いてないので8並列。

  vfmaps r4,r4,r4
  vfmaps r5,r5,r5
  ...
  vfmaps r15,r15,r15
  vfmaps r4,r4,r4
  ...

xbyak を使って手軽に命令追加

以上のコードを簡単に出せるようにするのが、instruction-bench の機能の本体と言っていい。

https://github.com/tanakamura/instruction-bench/blob/master/bench.cpp を見てもらうと、下のほうに、

    GEN(Reg64, "add", (g->add(dst, src)), false, OT_INT);
    GEN(Reg64, "lea", (g->lea(dst, g->ptr[src])), false, OT_INT);
    GEN(Reg64, "xor dst,dst", (g->xor_(dst, dst)), false, OT_INT);

のようなのが、並んでいるのが確認できると思う。

ここに書いた命令が、実行時にxbyakを使って生成されて、それが計測される。

dst, src レジスタは、レイテンシを計測する時は、全部 r8 もしくは xmm8 に置換される。これで依存を作る。 スループットを計測する時は、上で書いたように、r8-r15 までのレジスタに置換されて、依存が発生しないように並べられる。

rdx はゼロ初期化した4MBのメモリを差している。rdiはゼロ初期化されている。必要ならこれを使う。

これによって、追加したい命令パターンをちょっと書くだけで、その命令シーケンスのレイテンシ/スループットが簡単に計測できるようになっている

(後ろふたつの、false, OT_INT は何かやろうとした名残で今は意味ないのでそのうち消します…)

色々な命令パターンの計測方法

基本的には、上のように計測したい命令を書けばよいが、命令によっては工夫が必要なものもある。以下、覚えてる範囲で書く。

ロード

load の依存を作るには、ロードした値を次のロードのアドレスに入れないといけない。ロードした値が、0以外だと、ロードする毎にアドレス変わってしまって困るので、0 初期化したメモリから読んで、その読んだ0を次のアドレスに入れるようにする。

    GEN(Reg64, "load", (g->mov(dst, g->ptr[src + g->rdx])), false, OT_INT);

g->rdx はゼロ初期化したメモリを差しているので、dst は必ずゼロになる。これを次のメモリのアドレスに入れれば依存ループが作れる。

ストア

公式マニュアルとかには、ストア命令のレイテンシ乗ってなかったりする。ストア命令なんて、どうせストアバッファに入ってそこからフォワーディングされるんだから、レイテンシなんて無いでしょ?という意味なのだろうか。

普通に書けば、

    GEN(Reg64, "store [mem+0]->load[mem+0]", 
        (g->mov(g->ptr[src+g->rdx],g->rdi)) ; (g->mov(dst, g->ptr[g->rdx])),
        false, OT_INT);

こう。ロードした値をストアして、それをもう一回ロードするのを繰り返す。これから、ロード命令のレイテンシを引けば、ストア命令のレイテンシが出る。ただし、これはストアバッファから読んだときのレイテンシしか計測できないので、ストア命令のレイテンシを正しく計測しているとは言えない。

そこで、ロードするアドレスをあえて 1byte ずらす。基本的に、今のCPUは、ストアした値の境界をまたぐロードをするとストアフォワーディングはされないので、1byteずらせばフォワーディングされない時の値が取れる。

    GEN(Reg64, "store [mem+0]->load[mem+1]", 
        (g->mov(g->ptr[src+g->rdx],g->rdi)) ; (g->mov(dst, g->ptr[g->rdx + 1])),
        false, OT_INT);

レジスタが変わる命令

movq, pextr のように、入力と出力でレジスタの種類が変わる命令は綺麗に依存チェインが作れない。

    GEN(Xmm, "movq->movq",
        (g->movq(g->rdi,src));(g->movq(dst,g->rdi));,
        false, OT_INT);

movq で xmm→gen, gen→xmm の、二命令のチェインのレイテンシは計測できるが、片方だけのレイテンシは計測できない。

まあレイテンシ既知の命令をうまく詰めれば計測する方法あるかもしれない。あんまり考えてないです。

pextr は pinsrd と組みあわせて、xmm→gen, gen→xmm のチェインが作れる。pinsrdは単体でも依存が作れて、レイテンシ計測できるので、pextr+pinsrdのレイテンシからそれを引けば、pextr のレイテンシも出せる。(書きながら思ったが、movq の xmm→gen も同じ方法で出せるな…)

div

div は、分母分子の値で性能変わるので、専用の計測があったほうがいいかもしれない。(未実装)

昔2chでそれやってるプログラム見たけど名前思い出せない

gather と比較用の ins gather

            GEN_latency(Ymm, "gather64(<ld+ins>x4 + perm)",

                        /* throughput */
                        (g->vmovq(g->xmm2, g->ptr[g->rdx]));
                        (g->vmovq(g->xmm3, g->ptr[g->rdx]));
                        (g->vpinsrq(g->xmm2, g->xmm2, g->ptr[g->rdx + 8], 1));
                        (g->vpinsrd(g->xmm3, g->xmm3, g->ptr[g->rdx + 8], 1));
                        (g->vperm2i128(g->ymm2,g->ymm2,g->ymm3,0));,

                        /* latency */
                        (g->vmovq(g->xmm2, g->ptr[g->rdx + g->rdi]));
                        (g->vmovq(g->xmm3, g->ptr[g->rdx + g->rdi]));
                        (g->vpinsrq(g->xmm2, g->xmm2, g->ptr[g->rdx + 8], 1));
                        (g->vpinsrd(g->xmm3, g->xmm3, g->ptr[g->rdx + 8], 1));
                        (g->vperm2i128(g->ymm2,g->ymm2,g->ymm3,0));
                        (g->vmovd(g->edi, g->xmm2));,

                        false, OT_FP32);

気合いで命令列を書いてもいいです。

perf interface を使った正しいクロック計測

クロック数を取る場合、rdtsc などが広く使われている。が、rdtsc は、CPUの省電力機能によるクロックの変動に関係なく、常に一定のクロックを刻み続けるので、正しく値を取るには、省電力機能を停止して計測する必要があり、不便である。

instruction-bench では、パフォーマンスカウンタから、CPU_CLK_UNHALTED の値を読んでいるので、この心配が無い。

CPU_CLK_UNHALTED は、以下のようにすれば取れます。

static int
perf_event_open(struct perf_event_attr *hw_event, pid_t pid,
                int cpu, int group_fd, unsigned long flags )
{
    int ret;

    ret = syscall( __NR_perf_event_open, hw_event, pid, cpu,
                   group_fd, flags );
    return ret;
}

int perf_fd;

static void
cycle_counter_init(void)
{
    struct perf_event_attr attr;
    memset(&attr, 0, sizeof(attr));

    attr.type = PERF_TYPE_HARDWARE;
    attr.size = sizeof(attr);
    attr.config = PERF_COUNT_HW_CPU_CYCLES;

    perf_fd = perf_event_open(&attr, 0, -1, -1, 0);
    if (perf_fd == -1) {
        perror("perf_event_open");
        exit(1);
    }
}

static long long
read_cycle(void)
{
    long long val;
    ssize_t sz = read(perf_fd, &val, sizeof(val));
    if (sz != sizeof(val)) {
        perror("read");
        exit(1);
    }

    return val;
}

Windows もこれ相当の機能を標準で実装してくれ! (まあ最近はサイドチャネル攻撃が心配されがちなので入れるの難しそうだけど)

この文章について

この文書はx86/x64最適化勉強会8のために書きました。