为什么使用 AVX-512 指令转换数组时,与 7 或 9 个批次相比,以 8 个批次进行转换时要慢得多?

Inv*_*ost 7 c++ performance benchmarking clang avx512

请考虑以下最小示例minimal.cpphttps://godbolt.org/z/x7dYes91M)。

#include <immintrin.h>

#include <algorithm>
#include <ctime>
#include <iostream>
#include <numeric>
#include <vector>

#define NUMBER_OF_TUPLES 134'217'728UL

void transform(std::vector<int64_t>* input, std::vector<double>* output, size_t batch_size) {
  for (size_t startOfBatch = 0; startOfBatch < NUMBER_OF_TUPLES; startOfBatch += batch_size) {
    size_t endOfBatch = std::min(startOfBatch + batch_size, NUMBER_OF_TUPLES);

    for (size_t idx = startOfBatch; idx < endOfBatch;) {
      if (endOfBatch - idx >= 8) {
        auto _loaded = _mm512_loadu_epi64(&(*input)[idx]);
        auto _converted = _mm512_cvtepu64_pd(_loaded);

        _mm512_storeu_epi64(&(*output)[idx], _converted);
        idx += 8;
      } else {
        (*output)[idx] = static_cast<double>((*input)[idx]);
        idx++;
      }
    }

    asm volatile("" : : "r,m"(output->data()) : "memory");
  }
}

void do_benchmark(size_t batch_size) {
  std::vector<int64_t> input(NUMBER_OF_TUPLES);
  std::vector<double> output(NUMBER_OF_TUPLES);

  std::iota(input.begin(), input.end(), 0);

  auto t = std::clock();
  transform(&input, &output, batch_size);
  auto elapsed = std::clock() - t;

  std::cout << "Elapsed time for a batch size of " << batch_size << ": " << elapsed << std::endl;
}

int main() {
  do_benchmark(7UL);
  do_benchmark(8UL);
  do_benchmark(9UL);
}
Run Code Online (Sandbox Code Playgroud)

它将input的 数组 批量转换为给定 的int64_t输出数组。我们插入了以下 AVX-512 内在函数,以防输入中仍然存在大于或等于 8 个元组,以便一次处理所有元组,从而提高性能doublebatch_size

auto _loaded = _mm512_loadu_epi64(&(*input)[idx]);
auto _converted = _mm512_cvtepu64_pd(_loaded);
_mm512_storeu_epi64(&(*output)[idx], _converted);
Run Code Online (Sandbox Code Playgroud)

否则,我们将退回到标量实现。

为了确保编译器不会崩溃两个循环,我们使用asm volatile("" : : "r,m"(output->data()) : "memory")调用来确保在每个批次之后刷新输出数据。

Intel(R) Xeon(R) Gold 5220R CPU它是在使用上编译和执行的

clang++ -Wall -Wextra -march=cascadelake -mavx512f -mavx512cd -mavx512vl -mavx512dq -mavx512bw -mavx512vnni -O3 minimal.cpp -o minimal
Run Code Online (Sandbox Code Playgroud)

然而,执行代码会产生以下令人惊讶的输出

Elapsed time for a batch size of 7: 204007
Elapsed time for a batch size of 8: 237600
Elapsed time for a batch size of 9: 209838
Run Code Online (Sandbox Code Playgroud)

它表明,由于某种原因,使用 a batch_sizeof 8 时,代码明显变慢。然而,两者使用batch_size7 或 9 的速度要快得多。

这让我感到惊讶,因为批量大小为 8 应该是完美的配置,因为它只需要使用 AVX-512 指令并且总是可以一次完美地处理 64 字节。但为什么这个例子的速度慢得多呢?

编辑:

添加了perf缓存未命中的结果

批量大小 7

 Performance counter stats for process id '653468':

     6,894,467,363      L1-dcache-loads                                               (44.43%)
     1,647,244,371      L1-dcache-load-misses     #   23.89% of all L1-dcache accesses  (44.43%)
     7,548,224,648      L1-dcache-stores                                              (44.43%)
         6,726,036      L2-loads                                                      (44.43%)
         3,766,847      L2-loads-misses           #   56.61% of all LL-cache accesses  (44.46%)
         6,171,407      L2-loads-stores                                               (44.45%)
         6,764,242      LLC-loads                                                     (44.46%)
         4,548,106      LLC-loads-misses          #   68.35% of all LL-cache accesses  (44.46%)
         6,954,088      LLC-loads-stores                                              (44.45%)
Run Code Online (Sandbox Code Playgroud)

批量大小 8

 Performance counter stats for process id '654880':

     1,009,889,247      L1-dcache-loads                                               (44.41%)
     1,413,152,123      L1-dcache-load-misses     #  139.93% of all L1-dcache accesses  (44.45%)
     1,528,453,525      L1-dcache-stores                                              (44.48%)
       158,053,929      L2-loads                                                      (44.51%)
       155,407,942      L2-loads-misses           #   98.18% of all LL-cache accesses  (44.50%)
       158,335,431      L2-loads-stores                                               (44.46%)
       158,349,901      LLC-loads                                                     (44.42%)
       155,902,630      LLC-loads-misses          #   98.49% of all LL-cache accesses  (44.39%)
       158,447,095      LLC-loads-stores                                              (44.39%)

      11.011153400 seconds time elapsed
Run Code Online (Sandbox Code Playgroud)

批量大小 9

 Performance counter stats for process id '656032':

     1,766,679,021      L1-dcache-loads                                               (44.38%)
     1,600,639,108      L1-dcache-load-misses     #   90.60% of all L1-dcache accesses  (44.42%)
     2,233,035,727      L1-dcache-stores                                              (44.46%)
       138,071,488      L2-loads                                                      (44.49%)
       136,132,162      L2-loads-misses           #   98.51% of all LL-cache accesses  (44.52%)
       138,020,805      L2-loads-stores                                               (44.49%)
       138,522,404      LLC-loads                                                     (44.45%)
       135,902,197      LLC-loads-misses          #   98.35% of all LL-cache accesses  (44.42%)
       138,122,462      LLC-loads-stores                                              (44.38%)
Run Code Online (Sandbox Code Playgroud)

Pet*_*des 1

更新:测试(参见评论)显示未对齐并不是原因,并且以某种方式将数组对齐 64 会使速度变慢。我不希望出现任何 4k 别名问题,因为我们正在加载然后存储,并且大型对齐分配可能相对于页面边界具有相同的对齐方式。即是相同的% 4096,可能是 0。即使在简化循环以不使用短内循环进行如此多的分支之后也是如此。


你的数组很大并且没有按 64 对齐,因为你让std::vector<>它们分配。使用 64 字节向量,每个未对齐的负载都将跨越两个 64 字节缓存线之间的边界。(你会在每个 4k 页面末尾遇到页面分割,尽管这种情况在顺序访问中很少见,无法解释这一点。)与 32 字节加载/存储不同,其中只有每个其他向量都是缓存 -线分裂。

(Glibc 的malloc/new用于大型分配,通常保留前 16 个字节进行簿记,因此它返回的地址是页面开头之后的 16 个字节,总是错位 32 和 64,总是会产生最坏的情况。)

众所周知,512 位向量(至少在 Skylake/Cascade Lake 上)会因未对齐的 64 字节加载/存储而减慢速度(超过 AVX1/2 代码与未对齐的 32 字节操作)。即使阵列太大,您预计它只会成为 DRAM 带宽的瓶颈,并且有时间在等待缓存行到达时解决内核内部的任何未对齐问题。

与“客户端”CPU 相比,大型 Xeon 上的单核 DRAM 带宽相当低,尤其是对于 Skylake 系列而言。(网状互连在这一代中是新的,并且低于 Broadwell Xeon。显然,Ice Lake Xeon 对最大每核 DRAM 带宽进行了重大改进。)因此,即使是标量代码也能够使内存带宽饱和。

(或者也许batch=7-mprefer-vector-width=256在完全展开内部循环后自动矢量化?不,它甚至没有内联你的循环,并且没有将该循环取消切换到while(full vector left) vector;/ while(any left) scalar;,所以你有非常讨厌的asm,它为每个向量和标量。)

但出于某种原因,使用 64 字节加载和存储的代码无法最大化一个核心的带宽。但你的实验表明,即使是 1 个向量 + 1 个标量的模式也有帮助(batch=9),假设编译为与源匹配。

我不知道为什么;也许加载执行单元耗尽了用于处理需要来自两个高速缓存线的数据的加载的分割缓冲区。(性能事件ld_blocks.no_sr)。但标量加载不需要分割缓冲区条目,因为它们总是自然对齐(至 8 字节)。因此,如果被调度,它们就可以执行,也许会更快地触发缓存行的获取。

(硬件预取无法跨 4k 页面边界工作,因为物理内存可能不连续;L2 流媒体只能看到物理地址。因此,请求加载到下一个 4k 页面可以让硬件预取尽早启动,从而最大限度地利用 L2 的 DRAM 带宽,如果后来没有发生分割向量加载,则可能不会发生这种情况。即使使用 2M 透明大页,4k 边界也适用;硬件预取器不会被告知提取是连续大页的一部分。)

Batch=9 还会使每八个向量之一对齐,这可能会略有帮助。

这些都是对微架构原因的疯狂猜测,没有任何测试这些假设的性能实验的支持。


使用对齐的缓冲区进行测试

如果您至少想测试一下是否是整个事情的错位造成的,请考虑使用std::vector<int64_t, my_aligned_allocator>and/or的自定义分配器std::vector<double, my_aligned_allocator>。(使 std::vector 分配对齐内存的现代方法)。对于生产使用来说,这是一个很好的选择,因为它的工作方式与 相同std::vector<int64_t>,尽管第二个模板参数使其类型不兼容。

为了进行快速实验,请制作它们std::vector<__m512i>和/或<__m512d>更改循环代码。(并至少使用 C++17 进行编译,以使标准库尊重alignof(T)。)(有助于查看源或目标未对齐是否是关键因素,或两者兼而有之。)对于batch = 8,您可以直接循环向量。在一般情况下,如果您想以这种方式进行测试,则需要static_cast<char*>(src->data())进行适当的指针数学运算。GNU C可能会double*定义将 an指向 a的行为__m512d,因为它恰好是根据 定义的double,但也有一些将 an 指向int*a的例子__m256i并没有按预期工作。对于性能实验,您只需检查 asm 并查看它是否正常。

(此外,您还想检查编译器是否展开了该内部循环,而不是实际在循环内分支。)

或者使用aligned_alloc获取原始存储而不是std::vector. 但是,您需要自己写入两个数组,以避免页面错误成为第一个测试的定时区域的一部分,就像 的std::vector构造函数那样。(性能评估的惯用方式?)(当你不想在 SIMD 循环之前写入内存时,std::vector这很烦人,因为使用SIMD 内在函数是一种痛苦。更不用说它在增长方面很糟糕,无法在大多数 C++ 实现中使用有时可以避免复制。).emplace_backrealloc

或者不编写 init 循环或memset,而是进行预热?无论如何,对于 AVX-512 来说,确保 512 位执行单元预热,并且 CPU 处于能够以所需的较低吞吐量运行 512 位 FP 指令的频率是个好主意。(SIMD指令降低CPU频率

(也许__attribute__((noinline,noipa))在 上do_benchmark,尽管我认为 Clang 不知道 GCC 的noipa属性 = 没有过程间分析。)