🦁

キャッシュのスラッシング

2020/09/25に公開

はじめに

本記事はキャッシュメモリのスラッシングが発生する様子を実験によって確かめた結果をまとめたものです。使ったCPUはAMD社のRyzen1800x(以下1800xと記載)です。スラッシングとは、あるキャッシュメモリの内容を書き換えたとき、キャッシュメモリに保存されているデータの整合性を保つために、別のキャッシュメモリの内容を無効化する、というしくみが頻繁に繰り返されることです。

CPUの構成

1800xにはCCXと呼ばれる4コアを搭載したダイが2つ乗っています。ダイの中にはコアが4つ入っており、かつ、コアの中には2つのハイパースレッドが存在します。これを、Linuxが認識する16の論理CPUの番号と対応付けたのが次の表です。

論理CPU番号 コア番号 CCX番号
0,1 0 0
2,3 1 0
4,5 2 0
6,7 3 0
8,9 4 1
10,11 5 1
12,13 6 1
14,15 7 1

1800xにはL1(L1i,L1d),L2,L3という3階層のexclusiveキャッシュメモリを持っており、L2まではコア内で共有、L3は同じCCX内でだけ共有という構成になっています。キャッシュラインサイズは64バイトです。

それぞれのサイズと共有関係を示したのが次の表です

名前 サイズ[KB] 共有
L1d 32 同一コア内
L1i 64 同一コア内
L2 512 同一コア内
L3 8192 同一CCX内

実験プログラム

実験プログラムの仕様は次の通りです。

  • 二つの引数を受け取る
  • バッファサイズ[KB単位]
  • 下記2つのスレッドがバッファを共有するか否か(true/false)
  • 2つのスレッドが、それぞれ合計1GBのデータを書きこむ: バッファ内の全キャッシュラインへの書き込みを"1GB/(バッファサイズ/キャッシュラインサイズ)"回繰り返す

これを実装したのが次のソースです。

false_sharing.c
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <err.h>
#include <errno.h>
#include <stdbool.h>

#define TOTAL_BYTES     (1024*1024*1024)
#define CACHE_LINE_SIZE 64

static int buffer_size;

static void * thread_fn(void *arg)
{
        char *buf = (char *)arg;
        char *end = buf + buffer_size;

        int n = TOTAL_BYTES / (buffer_size / CACHE_LINE_SIZE);

        int i;
        for (i = 0; i < n; i++) {
                char *p;
                for (p = buf; p < end; p += CACHE_LINE_SIZE)
                        *p = 0;
        }

        return NULL;
}

static char *progname;

int main(int argc, char *argv[])
{
        progname = argv[0];

        if (argc != 3) {
                fprintf(stderr, "usage: %s <buffer size[KB]> <share>\n", progname);
                exit(EXIT_FAILURE);
        }

        buffer_size = atoi(argv[1]) * 1024;
        if (!buffer_size) {
                fprintf(stderr, "'buffer size' should be larger than 0: %s\n",
                        argv[1]);
                exit(EXIT_FAILURE);
        }

        bool share;
        if (!strcmp(argv[2], "true")) {
                share = true;
        } else if (!strcmp(argv[2], "false")) {
                share = false;
        } else {
                fprintf(stderr, "'share' should be 'true' or 'false': %s\n", argv[2]);
                exit(EXIT_FAILURE);
        }

        int ret;
        char *buf1;
        ret = posix_memalign((void *)&buf1, CACHE_LINE_SIZE, buffer_size * 2);
        if (ret) {
                errno = ret;
                err(EXIT_FAILURE, "posix_memalign() failed");
        }

        char *buf2;
        if (share)
                buf2 = buf1;
        else
                buf2 = buf1 + buffer_size;

        int i;
        pthread_t tid[2];
        for (i = 0; i < 2; i++) {
                void *arg;
                if (i == 0) {
                        arg = buf1;
                } else if (i == 1) {
                        arg = buf2;
                } else {
                        abort();
                }
                ret = pthread_create(&tid[i], NULL, thread_fn, arg);
                if (ret) {
                        errno = ret;
                        err(EXIT_FAILURE, "pthread_create() failed");
                }
        }
        for (i = 0; i < 2; i++) {
                ret = pthread_join(tid[i], NULL);
                if (ret)
                        warn("pthread_join() failed");
        }

        exit(EXIT_SUCCESS);
}

ビルド方法

$ cc -O2 -o false_sharing{,.c} -lpthread
$ 

実行方法

単純なコマンド発行は次の通りです。

$ ./false_sharing <buffer size> <share>

2スレッドが同じCCX上に配置して所要時間を計測する場合は次のようにします。

$ time taskset -c 0,4 <buffer size> <share>

2スレッドを別のCCX上に配置して所要時間を計測する場合は次のようにします。

$ time taskset -c 0,8 <buffer size> <share>

採取パターン

以下のパターンを全部網羅します。

  • バッファサイズ: L1dキャッシュの1/4(バッファがL1dキャッシュに収まる場合), L2キャッシュの1/4(バッファがL2キャッシュに収まる場合), L3キャッシュの1/4(バッファがL3キャッシュに収まる場合), L3キャッシュ*2(バッファがキャッシュに収まらない場合)
  • 2つのスレッドのCCX: 同じ、別

結果

採取したデータの1.x倍、あるいは数倍程度の差はここでは気にしません。数十倍の性能劣化が発生するスラッシングにのみ話を絞ります。なお、これから示す測定結果の数値の単位は秒です。

ケースA: バッファサイズがL1dのサイズのに収まる(32/4 = 8[KB])

プロセスを割り当てる論理CPU バッファ非共有 バッファ共有
0,4(同じCCX) 0.675 13.2
0,8(別CCX) 0.824 22.5

2つのスレッドのCCXが違う場合も異なる場合もL1を吹っ飛ばすスラッシングが発生していることがわかります。これによって数十倍のアクセス性能劣化が発生しています。

ケースB: バッファサイズがL2 cacheに収まる(512/4 = 128[KB])

プロセスを割り当てる論理CPU バッファ非共有 バッファ共有
0,4(同じCCX) 0.625 14.70
0,8(別CCX) 0.690 18.8

ケースAと同様、2つのスレッドが同じCCXの場合も、違うCCXにある場合もL2 cacheを飛ばすスラッシングが発生しています。

ケースC: バッファサイズがL3 cacheに収まる(8*1024/4 = 2MB)

プロセスを割り当てる論理CPU バッファ非共有 バッファ共有
0,4(同じCCX) 0.885 0.900
0,8(別CCX) 0.878 6.93

2スレッドが同じCCXにある場合は、バッファを共有していようと性能がほとんど変わりません。この理由は、このバッファサイズの場合はデータはほとんどL3キャッシュ上にあること、および、2スレッド間でL3キャッシュを共有しているためにスラッシングが発生しないことだと考えられます。私の知る限り、本記事執筆現在のIntelのCore系プロセッサにおいてはL3キャッシュを全コア間で共有するため[1]、このようなことが発生しないはずです。

2スレッドが別のCCXにある場合は、互いのL3キャッシュを吹っ飛ばし合うのでスラッシングが発生しています。

ケースD: バッファサイズがL3 cacheに収まらない(810242 [KB] = 16 [MB])

プロセスを割り当てる論理CPU バッファ非共有 バッファ共有
0,4(同じCCX) 9.13 14.5
0,8(別CCX) 8.87 7.96

いずれもケースA-Cにおいてスラッシングが発生した場合のような速度になっています。それぞれ、もともとデータがL3キャッシュに乗っていないため、キャッシュの吹き飛ばしによる影響が少ないためだと考えられます。

おわりに

細かい数値の大小について気になるところはいくらでもありますが、スラッシングの発生を確かめられたので、満足です。

脚注
  1. もし間違っていたらご指摘ください ↩︎

Discussion