[英]Counting differences between 2 buffers seems too slow
我有 2 个相同大小的相邻字节缓冲区(每个缓冲区大约 20 MB)。 我只想数一数它们之间的差异。
在配备 3600MT RAM 的 4.8GHz Intel I7 9700K 上运行此循环需要多长时间?
我们如何计算最大理论速度?
uint64_t compareFunction(const char *const __restrict buffer, const uint64_t commonSize)
{
uint64_t diffFound = 0;
for(uint64_t byte = 0; byte < commonSize; ++byte)
diffFound += static_cast<uint64_t>(buffer[byte] != buffer[byte + commonSize]);
return diffFound;
}
在我的 PC (9700K 4.8Ghz RAM 3600 Windows 10 Clang 14.0.6 -O3 MinGW ) 上需要 11 毫秒,我觉得它太慢了,我错过了一些东西。
在 CPU 上读取 40MB 应该不到 2ms(我的 RAM 带宽在 20 到 30GB/s 之间)
我不知道如何计算执行一次迭代所需的周期(特别是因为现在的 CPU 是超标量的)。 如果我假设每个操作有 1 个周期,并且如果我没有弄乱我的计数,那么每次迭代应该有 10 次操作 -> 2 亿次操作 -> 在 4.8 Ghz 下只有一个执行单元 -> 40 毫秒。 显然我在如何计算每个循环的周期数上是错误的。
有趣的事实:我尝试了 Linux PopOS GCC 11.2 -O3,它的运行时间为 4.5 毫秒。 为什么会有这样的差异?
以下是 clang 生成的向量化和标量反汇编:
compareFunction(char const*, unsigned long): # @compareFunction(char const*, unsigned long)
test rsi, rsi
je .LBB0_1
lea r8, [rdi + rsi]
neg rsi
xor edx, edx
xor eax, eax
.LBB0_4: # =>This Inner Loop Header: Depth=1
movzx r9d, byte ptr [rdi + rdx]
xor ecx, ecx
cmp r9b, byte ptr [r8 + rdx]
setne cl
add rax, rcx
add rdx, 1
mov rcx, rsi
add rcx, rdx
jne .LBB0_4
ret
.LBB0_1:
xor eax, eax
ret
Clang14 O3:
.LCPI0_0:
.quad 1 # 0x1
.quad 1 # 0x1
compareFunction(char const*, unsigned long): # @compareFunction(char const*, unsigned long)
test rsi, rsi
je .LBB0_1
cmp rsi, 4
jae .LBB0_4
xor r9d, r9d
xor eax, eax
jmp .LBB0_11
.LBB0_1:
xor eax, eax
ret
.LBB0_4:
mov r9, rsi
and r9, -4
lea rax, [r9 - 4]
mov r8, rax
shr r8, 2
add r8, 1
test rax, rax
je .LBB0_5
mov rdx, r8
and rdx, -2
lea r10, [rdi + 6]
lea r11, [rdi + rsi]
add r11, 6
pxor xmm0, xmm0
xor eax, eax
pcmpeqd xmm2, xmm2
movdqa xmm3, xmmword ptr [rip + .LCPI0_0] # xmm3 = [1,1]
pxor xmm1, xmm1
.LBB0_7: # =>This Inner Loop Header: Depth=1
movzx ecx, word ptr [r10 + rax - 6]
movd xmm4, ecx
movzx ecx, word ptr [r10 + rax - 4]
movd xmm5, ecx
movzx ecx, word ptr [r11 + rax - 6]
movd xmm6, ecx
pcmpeqb xmm6, xmm4
movzx ecx, word ptr [r11 + rax - 4]
movd xmm7, ecx
pcmpeqb xmm7, xmm5
pxor xmm6, xmm2
punpcklbw xmm6, xmm6 # xmm6 = xmm6[0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7]
pshuflw xmm4, xmm6, 212 # xmm4 = xmm6[0,1,1,3,4,5,6,7]
pshufd xmm4, xmm4, 212 # xmm4 = xmm4[0,1,1,3]
pand xmm4, xmm3
paddq xmm4, xmm0
pxor xmm7, xmm2
punpcklbw xmm7, xmm7 # xmm7 = xmm7[0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7]
pshuflw xmm0, xmm7, 212 # xmm0 = xmm7[0,1,1,3,4,5,6,7]
pshufd xmm5, xmm0, 212 # xmm5 = xmm0[0,1,1,3]
pand xmm5, xmm3
paddq xmm5, xmm1
movzx ecx, word ptr [r10 + rax - 2]
movd xmm0, ecx
movzx ecx, word ptr [r10 + rax]
movd xmm1, ecx
movzx ecx, word ptr [r11 + rax - 2]
movd xmm6, ecx
pcmpeqb xmm6, xmm0
movzx ecx, word ptr [r11 + rax]
movd xmm7, ecx
pcmpeqb xmm7, xmm1
pxor xmm6, xmm2
punpcklbw xmm6, xmm6 # xmm6 = xmm6[0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7]
pshuflw xmm0, xmm6, 212 # xmm0 = xmm6[0,1,1,3,4,5,6,7]
pshufd xmm0, xmm0, 212 # xmm0 = xmm0[0,1,1,3]
pand xmm0, xmm3
paddq xmm0, xmm4
pxor xmm7, xmm2
punpcklbw xmm7, xmm7 # xmm7 = xmm7[0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7]
pshuflw xmm1, xmm7, 212 # xmm1 = xmm7[0,1,1,3,4,5,6,7]
pshufd xmm1, xmm1, 212 # xmm1 = xmm1[0,1,1,3]
pand xmm1, xmm3
paddq xmm1, xmm5
add rax, 8
add rdx, -2
jne .LBB0_7
test r8b, 1
je .LBB0_10
.LBB0_9:
movzx ecx, word ptr [rdi + rax]
movd xmm2, ecx
movzx ecx, word ptr [rdi + rax + 2]
movd xmm3, ecx
add rax, rsi
movzx ecx, word ptr [rdi + rax]
movd xmm4, ecx
pcmpeqb xmm4, xmm2
movzx eax, word ptr [rdi + rax + 2]
movd xmm2, eax
pcmpeqb xmm2, xmm3
pcmpeqd xmm3, xmm3
pxor xmm4, xmm3
punpcklbw xmm4, xmm4 # xmm4 = xmm4[0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7]
pshuflw xmm4, xmm4, 212 # xmm4 = xmm4[0,1,1,3,4,5,6,7]
pshufd xmm4, xmm4, 212 # xmm4 = xmm4[0,1,1,3]
movdqa xmm5, xmmword ptr [rip + .LCPI0_0] # xmm5 = [1,1]
pand xmm4, xmm5
paddq xmm0, xmm4
pxor xmm2, xmm3
punpcklbw xmm2, xmm2 # xmm2 = xmm2[0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7]
pshuflw xmm2, xmm2, 212 # xmm2 = xmm2[0,1,1,3,4,5,6,7]
pshufd xmm2, xmm2, 212 # xmm2 = xmm2[0,1,1,3]
pand xmm2, xmm5
paddq xmm1, xmm2
.LBB0_10:
paddq xmm0, xmm1
pshufd xmm1, xmm0, 238 # xmm1 = xmm0[2,3,2,3]
paddq xmm1, xmm0
movq rax, xmm1
cmp r9, rsi
je .LBB0_13
.LBB0_11:
lea r8, [r9 + rsi]
sub rsi, r9
add r8, rdi
add rdi, r9
xor edx, edx
.LBB0_12: # =>This Inner Loop Header: Depth=1
movzx r9d, byte ptr [rdi + rdx]
xor ecx, ecx
cmp r9b, byte ptr [r8 + rdx]
setne cl
add rax, rcx
add rdx, 1
cmp rsi, rdx
jne .LBB0_12
.LBB0_13:
ret
.LBB0_5:
pxor xmm0, xmm0
xor eax, eax
pxor xmm1, xmm1
test r8b, 1
jne .LBB0_9
jmp .LBB0_10
是的,如果你的数据在缓存中不热,即使是 SSE2 也应该跟上 memory 带宽。 如果数据在 L1d 缓存中很热,或者缓存外部级别可以提供的任何带宽,则每个周期(来自两个 32 字节加载)的 32 个比较结果的比较和求和是完全可能的。
如果不是,则编译器做得不好。 不幸的是,对于像这样减少到更广泛变量的问题来说,这很常见; 编译器不知道用于求和字节的好的矢量化策略,尤其是必须为 0/-1 的比较结果字节。 它们可能会立即使用pmovsxbq
到 64 位(如果 SSE4.1 指令不可用,甚至更糟)。
因此,即使-O3 -march=native
也无济于事; 这是一个很大的优化失误; 希望 GCC 和 clang 会在某个时候学习如何向量化这种循环,总结比较结果可能会出现在足够多的代码库中,值得识别这种模式。
高效的方法是使用psadbw
横向求和成 qwords。 但仅在内部循环执行vsum -= cmp(p, q)
的一些迭代之后,减去 0 或 -1 以增加计数器或不增加计数器。 8 位元素可以进行 255 次迭代而不会有溢出的风险。 通过展开多个向量累加器,每个向量有很多 32 字节,因此您不必经常跳出内部循环。
有关手动矢量化 AVX2 代码,请参阅如何使用 SIMD 计算字符出现次数。 (一个答案有一个指向 SSE2 版本的 Godbolt 链接。)对比较结果求和是同一个问题,但是你加载两个向量来提供 pcmpeqb 而不是在循环外广播一个字节来查找单个 char 的出现.
在 i7-6700 Skylake(仅 3.4GHz,可能他们禁用了 Turbo 或只是报告额定速度)上,AVX2 的基准测试报告为 28 GB/s,SSE2 的报告为 23 GB/s。DRAM 速度未提及。 )
我希望 2 个输入数据流能够实现与一个数据流大致相同的持续带宽。
如果您对适合 L2 缓存的较小的 arrays 进行基准重复传递,则优化起来会更有趣,那么 ALU 指令的效率很重要。 (该问题的答案中的策略非常好,并且针对该案例进行了很好的调整。)
快速计算两个 arrays 之间的相等字节数是使用较差策略的较旧的问答,而不是使用psadbw
将字节求和为 64 位。 (但不像 GCC/clang 那样糟糕,在扩展到 32 位时仍然会发出嗡嗡声。)
多线程/核心在现代桌面上几乎没有帮助,尤其是在像你这样的高核心时钟下。 Memory 延迟足够低,每个内核都有足够的缓冲区来保持足够的请求在运行中,几乎可以使双通道 DRAM 控制器饱和。
在大型 Xeon 上,情况会大不相同; 您需要大多数核心来实现峰值聚合带宽,即使只是 memcpy 或 memset 所以 ALU 工作为零,只是加载/存储。 更高的延迟意味着单核的可用带宽比台式机少得多(即使是绝对意义上的,更不用说占 6 个通道而不是 2 个通道的百分比)。 另请参阅针对 memcpy 的增强型 REP MOVSB和为什么 Skylake 对于单线程 memory 吞吐量比 Broadwell-E 好得多?
仍然不好(因为它每 128 个字节减少到标量,或者如果你想尝试的话减少到 192 个),但是@Jérôme Richard 想出了一个聪明的方法来给 clang 一些它可以用一个好的策略向量化一个短的东西,用uint8_t sum
,使用它作为一个足够短的内部循环,不会溢出。
但是 clang 仍然在该循环中做了一些愚蠢的事情,正如我们在他的回答中看到的那样。 我修改了循环控制以使用指针增量,这减少了一点循环开销,只有一个指针添加和比较/jcc,而不是 LEA/MOV。 我不知道为什么 clang 使用 integer 索引效率低下。
并且它避免了vpcmpeqb
memory 源操作数的索引寻址模式, 让它们在 Intel CPU 上保持微融合。 (Clang 似乎根本不知道这很重要!在源代码中将操作数反转为!=
足以使其对vpcmpeqb
使用索引寻址模式而不是vmovdqu
纯负载。)
// micro-optimized version of Jérôme's function, clang compiles this better
// instead of 2 arrays, it compares first and 2nd half of one array, which lets it index one relative to the other with an offset if we hand-hold clang into doing that.
uint64_t compareFunction_sink_fixup(const char *const __restrict buffer, const size_t commonSize)
{
uint64_t byteChunk = 0;
uint64_t diffFound = 0;
const char *endp = buffer + commonSize;
const char *__restrict ptr = buffer;
if(commonSize >= 127) {
// A signed type for commonSize wouldn't avoid UB in pointer subtraction creating a pointer before the object
// in practice it would be fine except maybe when inlining into a function where the compiler could see a compile-time-constant array size.
for(; ptr < endp-127 ; ptr += 128)
{
uint8_t tmpDiffFound = 0;
#pragma omp simd reduction(+:tmpDiffFound)
for(int off = 0 ; off < 128; ++off)
tmpDiffFound += ptr[off + commonSize] != ptr[off];
// without AVX-512, we get -1 for ==, 0 for not-equal. So clang adds set1_epi(4) to each bucket that holds the sum of four 0 / -1 elements
diffFound += tmpDiffFound;
}
}
// clang still auto-vectorizes, but knows the max trip count is only 127
// so doesn't unroll, just 4 bytes per iter.
for(int byte = 0 ; byte < commonSize % 128 ; ++byte)
diffFound += ptr[byte] != ptr[byte + commonSize];
return diffFound;
}
Godbolt与 clang15 -O3 -fopenmp-simd -mavx2 -march=skylake -mbranches-within-32B-boundaries
# The main loop, from clang 15 for x86-64 Skylake
.LBB0_4: # =>This Inner Loop Header: Depth=1
vmovdqu ymm2, ymmword ptr [rdi + rsi]
vmovdqu ymm3, ymmword ptr [rdi + rsi + 32] # Indexed addressing modes are fine here
vmovdqu ymm4, ymmword ptr [rdi + rsi + 64]
vmovdqu ymm5, ymmword ptr [rdi + rsi + 96]
vpcmpeqb ymm2, ymm2, ymmword ptr [rdi] # non-indexed allow micro-fusion without un-lamination
vpcmpeqb ymm3, ymm3, ymmword ptr [rdi + 32]
vpcmpeqb ymm4, ymm4, ymmword ptr [rdi + 64]
vpaddb ymm2, ymm4, ymm2
vpcmpeqb ymm4, ymm5, ymmword ptr [rdi + 96]
vpaddb ymm3, ymm4, ymm3
vpaddb ymm2, ymm2, ymm3
vpaddb ymm2, ymm2, ymm0 # add a vector of set1_epi8(4) to turn sums of 0 / -1 into sums of 1 / 0
vextracti128 xmm3, ymm2, 1
vpaddb xmm2, xmm2, xmm3
vpshufd xmm3, xmm2, 238 # xmm3 = xmm2[2,3,2,3]
vpaddb xmm2, xmm2, xmm3 # reduced to 8 bytes
vpsadbw xmm2, xmm2, xmm1 # hsum to one qword
vpextrb edx, xmm2, 0 # extract and zero-extend
add rax, rdx # accumulate the chunk sum
sub rdi, -128 # pointer increment (with a sign_extended_imm8 instead of +imm32)
cmp rdi, rcx
jb .LBB0_4 # }while(p < endp)
这可以使用192
而不是128
来进一步分摊循环开销,代价是需要执行%192
(不是 2 的幂),并使清理循环最坏情况为 191 字节。 我们不能将 go 变为 256,或任何高于 UINT8_MAX (255) 的值,并且必须坚持使用 32 的倍数。 或者 64 为好。
有一个修复常量的额外vpaddb
set1_epi8(4)
,它将四个 0 / -1 的总和转换为来自 C !=
运算符的四个 1 / 0 结果的总和。
我不认为有任何方法可以摆脱它或将其从循环中取出,同时仍然累积到uint8_t
中,这是 clang 以这种方式矢量化所必需的。 它不知道如何使用vpsadbw
来扩大(非截断)字节总和,这具有讽刺意味,因为这正是它在针对全零寄存器使用时实际执行的操作。 如果你做类似sum += ptr[off + commonSize] == ptr[off]? -1: 0
sum += ptr[off + commonSize] == ptr[off]? -1: 0
你可以让它直接使用vpcmpeqb
结果,将 4 个向量加到一个有 3 个加法,并最终在一些减少步骤后将其提供给vpsadbw
。 因此,对于每个 128 字节的块,您会得到一个matches * 0xFF
截断为 uint8_t 的总和。 或者作为int8_t
,这是-1 * matches
的总和,所以0..-128
不会溢出有符号字节。 这很有趣。 但是将零扩展添加到 64 位计数器中可能会破坏信息,并且在外部循环内进行符号扩展会花费另一条指令。 这将是一个标量movsx
指令而不是vpaddb
,但这对 Skylake 来说并不重要,可能只有在将 AVX-512 与 512 位向量一起使用时(clang 和 GCC 都表现不佳,不使用掩码添加)。 我们可以做128*n_chunks - count
以恢复匹配总和的差异吗? 不,我不这么认为。
uiCA static 分析预测,如果 L1d 缓存中的数据很热,Skylake(例如您的 CPU)将以5.51 周期/迭代器(4 个向量)运行主循环,或者在 Ice Lake / Rocket Lake 上以 5.05 运行。 (我不得不手动调整 asm 以模拟填充效果-mbranches-within-32B-boundaries
会产生,因为 uiCA 的默认假设是循环顶部相对于 32 字节 alignment 边界的位置。我可以只而是在 uiCA 中更改了该设置。:/)
实施此次优策略时唯一遗漏的微优化是它使用vpextrb
(因为它不能证明不需要截断到uint8_t
?)而不是vmovd
或vmovq
。 因此,前端和后端的端口 5 需要额外的 uop。 经过优化(在链接中注释 + 取消注释),Skylake 上的 5.25c / iter,或 Ice Lake 上的 4.81,非常接近 2 负载/时钟瓶颈。
(每个迭代器执行 6 个向量,192 字节,预测 SKL 上每个迭代器有 7 个周期,或每个向量 1.166 个,低于每个向量的 5.5 / iter = 1.375。或者在 ICL/RKL = 1.08 c/vec 上大约 6.5,命中后端ALU 端口瓶颈。)
这对于我们能够从便携式 C++ 源中诱导 clang 生成的东西来说还不错,相对于每 4 个 32 字节的向量进行 4 个周期比较,每个向量进行高效的手动向量化。 这很可能会跟上 memory 或甚至来自 L2 缓存的缓存带宽,因此它非常有用,并且在 L1d 中热数据时不会慢很多。 多采用几个 uops 确实会损害乱序执行,并占用更多共享物理核心的另一个逻辑核心可以使用的执行资源。 (超线程)。
不幸的是,gcc/clang没有为此充分利用 AVX-512。 如果您使用的是 512 位向量(或 256 位向量上的 AVX-512 特征),您将与掩码寄存器进行比较,然后执行类似vpaddb zmm0{k1}, zmm0, zmm1
merge-masking 的操作以有条件地递增向量,其中 zmm1 = set1_epi8( 1 )
。 (或带有sub
的-1
常量。)如果操作正确,每个向量的指令和 uop 计数应该与 AVX2 大致相同,但 gcc/clang 使用的数量大约是其两倍,因此唯一的节省是减少标量,这似乎是获得任何可用的东西的代价。
这个版本也避免了清理循环的展开,只是用它的 dumb 4 bytes per iter 策略进行矢量化,这对于size%128
字节的清理来说是正确的。 它同时使用vpxor
翻转和vpand
将 0xff 转换为 0x01 是非常愚蠢的,而它本可以使用vpandn
在一条指令中完成这两项操作。 这将使清理循环下降到 8 微指令,只是 Haswell / Skylake 管道宽度的两倍,因此它会从循环缓冲区更有效地发出,除了 Skylake 在微代码更新中禁用它。 这会对哈斯韦尔有所帮助
TLDR : Clang 代码之所以如此缓慢,是因为矢量化方法不佳导致端口 5 饱和(已知这通常是一个问题)。 GCC 在这里做得更好,但离效率还差得很远。 可以使用不使端口 5 饱和的 AVX-2 编写更快的基于块的代码。
要了解发生了什么,最好从一个简单的例子开始。 事实上,正如您所说,现代处理器是超标量的,因此很难理解在这种架构上生成的某些代码的速度。
-O1
使用 -O1 优化标志生成的代码是一个好的开始。 这是您问题中提供的 GodBold 生成的热循环代码:
(instructions) (ports)
.LBB0_4:
movzx r9d, byte ptr [rdi + rdx] p23
xor ecx, ecx p0156
cmp r9b, byte ptr [r8 + rdx] p0156+p23
setne cl p06
add rax, rcx p0156
add rdx, 1 p0156
mov rcx, rsi (optimized)
add rcx, rdx p0156
jne .LBB0_4 p06
像 Coffee Lake 9700K 这样的现代处理器的结构分为两大部分:前端获取/解码指令(并将它们拆分为微指令,也称为uops ),以及后端调度/执行它们。 后端在许多端口上调度微指令,每个微指令都可以执行一些特定的指令集(例如,仅 memory 加载,或仅算术指令)。 对于每条指令,我都放置了可以执行它们的端口。 p0156+p23
表示指令分为两个微指令:第一个可以由端口 0 或 1 或 5 或 6 执行,第二个可以由端口 2 或 3 执行。注意前端可以以某种方式优化代码因此不会为循环中的mov
之类的基本指令生成任何微指令(由于称为寄存器重命名的机制)。
对于每个循环迭代,处理器需要从 memory 读取 2 个值。像 9700K 这样的 Coffee Lake 处理器每个周期可以加载两个值,因此循环至少需要 1 个周期/迭代(假设r9d
和r9b
中的加载不冲突由于使用了同一r9
64 位寄存器的不同部分)。 这个处理器有一个 uops 缓存,循环有很多指令,所以解码部分应该不是问题。 也就是说,有 9 个微指令要执行,处理器每个周期只能执行其中的 6 个微指令,因此循环不能少于 1.5 个周期/迭代。 更准确地说,端口 0、1、5 和 6 处于压力之下,因此即使假设处理器完美地负载平衡 uops,也需要 2 个周期/迭代。 这是一个乐观的下限执行时间,因为处理器可能无法完美地安排指令,并且有很多事情可能 go 错误(比如我没有看到的偷偷摸摸的隐藏依赖项)。 在 4.8GHz 的频率下,最终执行时间至少为 8.3 毫秒。 3 次循环/迭代可以达到 12.5 毫秒(请注意,由于 uops 到端口的调度,2.5 次循环/迭代是可能的)。
可以使用unrolling改进循环。 实际上,只需要执行循环而不是实际计算就需要大量指令。 展开有助于增加有用指令的比例,从而更好地利用可用端口。 尽管如此,这 2 个负载仍会阻止循环快于 1 个循环/迭代,即 4.2 毫秒。
Clang生成的向量化代码比较复杂。 人们可以尝试应用与先前代码相同的分析,但这将是一项乏味的任务。
可以注意到,即使代码是矢量化的,负载也不是矢量化的。 这是一个问题,因为每个周期只能完成 2 次加载。 也就是说,加载是由两个连续的 char 值成对执行的,因此与之前生成的代码相比,加载并不那么慢。
Clang 这样做是因为只有两个 64 位值可以放入一个 128 位 SSE 寄存器和一个 64 位寄存器中,它需要这样做,因为diffFound
是一个 64 位 integer。8位到 64 位的转换是代码中最大的问题,因为它需要几个 SSE 指令来进行转换。 此外,一次只能计算 4 个整数,因为 Coffee Lake 上有 3 个 SSE integer 单元,每个单元一次只能计算两个 64 位整数。 最后,Clang 只在每个 SSE 寄存器中放入 2 个值(并使用其中的 4 个,以便在每次循环迭代中计算 8 个项目)因此应该期望代码运行速度快两倍以上(特别是由于 SSE 和循环展开),但由于 SSE 端口少于 ALU 端口以及类型转换所需的更多指令,因此情况并非如此。 简而言之,向量化显然是低效的,但是在这种情况下 Clang 生成高效代码并不是那么容易。 尽管如此,使用 28 个 SSE 指令和 3 个 SSE integer 单元每个循环计算 8 个项目,应该期望代码的计算部分大约需要28/3/8 ~= 1.2
周期/项目,这与您可以观察到的相去甚远(并且这不是由于其他指令,因为它们大多可以并行执行,因为它们大多可以安排在其他端口上)。
事实上,性能问题肯定来自端口 5 的饱和。 实际上,这个端口是唯一可以洗牌 SIMD 寄存器项的端口。 因此,指令punpcklbw
、 pshuflw
、 pshufd
甚至movd
只能在端口 5 上执行。这是 SIMD 代码的一个很常见的问题。 这是一个大问题,因为每个循环有 20 条指令,处理器甚至可能无法完美地使用它。 这意味着代码至少需要 10.4 毫秒,这非常接近观察到的执行时间 (11 毫秒)。
与 Clang 相比,GCC 生成的代码实际上相当不错。首先,GCC 直接使用 SIMD 指令加载项目,这更加高效,因为每条指令(和迭代)计算 16 个项目:它每次只需要 2 个加载微指令迭代减少端口 2 和 3 上的压力(为此 1 个循环/迭代,因此 0.0625 个循环/项目)。 其次,GCC 仅使用 14 punpckhwd
指令,每次迭代计算 16 项,减少了端口 5 的临界压力(0.875 周期/项)。 第三,SIMD 寄存器几乎被完全使用,至少在比较方面是这样,因为pcmpeqb
比较指令一次比较 16 个项目(与 Clang 的 2 个项目相反)。 其他指令如paddq
很便宜(例如, paddq
可以调度在 3 个 SSE 端口上)并且它们不会对执行时间产生太大影响。 最后,这个版本应该还是受端口5限制,但是应该比Clang版本快很多。 实际上,应该期望执行时间达到 1 个周期/项(因为端口调度肯定不是完美的,memory 负载可能会引入一些停滞周期)。 这意味着 4.2 毫秒的执行时间。 这与观察到的结果很接近。
GCC 实现并不完美。
首先,它不使用处理器支持的 AVX2,因为未提供-mavx2
标志(或任何类似的标志,如-march=native
)。 事实上,GCC 和其他主流编译器一样,为了与以前的架构兼容,默认情况下只使用 SSE2:SSE2 可以安全地用于所有 x86-64 处理器,但其他指令集如 SSE3、SSSE3、SSE4.1、SSE4.2、 AVX,AVX2。 有了这样的标志,GCC 应该能够生成 memory 绑定代码。
此外,编译器理论上可以执行多级总和缩减。 这个想法是使用大小为 1024 项(即 64x16 项)的块在 8 位宽 SIMD 通道中累积比较结果。 这是安全的,因为每个通道的值不能超过 64。为避免溢出,累加值需要存储在更宽的 SIMD 通道(例如 64 位通道)中。 使用此策略, punpckhwd
指令的开销减少了 64 倍。 这是一个很大的改进,因为它消除了端口 5 的饱和。这种策略应该足以生成内存绑定代码,即使只使用 SSE2。 这是一个未经测试的代码示例,需要标志-fopenmp-simd
才能有效。
uint64_t compareFunction(const char *const __restrict buffer, const uint64_t commonSize)
{
uint64_t byteChunk = 0;
uint64_t diffFound = 0;
if(commonSize >= 127)
{
for(; byteChunk < commonSize-127; byteChunk += 128)
{
uint8_t tmpDiffFound = 0;
#pragma omp simd reduction(+:tmpDiffFound)
for(uint64_t byte = byteChunk; byte < byteChunk + 128; ++byte)
tmpDiffFound += buffer[byte] != buffer[byte + commonSize];
diffFound += tmpDiffFound;
}
}
for(uint64_t byte = byteChunk; byte < commonSize; ++byte)
diffFound += buffer[byte] != buffer[byte + commonSize];
return diffFound;
}
GCC和Clang都生成了相当高效的代码(虽然对于缓存中的数据拟合不是最优的),尤其是 Clang。例如,这里是 Clang 使用 AVX2 生成的代码:
.LBB0_4:
lea r10, [rdx + 128]
vmovdqu ymm2, ymmword ptr [r9 + rdx - 96]
vmovdqu ymm3, ymmword ptr [r9 + rdx - 64]
vmovdqu ymm4, ymmword ptr [r9 + rdx - 32]
vpcmpeqb ymm2, ymm2, ymmword ptr [rcx + rdx - 96]
vpcmpeqb ymm3, ymm3, ymmword ptr [rcx + rdx - 64]
vpcmpeqb ymm4, ymm4, ymmword ptr [rcx + rdx - 32]
vmovdqu ymm5, ymmword ptr [r9 + rdx]
vpaddb ymm2, ymm4, ymm2
vpcmpeqb ymm4, ymm5, ymmword ptr [rcx + rdx]
vpaddb ymm3, ymm4, ymm3
vpaddb ymm2, ymm3, ymm2
vpaddb ymm2, ymm2, ymm0
vextracti128 xmm3, ymm2, 1
vpaddb xmm2, xmm2, xmm3
vpshufd xmm3, xmm2, 238
vpaddb xmm2, xmm2, xmm3
vpsadbw xmm2, xmm2, xmm1
vpextrb edx, xmm2, 0
add rax, rdx
mov rdx, r10
cmp r10, r8
jb .LBB0_4
所有加载都是 256 位 SIMD 的。 vpcmpeqb
的数量是最优的。 vpaddb
的数量比较好。 其他指令很少,但它们显然不应成为瓶颈。 该循环每次迭代对 128 个项目进行操作,我希望对于缓存中已有的数据,每次迭代花费的周期少于十几个周期(否则它应该完全受内存限制)。 这意味着<0.1 cycle/item,也就是远低于之前的实现。 事实上,uiCA 工具指示大约 0.055 周期/项,即 81 GiB/s,可以使用 SIMD 内在函数手动编写更好的代码,但代价是可移植性明显变差。 维护性和可读性。
请注意,生成顺序内存限制并不总是意味着 RAM 吞吐量将饱和。 事实上,在一个内核上,有时没有足够的并发来隐藏 memory 操作的延迟,尽管它在你的处理器上应该没问题(就像在我的 i5-9600KF 上有 2 个交错的 3200 MHz DDR4 memory 通道)。
如果我错了,请纠正我,但答案似乎是
我将尝试编写自己的 AVX 代码进行比较。
细节
经过一个下午修改这些建议后,这是我在 clang 中发现的内容:
-O1 大约 10 毫秒,标量代码
-O3 启用 SSE2 并且与 O1 一样慢,可能是糟糕的汇编代码
-O3 -march=westmere 也启用 SSE2 但速度更快 (7ms)
-O3 -march=native 启用 AVX -> 2.5ms,我们可能会限制 RAM 带宽(接近理论速度)
标量 10ms 现在有意义,因为根据那个很棒的工具 uica.uops.info 它需要
g++ 似乎在没有添加标志时生成更好的默认代码
声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.