clflushopt を使ってキャッシュフラッシュを高速にしよう

キャッシュフラッシュに苦しんでいる人も世の中にはたくさんいる。

そういう人を救うために、Skylake からはclflushopt という命令が新設された。


これまでは、clflush という命令があって、これを使うとキャッシュフラッシュができた。clflushoptは、これよりはやい。どのぐらい違うかというと、手元(i7-6770 + DDR4 2ch) だと、

clflush 4.1[GB/s]
clflushopt 43.6[GB/s]
memset 30.2[GB/s]

このぐらい違う。

#include <stdio.h>
#include <x86intrin.h>
#include <sys/time.h>
#include <string.h>
#include <time.h>

double
sec(void)
{
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);

    return ts.tv_sec + (ts.tv_nsec / 1000000000.0);
}

unsigned char *data;

void __attribute__((noinline,noclone))
clflush(size_t nline)
{
    int li;
    for (li=0; li<nline; li++) {
        _mm_clflush(data + li*64);
    }

    asm volatile ("" ::: "memory");
}

void __attribute__((noinline,noclone))
clflush_opt(size_t nline)
{
    int li;
    for (li=0; li<nline; li++) {
        _mm_clflushopt(data + li*64);
    }

    asm volatile ("" ::: "memory");
}

void __attribute__((noinline,noclone))
run_memset(size_t nline)
{
    int li;
    memset(data, 0, nline * 64);
    asm volatile ("" ::: "memory");
}

int
main(int argc, char **argv)
{
    double t0, t1, total;
    size_t size = 128 * 1024*1024;
    void *p;
    int li;
    int nloop = 32;

    if (argc > 1) {
        size = atoi(argv[1]) * 1024*1024;
    }

    posix_memalign(&p, 4096, size);
    data = p;

    clflush(size/64);
    clflush_opt(size/64);

    t0 = sec();
    for (li=0; li<nloop; li++) {
        clflush(size/64);
    }
    t1 = sec();

    total = size * (double)nloop;
    printf("%20s %f[MiB/s]\n", "clflush", total / ((t1-t0) * 1024*1024));

    t0 = sec();
    for (li=0; li<nloop; li++) {
        clflush_opt(size/64);
    }
    t1 = sec();

    total = size * (double)nloop;
    printf("%20s %f[MiB/s]\n", "clflushopt", total / ((t1-t0) * 1024*1024));


    t0 = sec();
    for (li=0; li<nloop; li++) {
        run_memset(size/64);
    }
    t1 = sec();

    total = size * (double)nloop;
    printf("%20s %f[MiB/s]\n", "memset", total / ((t1-t0) * 1024*1024));
    
}

以下書いてたらクソつまらなくて長い文章になったのでひとことで書いておくと、clflushopt を使うときは、ちゃんと mfence も入れたほうがいいです。

(以下、クソつまらなくて長い文章です)


さて、何故、同じキャッシュフラッシュなのに、clflushopt が、 clflush よりとても速いのか、だが、以下に書いておく。


まず、clflush が memset より遅いのは不思議に思うかもしれない。(思ってほしい)


clflush は、データをキャッシュから追い出す処理で、memset はメモリに書き込む処理だが、キャッシュは数MBしか無いのだから、巨大な領域に大してキャッシュフラッシュするのと、memsetするのを比べると

  • clflush : 8MBのキャッシュ領域をチェックして、あとはなにもしない
  • memset : 8MBのキャッシュ領域をチェックして、溢れたら巨大な領域にデータをストアする

と、なるはず。作業量は、clflushのほうが少ないはずだ。しかし、実際には、clflush のほうが10倍近く遅い。何故?


何故かというと、clflush は、処理の完了を待ってから、次の命令を実行する必要があるからだ。


今のCPUでは、物理的にメモリに書き込まれる値は、プログラムから見える値とは、異なる場合がある。

p0[0] = 0; // (1)
p1[0] = 0;
p0[0] = 1;

とかを考えれば、今のCPUにキャッシュが付いてることをを知ってる人ならば(1)の書き込みが、実際にメモリに書きこまれることなく世界から消えてることはわかるだろう。

store 命令は、実際にはstore なんてしてなくて、単に「次にアドレスp0を読んだときに値が見えるようにする」だけだ。

キャッシュに書き込む処理というのは、

  1. MMU経由して、仮想アドレスを物理アドレスに変換する(これも実際には2階層 TLBを引くので結構処理する)
  2. PAT を見てキャッシュの挙動を探す
  3. キャッシュ可能領域なら L1 のタグを探してあるかどうか見る
  4. L1 に無ければ L2 を探す

と、いうような処理をしないといけない。しかし、「次にp0を読んだときに値が見えるようにする」だけなら、直前に書いた値をいくつか履歴として持っておくだけでよい。(これをストアバッファとかいう)
CPUとしては、とりあえず、ストアバッファに値だけ入れておいて、そこから先は、CPUコアとは独立して動くキャッシュシステムに丸投げ、CPUコアは、キャッシュシステムと並列に動き、全体として効率が上がる、というようになる

そして、上の 1,2,3,4 の処理は、依存しない書き込みならパイプライン化できるので、長い目で見れば、スループットはCPUの速度に追い付けるぐらい十分良くできる(ここらへん説明不足。お前こんだけ長い文章書いてて説明不足なのかよぉ!)


一方、clflush では、そういうインチキは許されない。


例えば、次の処理を考える

memset(mem, 0, sizeof(mem)); // メモリ領域に値をセット
dma_set_address(mem);        // これをdma転送する
clflush(mem);                // DMAできるように領域flush
dma_start();                 // DMA開始

wait_dma();

この処理は、正しく動くだろうか?DMA は実際にメインメモリへ読みに行くので、storeと同じように、clflush が「メインメモリには書かないでclflushしたように見えるだけ」なら、間違った値が読めてしまう可能性がある。

つまり、clflush は、

  1. MMU経由して、仮想アドレスを物理アドレスに変換する
  2. PAT を見てキャッシュの挙動を探す
  3. L1 のタグを探してあるかどうか見る
  4. L1 にあればそれをフラッシュ
  5. L2 のタグを探してあるかどうか見る
  6. L2 にあればそれをフラッシュ

この動作を完全に終えた時点で、命令終了としなければならない。完了まで、次の動作を開始できないので、メモリパイプラインを埋めることができず、効率は非常に悪くなってしまう。


ボールが一個ギリギリ通せる配管をイメージしてもらって、store 命令は、この配管にボールを大量に片方向に流しこんでるイメージ

(A) store

\10回ストアして/            
      ○      ----------------        ○ 
      ↑      コロコロ =OOOOOOOOOO        ↑
      ∧      ----------------        ∧
                              /ボールまだかなー\


一方、clflush は、同じ配管を使って、ボールを交互にやりとりしてる状態をイメージしてもらうのがよいかもしれない。

(B) clflush

\フラッシュして/
      ○      ----------------        ○ 
      ↑      コロコロ =O                 ↑
      ∧      ----------------        ∧
                              /ボールまだかなー\


      ○      ----------------        ○ 
      ↑                     =O       ↑
      ∧      ----------------        ∧
                              /はいフラッシュ\


      ○      ----------------        ○ 
      ↑               O=             ↑
      ∧      ----------------        ∧
                                 /おわったよ\

   \(^o^)/
      ○      ----------------        ○ 
      ↑     O=                       ↑
      ∧      ----------------        ∧


これだと、例え、ボールの数が半分だったとしても片方向に一方的に流すよりもボールを相手に送るのに必要な時間は増えることがわかるだろう。


しかし、これは、実際の使いかたを考えると、安全側に倒しすぎだ。実際の用途なら、キャッシュライン毎に完了を待つのはほぼ確実に無駄で、もっと大きい領域で、完了を待ったほうが効率がいい。

上の配管のイメージでいうと、100個ぐらいボールを投入してから、「今まで入れた100個のボールが届いたら、1個ボールを返してくれ」とするほうが、配管の利用効率は上がる。

(C) clflushopt

\フラッシュしといて/
      ○      ----------------        ○ 
      ↑      コロコロ =XOOOOOO           ↑
      ∧      ----------------        ∧
                              /ボールまだかなー\


      ○      ----------------        ○ 
      ↑                    =XOOOO    ↑
      ∧      ----------------        ∧
                              /フラフラフラ…フラッシュ\

      ○      ----------------        ○ 
      ↑                     =X       ↑
      ∧      ----------------        ∧
                              /これで終わりね\


      ○      ----------------        ○ 
      ↑               O=             ↑
      ∧      ----------------        ∧
                                 /おわったよ\

   \(^o^)/
      ○      ----------------        ○ 
      ↑     O=                       ↑
      ∧      ----------------        ∧


ここで、(B)のように一回一回確認するのが、clflush、(C)のように複数のflushリクエストをまとめて実行するのが、clflushopt、ということになる。


なので、clflushoptのほうがはやくなるのだった。


ただし、上のような事情があって、clflushoptのほうは、命令が終わってもflushの完了が終わるわけではないので、clflushopt のあとは、ちゃんとmfence, sfenceを入れる必要がある。忘れないようにね。

// clflush はsfenceいらない

_mm_clflush(mem+64*0);
_mm_clflush(mem+64*1);
_mm_clflush(mem+64*2);
_mm_clflush(mem+64*3);
// clflushopt はちゃんと完了を待つために、最後にsfenceを入れる

_mm_clflushopt(mem+64*0);
_mm_clflushopt(mem+64*1);
_mm_clflushopt(mem+64*2);
_mm_clflushopt(mem+64*3);

_mm_sfence();

あと昔のマニュアルでは、clflushが今のclflushoptの挙動(完了を待たない)と書いてあったのだけど、ドライバが壊れることを恐れて日和ったIntel は、マニュアルに反してこれまでのCPU全てで clflush の挙動を安全側に倒しており、clflushopt の実装にあわせて、マニュアルのほうを安全側に修正するという対応をとった。過去は書きかわったのだ!(という話がそもそも書きたかったのだけど前フリが長くなりすぎたので没)