请考虑以下最小示例minimal.cpp
(https://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);
}
它将int64_t
的input
数组转换为给定batch_size
的一批double
的输出数组。我们插入了以下AVX-512内部函数,以防输入中仍有大于或等于8个元组,从而一次处理所有元组,从而提高的性能
auto _loaded = _mm512_loadu_epi64(&(*input)[idx]);
auto _converted = _mm512_cvtepu64_pd(_loaded);
_mm512_storeu_epi64(&(*output)[idx], _converted);
否则,我们将回到标量实现。
为了确保编译器不会折叠这两个循环,我们使用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
然而,执行代码会产生以下令人惊讶的输出
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
它表明,由于某种原因,使用batch_size
为8时,代码明显较慢。然而,使用batch_size
为7或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%)
批量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
批量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%)
更新:测试(见注释)显示错位是而不是的解释,并且以某种方式将数组对齐64会使其变慢。我预计不会出现任何4k混叠问题,因为我们正在加载并然后存储,并且大的对齐分配可能相对于页面边界具有相同的对齐方式。即是相同的% 4096
,可能为0。即使在将循环简化为不使用短的内部循环进行太多分支之后,情况也是如此。
您的数组很大,并且没有按64对齐,因为您让std::vector<>
分配它们。使用64字节矢量,每个未对齐的加载将跨越两个64字节缓存行之间的边界。(你会在每4k页结束时被页面分割绊倒,尽管这在顺序访问中很少见,无法解释这一点。)与32字节加载/存储不同,在32字节加载和存储中,只有每隔一个矢量都会被缓存线分割。
(对于大的分配,Glibc的malloc
/new
通常保留前16个字节用于记账,因此它返回的地址是页面开始后的16个字节,总是偏移32和64,总是造成最坏的情况。)
已知512位矢量(至少在Skylake/Cascade Lake上)会因64字节加载/存储不对齐而减慢速度(超过32字节操作不对齐的AVX1/2代码)。即使阵列如此之大,以至于您预计它只会成为DRAM带宽的瓶颈,并且在等待缓存线到达时,有时间解决内核内部的任何未对准问题。
大型Xeon上的单核DRAM带宽与"Xeon"相比相当低;客户端";CPU,特别适用于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),假设编译后与源代码匹配。
我不知道为什么;负载执行单元可能用完了用于处理需要来自两个缓存行的数据的负载的拆分缓冲区。(Perf事件ld_blocks.no_sr
)。但是标量加载不需要分割缓冲区条目,因为它们总是自然对齐的(8字节)。因此,如果调度,它们可以执行,可能会更快地触发缓存行的提取。
(硬件预取无法跨越物理内存可能不连续的4k页面边界;L2流媒体只看到物理地址。因此,对下一个4k页面的需求加载可以使硬件预取提前启动,从而最大限度地增加到L2的DRAM带宽,如果以后没有进行拆分矢量加载,可能就不会发生这种情况。即使使用2M透明护垫,4k边界也适用;硬件预取器不会被告知回迁是一个连续的hugepage的一部分。)
Batch=9还使每八个矢量中就有一个对齐,这可能会略有帮助。
这些都是对微观结构原因的胡乱猜测,没有任何测试这些假设的性能实验支持
使用对齐缓冲区进行测试
如果您至少想测试它的错位是造成整个事件的原因,那么可以考虑为std::vector<int64_t, my_aligned_allocator>
和/或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*
指向__m512d
的行为,因为它恰好是根据double
定义的,但也有将int*
指向__m256i
的例子,但并没有像希望的那样工作。对于性能实验,你可以检查asm,看看它是否正常。
(此外,您还需要检查编译器是否展开了内部循环,而不是在循环内进行分支。)
或者使用aligned_alloc
而不是std::vector
来获取原始存储。但是,您需要自己写入这两个数组,以避免页面错误成为第一个测试的定时区域的一部分,就像std::vector
的构造函数所做的那样。(性能评估的惯用方法?)(当您不想在SIMD循环之前写入内存时,std::vector
是很烦人的,因为使用.emplace_back
对SIMD内部来说是一种痛苦。更不用说它在增长方面很糟糕,因为在大多数C++实现中无法使用realloc
来避免复制。)
或者不写init循环或memset
,而是做一个预热过程?无论如何,对于AVX-512来说,确保512位执行单元预热是个好主意,并且CPU处于能够以所需的低吞吐量运行512位FP指令的频率。(SIMD指令降低CPU频率)
(也许__attribute__((noinline,noipa))
在do_benchmark
上,尽管我不认为Clang知道GCC的noipa
属性=没有过程间分析。)