1

我在大步访问时读过

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/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, 32 * 1024, 64);
BENCHMARK_TEMPLATE(BM_ReadArrayRndCacheLine, 32 * 1024, 128);

BENCHMARK_TEMPLATE(BM_ReadArrayRndCacheLine, 256 * 1024, 1);
BENCHMARK_TEMPLATE(BM_ReadArrayRndCacheLine, 256 * 1024, 64);
BENCHMARK_TEMPLATE(BM_ReadArrayRndCacheLine, 256 * 1024, 128);

BENCHMARK_TEMPLATE(BM_ReadArrayRndCacheLine, 15360 * 1024, 1);
BENCHMARK_TEMPLATE(BM_ReadArrayRndCacheLine, 15360 * 1024, 64);
BENCHMARK_TEMPLATE(BM_ReadArrayRndCacheLine, 15360 * 1024, 128);

BENCHMARK_TEMPLATE(BM_ReadArrayRndCacheLine, 15360 * 1024 * 4, 1);
BENCHMARK_TEMPLATE(BM_ReadArrayRndCacheLine, 15360 * 1024 * 4, 64);
BENCHMARK_TEMPLATE(BM_ReadArrayRndCacheLine, 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 < L2) 结果与 (workingSet < L1d) 的结果有何不同?L2 的吞吐量和延迟仍然​​非常高,但是通过随机访问,我试图防止预取和强制缓存未命中.. 那么,为什么我什至没有注意到最小增量?

即使在尝试从主内存(workingSet > L3)中获取数据时,我的性能也没有大幅下降。你提到最新的架构可以保持高达每时钟 8 字节的带宽,但我知道他们必须复制一个保持缓存行,并且如果没有使用可预测的线性模式预取,延迟应该在我的测试中更加明显......为什么是不是这样?

我怀疑页面错误和 tlb 也可能有问题。

(我已经下载了 vtune 分析器来尝试更好地理解所有这些东西,但它挂在我的机器上,我一直在等待支持)

我真的很感谢你的帮助 Peter Cordes :)

我只是一个游戏程序员,试图向我的队友展示在我们的代码中使用某些整数类型是否会(或不会)对我们的游戏性能产生影响。例如,我们是否应该担心使用快速类型(例如 int_fast16_t)或在变量中使用尽可能少的字节以便更好地打包(例如 int8_t)。

4

1 回答 1

2

回复:最终的问题:int_fast16_t数组是垃圾,因为 x86-64 上的 glibc 不幸地将其定义为 64 位类型(不是 32 位),因此它浪费了大量的缓存占用空间。问题是“快速用于什么目的”,并且 glibc 回答“快速用作数组索引/循环计数器”,显然,即使在某些较旧的 CPU 上除法或乘法较慢(在做出选择时这些 CPU 是当前的) )。IMO 这是一个糟糕的设计决定。

通常对数组使用小整数类型好的;通常缓存未命中是一个问题,因此即使这意味着使用movzxormovsx加载而不是内存源操作数来将其与int32unsigned位本地一起使用,也可以减少占用空间。如果 SIMD 是可能的,那么每个固定宽度向量有更多元素意味着您可以在每条指令中完成更多工作。

但不幸int_fast16_t的是,它不会帮助您使用某些库来实现这一目标,但short会或int_least16_t.


有关早期部分的答案,请参阅我在问题下的评论:200 个停顿周期是延迟,而不是吞吐量。硬件预取和内存级并行性隐藏了这一点。现代微处理器 - 90 分钟指南!非常好,并且有一个关于记忆的部分。另请参阅每个程序员应该了解的关于内存的知识?这在 2021 年仍然非常重要。(除了一些关于预取线程的东西。)


您的更新 2 具有更快的 PRNG

回复:为什么 L2 不比 L1 慢:乱序执行足以隐藏 L2 延迟,甚至您的 LGC 也太慢而无法强调 L2 吞吐量。很难以足够快的速度生成随机数,从而给可用的内存级并行性带来很多麻烦。

您的 Skylake 派生 CPU 具有 97 微指令的乱序调度程序 (RS),ROB 大小为 224 微指令(如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”测试尝试1这样的非 2 幂大小的大小来说,更像是 13 个周期。所以这大约是 L1d 可以处理的访问速率的 1/10,即使每次访问都未命中 L1d 但在 L2 中命中,您甚至不会从 L2 保持超过一个负载。OoO exec + 加载缓冲区甚至不会出汗。因此 L1d 和 L2 将具有相同的速度,因为它们都使用 2 的幂数组大小。

注 1:imul(3c) + add(1c) for x = a * x + c,然后remainder = x - (x/m * m)使用乘法逆,可能mul(size_t 的高半部分为 4 个周期?) + shr(1) + imul(3c) + sub(1c)。或者对于 2 的幂大小,模只是 AND 与常数 like (1UL<<n) - 1

显然,我的估计并不完全正确,因为您的非 2 次幂阵列小于 L1d / L2 的两倍,而不是我估计的 13/5,即使 L3 延迟/带宽不是一个因素。

在展开的循环中运行多个独立的 LCG 可能会有所作为。(使用不同的种子。)但是 LCG 的非 2 次幂m仍然意味着相当多的指令,因此您将成为 CPU 前端吞吐量(和后端执行端口,特别是乘法器)的瓶颈。

具有乘数 ( a) =的 LCGArraySize/10可能只是一个足够大的步幅,硬件预取器不会从锁定它中受益匪浅。但通常 IIRC 你想要一个大的奇数或其他东西(自从我查看 LCG 参数选择的数学以来已经有一段时间了),否则你可能只接触有限数量的数组元素,而不是最终覆盖它们。(您可以通过将 a 存储1到随机循环中的每个数组元素来测试这一点,然后计算有多少数组元素被触及,即通过对数组求和,如果其他元素为 0。)

a并且c绝对应该都是 的因素m,否则您每次都访问相同的 10 个缓存行,而排除其他所有内容。

正如我之前所说,击败硬件预取并不需要太多随机性。一个带有 的 LCG c=0a=一个奇数,可能是质数,并且m=UINT_MAX可能很好,实际上只是一个imul. 您可以分别对每个 LCG 结果的阵列大小取模,从而使该操作脱离关键路径。在这一点上,您不妨将标准库排除在外,而实际上只是unsigned rng = 1;开始,并rng *= 1234567;作为您的更新步骤。然后使用arr[rng % arraysize].

这比使用 xorshift+ 或 xorshft* 做的任何事情都便宜。


基准缓存延迟:

可以一次生成一个随机数组uint16_tuint32_t索引数组(例如在静态初始化程序或构造函数中)并重复循环,在这些位置访问另一个数组。这将交错顺序访问和随机访问,并使代码可能在 L1d 命中时每个时钟执行 2 次加载,特别是如果您使用gcc -O3 -funroll-loops. (-march=native它可能会使用 AVX2 收集指令自动矢量化,但仅适用于 32 位或更宽的元素,因此-fno-tree-vectorize如果您想排除仅来自从数组中获取索引的混杂因素,请使用。)

为了测试缓存/内存延迟,通常的技术是在数组周围创建具有随机分布的链表。遍历列表,下一个加载可以在前一个加载完成后(但不是之前)开始。因为一个依赖另一个。这称为“负载使用延迟”。另请参阅当基数+偏移量与基数位于不同页面时是否会受到惩罚?对于英特尔 CPU 用来乐观地加速此类工作负载的技巧(4 周期 L1d 延迟情况,而不是通常的 5 周期)。半相关:PyPy 比 Python 快 17 倍。Python可以加速吗?是另一个依赖于指针追踪延迟的测试。

于 2021-05-04T13:50:36.973 回答