🐸

DDR4の読込みレート計測

2022/08/18に公開

0. 概要

C++er向けの高速化ネタで、DDR4の転送レート(読込み)を計測する話です。

最適化が至上命題という、スピード狂向けの記事です。

結論だけ書くと下記です。

  • ピュアなC++の実装だと、最大転送レートの約60%でる
  • _mm256_loadu_si256を使うと、最大転送レートの約68%でる

ピュアなC++で実装するより、intrinsicを使うことで改善効果はありますが、
理論的な最大転送レートまで出すには、+αの工夫が必要そうです。
(そもそも理論的な最大転送レートまで出るのかは未確認です。ごめんなさい)

1. DDR4の最大転送レート

最大転送レートは、下記のように求めます。

転送レート[Byte/sec] = 動作周波数[Hz] * 8[Byte] * チャンネル数

例) DDR4-2400 4chの最大転送レート

76800[MB/sec] = 2400[MHz] * 8[Byte] * 4[ch]

DDR4-XXXXの、XXXXが動作周波数です。
8byteはDDR4のアクセス単位で固定値です。
チャンネル数は、CPUによって異なりますがコンシューマ向けは2か4が多いと思います。

2. 計測プログラム

計測プログラムの例を示します。

#include <cstdio>
#include <cstdint>
#include <cstring>
#include <numeric>
#include <vector>
#include <thread>
#include <intrin.h>

using namespace std;

extern "C" void mem_read_avx_nt(const void *, size_t);

static void mem_read(const void *buf, size_t len)
{
  const volatile uint64_t *b = reinterpret_cast<const volatile uint64_t *>(buf);

  while (len)
  {
    *(b + 0); // read 8byte
    *(b + 1); // read 8byte
    *(b + 2); // read 8byte
    *(b + 3); // read 8byte
    *(b + 4); // read 8byte
    *(b + 5); // read 8byte
    *(b + 6); // read 8byte
    *(b + 7); // read 8byte
    b += 8;
    len -= 64;
  }
}

static void mem_read_avx(const void *buf, size_t len)
{
  const volatile __m256i *b = reinterpret_cast<const volatile __m256i *>(buf);

  while (len)
  {
    *(b + 0); // read 32byte
    *(b + 1); // read 32byte
    b += 2;
    len -= 64;
  }
}

int main(void)
{
  using MemRead = void (*)(const void*, size_t);
  constexpr MemRead MEM_READ[] =
  {
    mem_read,
    mem_read_avx,
    mem_read_avx_nt,
  };
  constexpr size_t READ_SIZE[] =
  {
       16ULL * 1024 * 1024,  //   16MB
       32ULL * 1024 * 1024,  //   32MB
       64ULL * 1024 * 1024,  //   64MB
      128ULL * 1024 * 1024,  //  128MB
      256ULL * 1024 * 1024,  //  256MB
      512ULL * 1024 * 1024,  //  512MB
     1024ULL * 1024 * 1024,  // 1024MB
     2048ULL * 1024 * 1024,  // 2048MB
  };
  constexpr int NUM_THREAD[] =
  {
    1, // 1 thread
    2, // 2 thread
    4, // 4 thread
  };
  constexpr int PTN_FUNC = sizeof(MEM_READ) / sizeof(MemRead);
  constexpr int PTN_READ_SIZE = sizeof(READ_SIZE) / sizeof(size_t);
  constexpr int PTN_THREAD_NUM = sizeof(NUM_THREAD) / sizeof(int);
  constexpr int NUM_ITR = 3;
  constexpr int ALIGNMENT = 64;
  constexpr double CPU_FREQ = 3.5 * 1024 * 1024 * 1024; // i7-7800X 3.5GHz

  double aveTrRate[PTN_FUNC][PTN_THREAD_NUM][PTN_READ_SIZE] = {};

  for (int f = 0; f < PTN_FUNC; ++f)
  {
    for (int t = 0; t < PTN_THREAD_NUM; ++t)
    {
      for (int s = 0; s < PTN_READ_SIZE; ++s)
      {
        char *buf = nullptr;
        const auto rSize = READ_SIZE[s];
        const auto numThread = NUM_THREAD[t];
        vector<double> rRate(NUM_ITR);
        vector<size_t> rSizePerThread(numThread);
        vector<size_t> rOfstPerThread(numThread, 0);

        // スレッド毎の読込みサイズ計算
        for (int i = 0; i < numThread; ++i)
        {
          rSizePerThread[i] = rSize / numThread;
          if (i == numThread - 1)
            rSizePerThread[i] += rSize % numThread; // 端数処理
          if (i > 0)
            rOfstPerThread[i] = rOfstPerThread[i - 1] + rSizePerThread[i];
        }

        // ヒープメモリ確保
        buf = (reinterpret_cast<char *>(_aligned_malloc(rSize, ALIGNMENT)));
        if (!buf)
        {
          printf("***memory allocation err\n");
          exit(1);
        }

        for (int i = 0; i < NUM_ITR; ++i)
        {
          uint64_t beg, end;
          uint32_t tmp;
          double rate = 0.0;
          bool isErr = false;
          vector<thread> trThread;
          trThread.reserve(numThread);

          // キャッシュメモリをフラッシュ
          for (auto j = 0; j < rSize >> 6; ++j)
            _mm_clflush(buf + 64 * j);

          // 転送時間を計測
          beg = __rdtscp(&tmp);
          for (int j = 0; j < numThread; ++j)
          {
            trThread.emplace_back(thread(MEM_READ[f],
                                         buf + rOfstPerThread[j],
                                         rSizePerThread[j]));
          }
          for (int j = 0; j < numThread; ++j)
            trThread[j].join();
          end = __rdtscp(&tmp);

          // 転送レート(MB per second)を記録
          rate = (rSize / (1024 * 1024)) / ((end - beg) / CPU_FREQ);
          rRate.push_back(rate);

          // 結果の検証
          if (isErr)
            printf("*** verify err\n");
        }

        // ヒープメモリ解放
        _aligned_free(buf);

        aveTrRate[f][t][s] =
          accumulate(rRate.begin(), rRate.end(), 0.0) / NUM_ITR;
      }
    }
  }

  printf("%6s %8s %20s %20s %21s\n"
         , "thread"
         , "size[MB]"
         , "mem_read[MB/s]"
         , "mem_read_avx[MB/s]"
         , "mem_read_avx_nt[MB/s]");
  for (int t = 0; t < PTN_THREAD_NUM; ++t)
  {
    for (int s = 0; s < PTN_READ_SIZE; ++s)
    {
      printf("%6d %8llu"
           , NUM_THREAD[t]
           , READ_SIZE[s] / (1024 * 1024));
      for (int f = 0; f < PTN_FUNC; ++f)
        printf(" %21.2f", aveTrRate[f][t][s]);
      printf("\n");
    }
  }
}

mem_read_avx_nt関数は、NASMで実装し下記になっています。
(Windowsのx64専用)

bits    64                                        ; select 64bit asm
default rel                                       ; RIP–relative

SECTION .text

global mem_read_avx
mem_read_avx:
        test      rdx,    rdx
        je        .END_LOOP
        .BEG_LOOP:
        vmovntdqa ymm0,   [rcx +  0]
        vmovntdqa ymm0,   [rcx +  32]
        add       rcx,    64
        sub       rdx,    64
        jnz       .BEG_LOOP
        .END_LOOP:
        ret

プログラムの概要は下記です。

  • ピュアなC++実装のmem_read関数、_mm256_loadu_si256を使ったmem_read_avx関数、_mm256_stream_load_si256を使ったmem_read_avx_nt関数で性能を比較
  • 各関数は、キャッシュライン(64Byte)単位でメモリを読込む
  • 読込みサイズは、16MB~2GBと2次キャッシュでも収まらないサイズに設定
  • スレッド数は、1, 2, 4の3パタンに設定
  • 上記の組合せを各3回ずつ計測

プログラム中のCPU_FREQは、私の環境のCPUの動作周波数なので流用される方はご注意を。

3. 計測結果

Window10, i7-7800X@3.5GHz(物理コア数6), DDR4-2400@4ch 16GBx8, VisualStudio2019 x64ビルドでの実行例です。

thread size[MB]         mem_read[MB/s]   mem_read_avx[MB/s] mem_read_avx_nt[MB/s]
     1       16               9558.36              13529.96              13210.25
     1       32              11719.98              14745.37              13989.32
     1       64              12048.94              14542.21              13498.72
     1      128              12610.48              15266.82              15273.88
     1      256              12657.79              14858.62              15025.43
     1      512              12755.50              15374.47              15108.62
     1     1024              12783.94              15215.40              15008.49
     1     2048              12683.35              15134.38              14140.57
     2       16              18416.47              20764.16              22568.58
     2       32              21681.86              24634.78              25017.47
     2       64              21339.79              27013.29              26866.02
     2      128              23954.56              27892.59              26395.73
     2      256              24436.71              27970.84              28255.63
     2      512              24725.43              28636.16              28686.63
     2     1024              24593.62              28721.49              28801.58
     2     2048              24446.25              28747.50              28712.78
     4       16              26742.91              29073.48              27581.21
     4       32              34550.82              38602.16              37500.95
     4       64              39799.26              45041.57              43719.49
     4      128              42282.87              45325.44              46785.39
     4      256              42789.44              50627.59              48488.50
     4      512              45059.22              51730.96              51295.66
     4     1024              45736.81              52284.70              51887.22
     4     2048              45304.22              52043.01              51822.84

DDR4-2400@4chの理論転送レートは76800[MB/sec]なので、以下の事がわかります。

  • ピュアなC++の実装だと、4threadで1GB書込むときに、最大で理論転送レートの約59.6%(=100 * 45736 / 76800)
  • _mm256_loadu_si256を使うと、4threadで1GB書込むときに、最大で理論転送レートの68.1%(=100 * 52284 / 76800)
  • _mm256_stream_load_si256を使うと、_mm256_loadu_si256とほぼ同じ結果になる

4. まとめ

1スレッドでもピュアなC++の実装よりはSIMD処理した方が高速化できる一方、転送レートの改善効果はスレッド数に比例しない(スレッド数を2倍にしても転送レートは2倍にならない)ことがわかります。

また、メモリ書込みではキャッシュを汚さない専用命令を使うことで、理論的な転送レートになりましたが、メモリ読込処理では専用命令を使っても効果はなく、4スレッドで理論転送レートの68%程度しかでないことがわかります。

キャッシュを汚さない専用命令で読込みしても効果が無い理由は、下記が参考になると思います。
https://web.archive.org/web/20120918010837/http://software.intel.com/en-us/articles/increasing-memory-throughput-with-intel-streaming-simd-extensions-4-intel-sse4-streaming-load/

画像処理のように大容量のメモリ読込を必要とする処理では、転送レートの理論値ではなく実測値と比較することで、演算処理が少ない(メモリボトルネック)か判断する目安になるかと思います。

5. おまけ(計測プログラムの補足)

  • _mm256_loadu_si256を使ってないじゃん
  • なぜポインタをvolatileにキャストしてるの?
  • なぜアセンブラを使ってるの?

と思った方への補足です。

_mm256_loadu_si256関数を明示的に呼び出す記述にはなっていませんが、生成されたアセンブラには、_mm256_loadu_si256(VMOVDQU)が使われていることを確認しています(下記を参照)

_TEXT	SEGMENT
buf$ = 8
len$ = 16
?mem_read_avx@@YAXPEBX_K@Z PROC				; mem_read_avx, COMDAT
	test	rdx, rdx
	je	SHORT $LN3@mem_read_a
	npad	11
$LL2@mem_read_a:
	vmovdqu	ymm0, YMMWORD PTR [rcx]
	vmovdqu	ymm1, YMMWORD PTR [rcx+32]
	lea	rcx, QWORD PTR [rcx+64]
	sub	rdx, 64					; 00000040H
	jne	SHORT $LL2@mem_read_a
	vzeroupper
$LN3@mem_read_a:
	ret	0
?mem_read_avx@@YAXPEBX_K@Z ENDP				; mem_read_avx

各関数で読込先のポインタをvolatileにキャストしてるのは、コンパイラの最適化により読込処理を削除されるのを抑制するためです。

また、volatileにキャストしたポインタは、_mm256_stream_load_si256の引数として利用できないため(コンパイルできないため)、アセンブラを使っています。

Discussion