🐸

DDR4の書込みレート計測

2022/08/14に公開約7,200字

0. 概要

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

最適化が至上命題という、スピード狂向けの記事です。
メモリボトルネックでお困りの方にも有用かもと思います。

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

  • memsetを使うと、最大転送レートの約58%しかでない
  • _mm256_stream_si256を使うと、最大転送レートのほぼ100%でる

マルチスレッドかつ、大容量のメモリ書込み処理でmemsetを使ってる人は、_mm256_stream_si256を使うと処理速度が改善するかもしれません

1. DDR4の最大転送レート

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

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

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

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

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;

/*!
 * \note 以下の条件に特化してるので注意
 *  - 引数のbufは、32byteにアライメント
 *  - 引数のsizeは、128byteの倍数
 */
void *memset_nt(void *buf, int val, size_t size)
{
  const size_t lenDiv32 = size >> 5;
  const char v = static_cast<char>(val);
  __m256i *buf256;
  __m256i ymm;

  ymm = _mm256_set1_epi8(v);
  buf256 = reinterpret_cast<__m256i *>(buf);

  for (size_t i = 0; i < lenDiv32; i += 2)
  {
    _mm256_stream_si256(&buf256[i + 0], ymm);
    _mm256_stream_si256(&buf256[i + 1], ymm);
  }

  return buf;
}

int main(void)
{
  using Memset = void* (*)(void*, int, size_t);
  constexpr Memset MEME_SET[] =
  {
    memset,
    memset_nt,
  };
  constexpr size_t WRITE_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(MEME_SET) / sizeof(Memset);
  constexpr int PTN_WRITE_SIZE = sizeof(WRITE_SIZE) / sizeof(size_t);
  constexpr int PTN_THREAD_NUM = sizeof(NUM_THREAD) / sizeof(int);
  constexpr int NUM_ITR = 3;
  constexpr int MEMSET_VAL = 255;
  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_WRITE_SIZE] = {};

  for (int f = 0; f < PTN_FUNC; ++f)
  {
    for (int t = 0; t < PTN_THREAD_NUM; ++t)
    {
      for (int s = 0; s < PTN_WRITE_SIZE; ++s)
      {
        char *buf = nullptr;
        const auto wSize = WRITE_SIZE[s];
        const auto numThread = NUM_THREAD[t];
        vector<double> wRate(NUM_ITR);
        vector<size_t> wSizePerThread(numThread);
        vector<size_t> wOfstPerThread(numThread, 0);

        // スレッド毎の書込みサイズ計算
        for (int i = 0; i < numThread; ++i)
        {
          wSizePerThread[i] = wSize / numThread;
          if (i == numThread - 1)
            wSizePerThread[i] += wSize % numThread;
          if (i > 0)
            wOfstPerThread[i] = wOfstPerThread[i - 1] + wSizePerThread[i];
        }

        // ヒープメモリ確保
        buf = (reinterpret_cast<char *>(_aligned_malloc(wSize, 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 < wSize >> 6; ++j)
            _mm_clflush(buf + 64 * j);

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

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

          // 結果の検証
          for (auto j = 0; j < wSize; ++j)
          {
            if (buf[j] != static_cast<char>(MEMSET_VAL))
              isErr = true;
          }
          if (isErr)
            printf("*** verify err\n");
        }

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

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

  printf("%6s %8s %16s %16s\n"
         , "thread"
         , "size[MB]"
         , "memset[MB/s]"
         , "memset_nt[MB/s]");
  for (int t = 0; t < PTN_THREAD_NUM; ++t)
  {
    for (int s = 0; s < PTN_WRITE_SIZE; ++s)
    {
      printf("%6d %8llu"
           , NUM_THREAD[t]
           , WRITE_SIZE[s] / (1024 * 1024));
      for (int f = 0; f < PTN_FUNC; ++f)
        printf(" %16.2f", aveTrRate[f][t][s]);
      printf("\n");
    }
  }
}

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

  • 標準ライブラリのmemsetと_mm256_stream_si256を使ったmemset_nt関数を比較
  • 書込みサイズは、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]     memset[MB/s]  memset_nt[MB/s]
     1       16         11345.33         17712.22
     1       32         19247.36         20267.48
     1       64         21254.95         21347.63
     1      128         22490.71         22361.12
     1      256         22549.88         22171.41
     1      512         23041.12         22871.81
     1     1024         23319.27         22608.92
     1     2048         23151.53         23049.48
     2       16         25314.78         26894.04
     2       32         31644.99         35528.00
     2       64         36194.49         39132.16
     2      128         39206.65         41527.68
     2      256         40618.50         42692.81
     2      512         41489.82         43097.01
     2     1024         42161.47         43568.82
     2     2048         42039.94         44200.75
     4       16         27814.67         32413.75
     4       32         35470.41         48683.81
     4       64         38428.65         59310.47
     4      128         41696.34         68718.82
     4      256         42771.21         74161.52
     4      512         43756.21         76315.55
     4     1024         44088.75         76879.00
     4     2048         44320.05         77849.70

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

  • memsetでは、4threadで2GB書込むときに、最大で理論転送レートの約57.7%(=100 * 44320 / 76800)
  • memset_ntでは、4threadで2GB書込むときに、最大で理論転送レートの101.4%(=100 * 77849 / 76800)
  • memsetは、スレッド数を2->4にしても転送レートは2倍にならない
  • _mm256_stream_si256は、キャッシュを汚さない書込み命令なのでマルチスレッドで書込みサイズが大きい程、転送レートの改善効果が高そう

4. まとめ

  • memsetでは、4スレッド使っても最大で最大転送レートの約57.7%(=100 * 44320 / 76800)しか出ないことがわかりました
  • _mm256_stream_si256を使うと、4スレッドで最大転送レートが出てることもわかりました
  • 一方、シングルスレッド処理ではmemsetと_mm256_stream_si256に大きな差はなかったため、マルチスレッドでかつ大きなサイズのメモリ書込み時に、memsetを使ってる処理では高速化できる可能性がありそうです
  • _mm256_stream_si256を使うには、32バイトにアラインメントされたメモリ領域でないといけませんので、非アラインメント領域や32バイト未満の端数領域には別の処理が必要です

Discussion

ログインするとコメントできます