DDR4の読込みレート計測
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%程度しかでないことがわかります。
キャッシュを汚さない専用命令で読込みしても効果が無い理由は、下記が参考になると思います。
画像処理のように大容量のメモリ読込を必要とする処理では、転送レートの理論値ではなく実測値と比較することで、演算処理が少ない(メモリボトルネック)か判断する目安になるかと思います。
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