问题描述
我已经阅读了以大步访问时的内容
for (int i = 0; i < aSize; i++) a[i] *= 3;
for (int i = 0; i < aSize; i += 16) a[i] *= 3;
两个循环的执行应该相似,因为内存访问的顺序高于乘法。
我正在玩谷歌基准测试,在测试类似的缓存行为时,我得到了我不明白的结果。
template <class IntegerType>
void BM_FillArray(benchmark::State& state) {
for (auto _ : state)
{
IntegerType a[15360 * 1024 * 2]; // Reserve array that doesn't fit in L3
for (size_t i = 0; i < sizeof(a) / sizeof(IntegerType); ++i)
benchmark::DoNotOptimize(a[i] = 0); // I have compiler optimizations disabled anyway
}
}
BENCHMARK_TEMPLATE(BM_FillArray,int32_t);
BENCHMARK_TEMPLATE(BM_FillArray,int8_t);
Run on (12 X 3592 MHz CPU s)
CPU Caches:
L1 Data 32 KiB (x6)
L1 Instruction 32 KiB (x6)
L2 Unified 256 KiB (x6)
L3 Unified 15360 KiB (x1)
---------------------------------------------------------------
Benchmark Time CPU Iterations
---------------------------------------------------------------
BM_FillArray<int32_t> 196577075 ns 156250000 ns 4
BM_FillArray<int8_t> 205476725 ns 160156250 ns 4
我希望访问字节数组比访问整数数组更快,因为缓存行中可以容纳更多元素,但事实并非如此。
以下是启用优化后的结果:
BM_FillArray<int32_t> 47279657 ns 47991071 ns 14
BM_FillArray<int8_t> 49374830 ns 50000000 ns 10
有人可以澄清一下吗?谢谢:)
更新 1:
我已经阅读了旧文章“程序员应该了解的内存”,现在一切都更加清楚了。但是,我尝试了以下基准测试:
template <int32_t CacheLineSize>
void BM_ReadArraySeqCacheLine(benchmark::State& state) {
struct CacheLine
{
int8_t a[CacheLineSize];
};
vector<CacheLine> cl;
int32_t workingSetSize = state.range(0);
int32_t arraySize = workingSetSize / sizeof(CacheLine);
cl.resize(arraySize);
const int32_t iterations = 1536 * 1024;
for (auto _ : state)
{
srand(time(NULL));
int8_t res = 0;
int32_t i = 0;
while (i++ < iterations)
{
//size_t idx = i% arraySize;
int idx = (rand() / float(RAND_MAX)) * arraySize;
benchmark::DoNotOptimize(res += cl[idx].a[0]);
}
}
}
BENCHMARK_TEMPLATE(BM_ReadArraySeqCacheLine,1)
->Arg(32 * 1024) // L1 Data 32 KiB(x6)
->Arg(256 * 1024) // L2 Unified 256 KiB(x6)
->Arg(15360 * 1024);// L3 Unified 15360 KiB(x1)
BENCHMARK_TEMPLATE(BM_ReadArraySeqCacheLine,64)
->Arg(32 * 1024) // L1 Data 32 KiB(x6)
->Arg(256 * 1024) // L2 Unified 256 KiB(x6)
->Arg(15360 * 1024);// L3 Unified 15360 KiB(x1)
BENCHMARK_TEMPLATE(BM_ReadArraySeqCacheLine,128)
->Arg(32 * 1024) // L1 Data 32 KiB(x6)
->Arg(256 * 1024) // L2 Unified 256 KiB(x6)
->Arg(15360 * 1024);// L3 Unified 15360 KiB(x1)
当工作大小不适合缓存时,我预计随机访问的性能会更差。但是,结果如下:
BM_ReadArraySeqCacheLine<1>/32768 39936129 ns 38690476 ns 21
BM_ReadArraySeqCacheLine<1>/262144 40822781 ns 39062500 ns 16
BM_ReadArraySeqCacheLine<1>/15728640 58144300 ns 57812500 ns 10
BM_ReadArraySeqCacheLine<64>/32768 32786576 ns 33088235 ns 17
BM_ReadArraySeqCacheLine<64>/262144 32066729 ns 31994048 ns 21
BM_ReadArraySeqCacheLine<64>/15728640 50734420 ns 50000000 ns 10
BM_ReadArraySeqCacheLine<128>/32768 29122832 ns 28782895 ns 19
BM_ReadArraySeqCacheLine<128>/262144 31991964 ns 31875000 ns 25
BM_ReadArraySeqCacheLine<128>/15728640 68437327 ns 68181818 ns 11
我错过了什么?
更新 2:
我现在正在使用您建议的 (linear_congruential_engine) 来生成随机数,并且我只使用静态数组,但结果现在让我更加困惑。
这是更新的代码:
template <int32_t WorkingSetSize,int32_t ElementSize>
void BM_ReadArrayRndCacheLine(benchmark::State& state) {
struct Element
{
int8_t data[ElementSize];
};
constexpr int32_t ArraySize = WorkingSetSize / sizeof(ElementSize);
Element a[ArraySize];
constexpr int32_t iterations = 1536 * 1024;
linear_congruential_engine<size_t,ArraySize/10,ArraySize> lcg; // I've tried with many params...
for (auto _ : state)
{
int8_t res = 0;
int32_t i = 0;
while (i++ < iterations)
{
size_t idx = lcg();
benchmark::DoNotOptimize(res += a[idx].data[0]);
}
}
}
// L1 Data 32 KiB(x6)
// L2 Unified 256 KiB(x6)
// L3 Unified 15360 KiB(x1)
BENCHMARK_TEMPLATE(BM_ReadArrayRndCacheLine,32 * 1024,1);
BENCHMARK_TEMPLATE(BM_ReadArrayRndCacheLine,64);
BENCHMARK_TEMPLATE(BM_ReadArrayRndCacheLine,128);
BENCHMARK_TEMPLATE(BM_ReadArrayRndCacheLine,256 * 1024,15360 * 1024,15360 * 1024 * 4,128);
以下是结果(已启用优化):
// First template parameter is working set size.
// Second template parameter is array elemeent size.
BM_ReadArrayRndCacheLine<32 * 1024,1> 2833786 ns 2823795 ns 249
BM_ReadArrayRndCacheLine<32 * 1024,64> 2960200 ns 2979343 ns 236
BM_ReadArrayRndCacheLine<32 * 1024,128> 2896079 ns 2910539 ns 204
BM_ReadArrayRndCacheLine<256 * 1024,1> 3114670 ns 3111758 ns 236
BM_ReadArrayRndCacheLine<256 * 1024,64> 3629689 ns 3643135 ns 193
BM_ReadArrayRndCacheLine<256 * 1024,128> 3213500 ns 3187189 ns 201
BM_ReadArrayRndCacheLine<15360 * 1024,1> 5782703 ns 5729167 ns 90
BM_ReadArrayRndCacheLine<15360 * 1024,64> 5958600 ns 6009615 ns 130
BM_ReadArrayRndCacheLine<15360 * 1024,128> 5958221 ns 5998884 ns 112
BM_ReadArrayRndCacheLine<15360 * 1024 * 4,1> 6143701 ns 6076389 ns 90
BM_ReadArrayRndCacheLine<15360 * 1024 * 4,64> 5800649 ns 5902778 ns 90
BM_ReadArrayRndCacheLine<15360 * 1024 * 4,128> 5826414 ns 5729167 ns 90
对于 (L1d
即使在尝试从主内存(workingSet > L3)中获取数据时,我的性能也没有大幅下降。你提到最新的架构每个时钟最多可以保持约 8 字节的带宽,但我知道他们必须复制一个保持缓存线,并且如果没有使用可预测的线性模式进行预取,延迟在我的测试中应该更明显......为什么不是这样吗?
我怀疑页面错误和 tlb 可能也有关系。
(我已经下载了 vtune 分析器以试图更好地理解所有这些东西,但它挂在我的机器上,我正在等待支持)
我真的很感谢你的帮助 Peter Cordes :)
我只是一名 GAME 程序员,试图向我的队友展示在我们的代码中使用某些整数类型是否会(或不会)影响我们的游戏性能。例如,我们是否应该担心使用快速类型(例如 int_fast16_t)或在我们的变量中使用尽可能少的字节来更好地打包(例如 int8_t)。
解决方法
Re:终极问题:int_fast16_t
对于数组来说是垃圾,因为 x86-64 上的 glibc 不幸地将它定义为 64 位类型(不是 32 位),因此它浪费了大量的缓存占用空间。问题是“为了什么目的而快速”,显然,glibc 回答了“快速用作数组索引/循环计数器”,即使在某些较旧的 CPU 上进行除法或乘法较慢(在做出选择时是最新的) )。 IMO 这是一个糟糕的设计决定。
- Cpp uint32_fast_t resolves to uint64_t but is slower for nearly all operations than a uint32_t (x86_64). Why does it resolve to uint64_t?
- How should the [u]int_fastN_t types be defined for x86_64,with or without the x32 ABI?
- Why are the fast integer types faster than the other integer types?
通常对数组使用小整数类型是好的;通常缓存未命中是一个问题,因此减少占用空间是很好的,即使这意味着使用 movzx
或 movsx
加载而不是内存源操作数将其与 int
或 {{1 }} 32 位本地。如果 SIMD 成为可能,那么每个固定宽度向量拥有更多元素意味着您可以在每条指令中完成更多工作。
但不幸的是,unsigned
不会帮助您通过某些库实现这一目标,但 int_fast16_t
会或 short
。
请参阅我在问题下的评论以获取早期部分的答案:200 个停顿周期是延迟,而不是吞吐量。硬件预取和内存级并行隐藏了这一点。 Modern Microprocessors - A 90 Minute Guide! 非常好,并且有一个关于内存的部分。另请参阅 What Every Programmer Should Know About Memory?,其中 在 2021 年仍然高度相关。(除了一些关于预取线程的内容。)
您的 Update 2 具有更快的 PRNG
Re:为什么 L2 不比 L1 慢:乱序执行足以隐藏 L2 延迟,甚至您的 LGC 也太慢而无法强调 L2 吞吐量。很难以足够快的速度生成随机数,从而给可用的内存级并行带来很多麻烦。
您的 Skylake 派生 CPU 具有 97 uops 的乱序调度程序 (RS),以及 224 uops 的 ROB 大小(类似于 https://realworldtech.com/haswell-cpu/3,但更大)和 12 个 LFB 来跟踪它正在等待的缓存行为了。只要 CPU 可以跟踪足够的动态负载(延迟 * 带宽),就不必去 L2 了。隐藏缓存未命中的能力是首先衡量无序窗口大小的一种方法:https://blog.stuffedcow.net/2013/05/measuring-rob-capacity
L2 命中的延迟为 12 个周期 (https://www.7-cpu.com/cpu/Skylake.html)。 Skylake 每个时钟可以从 L1d 缓存加载 2 次,但不能从 L2 加载。 (它不能维持每个时钟 IIRC 1 个缓存线,但每 2 个时钟 1 个甚至更好一点是可行的)。
您的 LCG RNG 会在其延迟上限制您的循环:2 次幂数组大小为 5 个周期,或者像您的“L3”测试尝试这样的非 2 次幂大小为 13 个周期1。因此,这大约是 L1d 可以处理的访问速率的 1/10,即使每次访问都错过了 L1d 但在 L2 中命中,您甚至不会从 L2 保持一个以上的飞行负载。 OoO exec + 加载缓冲区甚至不会出汗。所以 L1d 和 L2 将具有相同的速度,因为它们都使用 2 的幂数组大小。
注意 1:int_least16_t
的 imul(3c) + add(1c),然后 x = a * x + c
使用 a multiplicative inverse,可能是 remainder = x - (x/m * m)
(size_t 的上半部分为 4 个周期? ) + shr(1) + imul(3c) + sub(1c)。或者对于 2 的幂大小,模只是与 mul
之类的常数。
显然我的估计不太正确因为您的非 2 次幂数组小于 L1d / L2 的两倍,而不是我的估计会预测的 13/5,即使L3 延迟/带宽不是一个因素。
在展开的循环中运行多个独立的 LCG 可能会有所作为。 (使用不同的种子。)但是 LCG 的非 2 次幂 (1UL<<n) - 1
仍然意味着相当多的指令,因此您会遇到 CPU 前端吞吐量(和后端执行端口,特别是乘法器)的瓶颈).
具有乘数 (m
) = a
的 LCG 可能只是一个足够大的步幅,硬件预取器无法从锁定它中获益。但通常 IIRC 你想要一个大的奇数或其他东西(自从我查看 LCG 参数选择的数学以来已经有一段时间了),否则你可能只接触有限数量的数组元素,最终不会覆盖它们。 (您可以通过将 ArraySize/10
存储到随机循环中的每个数组元素来测试,然后计算有多少数组元素被触及,即通过对数组求和,如果其他元素为 0。)
1
和 a
绝对应该不都是 c
的因数,否则您每次都访问相同的 10 个缓存行以排除其他一切。
正如我之前所说,击败硬件预取不需要太多随机性。带有 m
、c=0
一个奇数(可能是质数)和 a=
的 LCG 可能很好,实际上只是一个 m=UINT_MAX
。您可以分别对每个 LCG 结果对数组大小求模,从而使该操作脱离关键路径。在这一点上,您不妨将标准库排除在外,实际上只是 imul
开始,unsigned rng = 1;
作为您的更新步骤。然后使用 rng *= 1234567;
。
这比使用 xorshift+ 或 xorshft* 所做的任何事情都便宜。
基准缓存延迟:
您可以生成一个随机 arr[rng % arraysize]
或 uint16_t
索引数组一次(例如在静态初始化程序或构造函数中)并重复循环,访问另一个数组职位。这将交错顺序访问和随机访问,并使代码可能在每个时钟执行 2 次加载并带有 L1d 命中,尤其是在您使用 uint32_t
的情况下。 (使用 gcc -O3 -funroll-loops
它可能会使用 AVX2 收集指令自动矢量化,但仅适用于 32 位或更宽的元素,因此如果您想排除仅来自从以下位置获取索引的混杂因素,请使用 -march=native
一个数组。)
为了测试缓存/内存延迟,通常的技术是在数组周围创建随机分布的链表。遍历列表,下一个加载可以在前一个加载完成(但不是之前)完成后开始。因为一个依赖另一个。这称为“负载使用延迟”。另请参阅 Is there a penalty when base+offset is in a different page than the base? 以了解英特尔 CPU 用来乐观地加速此类工作负载的技巧(4 周期 L1d 延迟情况,而不是通常的 5 周期)。半相关:PyPy 17x faster than Python. Can Python be sped up? 是另一个依赖于指针追踪延迟的测试。