EPYCマシンの検証(2) - NUMAノードをまたぐメモリアクセス速度

はじめに

技術顧問のsatです。前回に引き続き、EPYCマシンの検証についての話をします。手元のEPYCマシン(Super Micro AS-1023US-TR4)はNUMAアーキテクチャ(後述)を採用してます。今回はこのマシンにおけるNUMAノードをまたいだメモリアクセスに関するデータを採取しましたので、その結果をお伝えします。

NUMAについて知っているかた向けの結論

  • 2CPUパッケージから成るEPYCマシンにおいては、CPUパッケージごとに4つのノード、合計8つのノードがある
  • 同じCPUパッケージ上のリモートノード上のメモリへのアクセス速度はローカルノード上のメモリへのアクセス速度に比べて1.7倍程度遅い
  • 別のCPUパッケージ上のリモートノード上のメモリへのアクセス速度はローカルノード上のメモリへのアクセス速度に比べて3.3倍程度遅い
  • (記事では省略したが)"numactl --interleave all"した場合はローカルノード上のメモリへのアクセス速度に比べて2.2倍程度遅い

このデータだけで何ができるというわけではないですが、メモリアクセス速度がボトルネックになるようなシステムにおいては、この値が性能評価における一つの基準になるでしょう。

NUMAとは

本節ではNUMAという言葉についてなじみのないかたがたに向けて簡単にNUMAの説明をします。知っているかたは本節は読み飛ばしてください。NUMAとは複数のCPUコア(以下コアと記載)から成るシステムにおける次のようなアーキテクチャです。

  • システム内の1つないし複数のCPUコア(以下コアと記載)とメモリをひとまとめにしたものをノードと呼ぶ
  • システムは2つ以上のノードから構成される
  • ノード同士はインターコネクトと呼ばれる伝送路によって接続される
  • あるコアから見ると、自身が属するノードをローカルノード、そうでないノードをリモートノードと呼ぶ
  • ローカルノードに属するメモリをローカルメモリ、そうでないメモリをリモートメモリと呼ぶ
  • ローカルメモリへのアクセスはリモートメモリへのアクセスよりも早い

これだけでは難しいので、もっとも単純な次のようなNUMAシステムを考えます。

f:id:cybozuinsideout:20180327112014p:plain

このとき、メモリアクセスの速度は下図のようにどのノード上のコアからどのノード上のメモリにアクセスするかによって異なります。

f:id:cybozuinsideout:20180327112018p:plain

ハードウェアが提供する情報によって、この速度差がどれくらいのものかがわかります。具体的にはnumactlパッケージに含まれるnumactlコマンドを-Hオプション付きで実行します。とある2ノードシステムにおける結果は次のようになりました。

$ numactl -H
...
node distances:
node   0   1  
  0:  10  21
  1:  21  10
$ 

これは次のような意味です。

  • ノード0上のコアからローカルメモリへのアクセス速度は10(この数値の意味は後述)
  • ノード0上のコアからノード1上のリモートメモリへのアクセス速度は21
  • ノード1上のコアからノード0上のリモートメモリへのアクセス速度は21
  • ノード1上のコアからノード1上のローカルメモリへのアクセス速度は10

速度欄の10や21という値は「ナノ秒」などの具体的な単位付きのものではなく相対的なもので、「ノード0からノード1上のリモートメモリへのアクセスはローカルメモリへのアクセスよりも2.1倍遅い」という意味です。

ハードウェアが提供するNUMAノード間のメモリアクセス速度差

手元のEPYCマシンのNUMA構成は前節において記載したものよりもはるかに複雑です。

  • 2つの物理的なCPUパッケージを搭載
  • 1CPUの中にダイと呼ばれる4つの部品を搭載
  • 1ダイの中に6個のコア(12スレッド)を搭載
  • 1つのダイが1つのノードに対応

これを図示すると次のようになります。インターコネクトをすべて書くと図が非常に見づらくなるので、図でいうと一番左上のノードから他のノードにたどり着くためのものだけを記載しています。

f:id:cybozuinsideout:20180327112022p:plain

ではこのマシンのnumactl -Hの値を見てみましょう。

$ numactl -H
...
node distances:
node   0   1   2   3   4   5   6   7
  0:  10  16  16  16  32  32  32  32
  1:  16  10  16  16  32  32  32  32
  2:  16  16  10  16  32  32  32  32
  3:  16  16  16  10  32  32  32  32
  4:  32  32  32  32  10  16  16  16
  5:  32  32  32  32  16  10  16  16
  6:  32  32  32  32  16  16  10  16
  7:  32  32  32  32  16  16  16  10
$ 

ややこしいのでここではノード0からノード0-7へのアクセス速度のみに注目します。

...
node   0   1   2   3   4   5   6   7
  0:  10  16  16  16  32  32  32  32
...

詳細は省略しますが*1、node0-3はCPUパッケージ0上にあり、node4-7がCPUパッケージ1上にあります。これから次のことが言えます。

  • CPUパッケージ0上のリモートノード(ノード1-3)上からのリモートメモリへのアクセスはローカルメモリへのアクセスより1.6倍遅い
  • CPUパッケージ1上のリモートノード(ノード4-7)上のリモートメモリへのアクセスはローカルメモリへのアクセスより3.2倍遅い

ただし、ハードウェアが提供する情報と実際のメモリアクセス速度が異なることは珍しくないので、次節においてメモリアクセス速度を実測します。

実測

プログラム

実測には次のようなことをするプログラムを使います。

  • 適当なサイズのメモリバッファを獲得する
  • 上記バッファに所定の回数アクセスして、所要時間を計測する
  • 所要時間を出力する

これを実装したのが以下のmacccess.cプログラムです。

#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 BUFFER_SIZE     (256*1024*1024)
#define NLOOP           256

#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[])
{
        int size = BUFFER_SIZE;

        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_wo_access, after_w_access;
        int i;
        volatile int j;
        clock_gettime(CLOCK_MONOTONIC, &before);
        for (i = 0; i < NLOOP; i++)
                for (j = size - CACHE_LINE_SIZE; j >= 0; j -= CACHE_LINE_SIZE)
                        ;
        clock_gettime(CLOCK_MONOTONIC, &after_wo_access);
        double t_wo_access = (double)diff_nsec(before, after_wo_access);

        clock_gettime(CLOCK_MONOTONIC, &before);
        for (i = 0; i < NLOOP; i++)
                for (j = size - CACHE_LINE_SIZE; j >= 0; j -= CACHE_LINE_SIZE)
                        buffer[j] = 0;
        clock_gettime(CLOCK_MONOTONIC, &after_w_access);
        double t_w_access = (double)diff_nsec(after_wo_access, after_w_access);
        printf("%f\n", (t_w_access - t_wo_access)/NSECS_PER_SEC);

        if (munmap(buffer, size) == -1)
                err(EXIT_FAILURE, "munmap() failed");
        exit(EXIT_SUCCESS);
}

ビルド方法は次の通り。

$ cc -O3 -o meccess maccess.c
$ 

測定においてはnumactlコマンドを利用して以下2つの条件を満たすようにします。

  • ノード0に属するコア0上で処理を実行する(--physcpubind 0オプションを指定)
  • ノード0-7上でメモリバッファを獲得する(--membind <ノード番号>オプションを指定)

測定は次のように実行しました。

$ for ((i=0;i<8;i++)) ; do numactl --physcpubind=0 --membind $i ./maccess ; done

結果

$  for ((i=0;i<8;i++)) ; do numactl --physcpubind=0 --membind $i ./maccess ; done
2.492153
4.294302
4.232083
4.20949
8.459225
8.568392
7.946654
8.367991
$ 

所要時間をノード0からのアクセスにおける所要時間からの相対値にすると次のようになります

ノード番号 相対速度
0 1
1 1.72
2 1.69
3 1.68
4 3.39
5 3.43
6 3.18
7 3.35

グラフ化すると次のようになります。

f:id:cybozuinsideout:20180327112032p:plain

これから次のことが言えます。

  • 同じCPUパッケージ上のノード上のメモリに対するアクセスはローカルノード上のメモリへのアクセスよりも1.68-1.72倍程度遅い
  • 別のCPUパッケージ上のノード上のメモリに対するアクセスはローカルノード上のメモリへのアクセスよりも3.18-3.43倍程度遅い

ハードウェアが報告する値とまったく同じとはいえないものの、おおよそ似たような値になりました。

細かい考察(参考)

前節において示したデータを見ると、ノード0と異なるCPUパッケージに属するノード4-7のうち、ノード6上のメモリへのアクセス速度はノード4,5,7上のメモリへのアクセス速度に比べて5-8%程度速いことがわかりました。グラフで見ても同じとは言いにくい差が出ていますし、何度測定しても結果は同じでした。これより、2ソケットのEPYCマシンが次の図のような構造になっているのではないかと推測しました。

f:id:cybozuinsideout:20180327112028p:plain

この推測が正しければ、ノード0からノード6はインターコネクトを1つ通してメモリアクセスできるのに対して、ノード0からノード4,5,7はノード6を経由してインターコネクトを2つ通してメモリアクセスしなければいけないため、アクセス速度に違いが出ていると考えられます。ハードウェアはノード0からノード4-7上のメモリへのアクセス速度は同じと報告したものの、実際には少し違っていた、というわけです。

NUMAについてもっと知りたいかたに

NUMAについてさらに気になるかたは以下のキーワードで調べてみてください。

  • numactlの--interleaveオプション
  • set_mempolicy()システムコール
  • mbind()システムコール
  • LinuxカーネルのNUMA balancing機能

*1:numactl -Hの実行結果と/proc/cpuinfoの内容("processor"で始まる行とそれに対応する"physical id"で始まる行)を照らし合わせます