はじめに
本記事はARM v8 アーキテクチャのネイティブな開発環境を提供するSC0FQAA-Bに搭載されているSocionext SC2A11 (24コア)のキャッシュメモリがそれぞれどのコア間で共有されているかを調べた結果をまとめた記事です。
なぜそんなことをしたかというと、SC2A11のカタログにはL1dが32KB, L2が256KB, L3が4MBということは書いてあったのですが、それぞれの共有関係については記載がなかったからです。公式サイトからたどれるその他ドキュメントにも同様に記載がありませんでした。
資料請求のフォームがあったのでそこから仕様書をもらって確認するという方法がありましたが
- 個人ユーザが「キャッシュメモリの情報を知りたかったから」という理由で資料をもらえる気がしない
- できたとしても面倒そうだし、時間もかかりそう
という理由により、実験によって情報を得ることにしました。
ちなみに実験に使ったマシンは自分で買ったわけではなく、東大PFLabが買ったもの同研究室のご厚意によって借りたものです。某SNSでこのキャッシュの疑問についてブツブツつぶやいていたら、PFLabでこのマシンをセットアップしたlivaさんがよきに計らってくれました。livaさんとPFLabには厚く御礼申し上げます。
調査結果
キャッシュメモリは恐らく次のような構成になっていることがわかりました。
| 名前 | 合計サイズ[KB] | 共有関係 | サイズ/コア[KB] |
|---|---|---|---|
| L1d | 32 | 全コア独立 | 32 |
| L2 | 256 | 2コアごとに共有 | 128 |
| l3 | 4096 | 全コアで共有 | 約170 |
(2018/3/16追記) 数日前、このマシン用のDevice Treeにキャッシュメモリ構成に関する情報を追加するパッチが投稿されていることをMasami Hiramatsuさんに教えていただきました。パッチの内容によると筆者の推測は正しかったようです。なお、このパッチはすでにマージされたので、いずれファームウェアのバージョンアップによってキャッシュメモリの情報が正しくlinuxから取得できるようになりそうです。
sysfsからの情報採取
カーネルが初期化時にハードウェアからキャッシュメモリの構成を得ている場合は/sys/devices/system/cpu/cpu
dmesgを見たところ、起動中に次のようなメッセージが出力されていました。
# dmesg ... [ 2.582856] cacheinfo: Unable to detect cache hierarchy for CPU 0 ...
これはlinuxカーネルのソースコードでいうと以下の部分に該当します。
...
static int detect_cache_attributes(unsigned int cpu)
{
int ret;
if (init_cache_level(cpu) || !cache_leaves(cpu))
return -ENOENT;
per_cpu_cacheinfo(cpu) = kcalloc(cache_leaves(cpu),
sizeof(struct cacheinfo), GFP_KERNEL);
if (per_cpu_cacheinfo(cpu) == NULL)
return -ENOMEM;
ret = populate_cache_leaves(cpu);
if (ret)
goto free_ci;
/* * For systems using DT for cache hierarchy, of_node and shared_cpu_map * will be set up here only if they are not populated already */
ret = cache_shared_cpu_map_setup(cpu);
if (ret) {
pr_warn("Unable to detect cache hierarchy for CPU %d\n", cpu); ... ★
goto free_ci;
}
cache_override_properties(cpu);
return 0;
free_ci:
free_cache_attributes(cpu);
return ret;
}
...
どうやらcache_shared_cpu_map_setup()が失敗したようです。この原因が次のうちどれなのかはわかっていません。
- 全アーキテクチャ共有コード(drivers/base/cacheinfo.cあたり)
- arm64のコード(arch/arm64/kernel/cacheinfo.cあたり)
- このマシン用のDevice Tree(/sys/firmware/devicetree以下にマップされている)
- その他
ここでこれ以上深堀りはしたくなかったので、次に示す実験によるアプローチによってキャッシュメモリ構成を推測することにしました。
実験
実験のコンセプトは次の通りです。
- コア0上で所定サイズのバッファにアクセスし続けるプログラム(memory_loadプログラム)を動かす
- コア0~23のいずれかの上で、所定サイズのバッファ(memory_loadプログラムのものとは別)に所定の回数アクセスすることによってアクセス速度を計測するプログラム(cacheプログラム)を動かす
- memory_loadプログラムのバッファサイズを(a) L1よりやや小さな値(30[KB])、(b) L2よりやや小さな値(500[KB])、(c) L3 よりやや小さな値(4000[KB])にする。これによって次のことが言える
- a) cacheプログラムのバッファサイズL1以下における性能が劣化した場合、2つのプログラムが動作しているコア間ではL1キャッシュを共有している
- b) 同バッファサイズがL2以下における性能が劣化した場合、2つのプログラムが動作しているコア間ではL2キャッシュを共有している
- c) 同バッファサイズがL3以下における性能が劣化した場合、2つのプログラムが動作しているコア間ではL3キャッシュを共有している
2つのプログラムのソースを示します。
#include <unistd.h>
#include <sys/mman.h>
#include <time.h>
#include <stdio.h>
#include <stdlib.h>
#include <err.h>
#define CACHE_LINE_SIZE 64
int main(int argc, char *argv[])
{
char *progname;
progname = argv[0];
if (argc != 2) {
fprintf(stderr, "usage: %s <size[KB]>\n", progname);
exit(EXIT_FAILURE);
}
register int size;
size = atoi(argv[1]) * 1024;
if (!size) {
fprintf(stderr, "size should be >= 1: %d\n", size);
exit(EXIT_FAILURE);
}
char *buffer;
buffer = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (buffer == (void *) -1)
err(EXIT_FAILURE, "mmap() failed");
struct timespec before, after;
clock_gettime(CLOCK_MONOTONIC, &before);
int i;
for (;;) {
long j;
for (j = 0; j < size; j += CACHE_LINE_SIZE)
buffer[j] = 0;
}
if (munmap(buffer, size) == -1)
err(EXIT_FAILURE, "munmap() failed");
exit(EXIT_SUCCESS);
}
#include <unistd.h>
#include <sys/mman.h>
#include <time.h>
#include <stdio.h>
#include <stdlib.h>
#include <err.h>
#define CACHE_LINE_SIZE 64
#define NLOOP (64*1024*1024)
#define NSECS_PER_SEC 1000000000UL
static inline long diff_nsec(struct timespec before, struct timespec after)
{
return ((after.tv_sec * NSECS_PER_SEC + after.tv_nsec)
- (before.tv_sec * NSECS_PER_SEC + before.tv_nsec));
}
int main(int argc, char *argv[])
{
char *progname;
progname = argv[0];
if (argc != 2) {
fprintf(stderr, "usage: %s <size[KB]>\n", progname);
exit(EXIT_FAILURE);
}
register int size;
size = atoi(argv[1]) * 1024;
if (!size) {
fprintf(stderr, "size should be >= 1: %d\n", size);
exit(EXIT_FAILURE);
}
char *buffer;
buffer = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (buffer == (void *) -1)
err(EXIT_FAILURE, "mmap() failed");
struct timespec before, after;
clock_gettime(CLOCK_MONOTONIC, &before);
int i;
for (i = 0; i < NLOOP / (size / CACHE_LINE_SIZE); i++) {
long j;
for (j = 0; j < size; j += CACHE_LINE_SIZE)
buffer[j] = 0;
}
clock_gettime(CLOCK_MONOTONIC, &after);
printf("%f\n", (double)diff_nsec(before, after) / NLOOP);
if (munmap(buffer, size) == -1)
err(EXIT_FAILURE, "munmap() failed");
exit(EXIT_SUCCESS);
}
それぞれ次のようにビルドします。
$ cc -O3 -o memory_load memory_load.c $ cc -O3 -o cache cache.c
memory_loadプログラムを所定のCPU上で動かすときは次のようにします。
$ taskset -c <CPU番号> ./memory_load <バッファサイズ [KB]>
cacheプログラムを所定のCPU上で動かすときは次のようにします。
$ taskset -c <CPU番号> ./cache <バッファサイズ [KB]>
リファレンスとなるデータの採取
まずはすべてのデータの比較対象となる、負荷をまったくかけない状態の性能を計りました。その結果を以下に示します。
$ for i in 8 16 32 64 128 256 512 1024 2048 4096 8192 16384 ; do taskset -c 0 ./cache $i ; done 0.494395338 0.4878244995 0.7931622984 0.849168839 0.8938277609 0.9555984775 1.041136652 1.064321914 1.069313969 1.158684927 1.505872599 1.577874511 $
コマンド実行結果の数値の単位はナノ秒です。これをグラフにすると次のようになります。x軸もy軸も対数なので見かたに注意してください。

おおよそL1(25 [KB]), L2(28 [KB]), L3(212 [KB])を境界として性能が悪くなってゆくことがわかります。
実験結果
L1の共有範囲
memory_loadのバッファサイズがL1よりやや小さな値(30KB)場合のデータをもとにしたグラフを以下に示します。

memory_loadプログラムとcacheプログラムを同じコア(コア0)上で動かした場合のみ性能が悪く、その他は同程度の性能となりました。ごちゃごちゃしていて見づらいので、
- memory_loadプログラムを動かさない場合(リファレンス性能)
- cacheプログラムをコア0上で動かした場合,
- 同、コア1上で動かした場合
というデータのみを抽出したグラフを以下に示します。

コア0上でmemory_loadプログラムによってL1キャッシュをほとんど使っても他のコアにおける性能がほぼ変わらないことから、コア0とコア1~23はキャッシュを共有していないことがわかりました。
なお、cacheプログラムをコア0上で動かした場合の性能が悪いのは、2つのプログラムがCPU時間を分け合っているせいでしょう。
L2の共有範囲
memory_loadのバッファサイズがL2よりやや小さな値(500KB)にした場合のデータをもとにしたグラフを以下に示します。ここでcacheプログラムをコア2~23で動かした場合のデータはほぼ同じだったので、
- memory_loadプログラムを動かさない場合(リファレンス性能)
- cacheプログラムをコア0上で動かした場合,
- 同、コア1上で動かした場合
- 同、コア2上で動かした場合
というデータのみを抽出しています。

cacheプログラムをコア1上で動かした場合にバッファサイズがL1のサイズ~L2のサイズあたりで性能劣化していることから、コア0とコア1はL2キャッシュを共有していると考えられます。
L3の共有範囲
memory_loadのバッファサイズがL3よりやや小さな値(4000 KB)にした場合のデータをもとにしたグラフを以下に示します。ここでcacheプログラムをコア2~23で動かした場合のデータはほぼ同じだったので、
- memory_loadプログラムを動かさない場合(リファレンス性能)
- cacheプログラムをコア0上で動かした場合,
- 同、コア1上で動かした場合
- 同、コア2上で動かした場合
というデータのみを抽出しています。

cacheプログラムをコア2上で動かした場合にバッファサイズがL2のサイズ~L3のサイズあたりで性能劣化していることから、コア0とコア2(および3~23)はL3キャッシュを共有していると考えられます。
おわりに
今後も本マシンの色々なデータをとっていきたいと思います。バッファサイズをL3のサイズを超える16MBにしてmemory_loadプログラムを動かしたところ、コア0とL2を共有するコア1上でcacheプログラムを動かした場合の性能が上がったという謎現象(L2を共有していないかのような振舞いをする)が発生しました。

これが気になっているので最初に調査するかもしれませんし、気が変わってI/O性能測定などの全然違うことをするかもしれません。
(2018/3/13追記) 結局はさらに気が変わってSC2A11がプロセススケジューラからどう見えるかという観点で調査しました。