让我们首先看一下适用于随每次调用而变化的一般offset 的解决方案(这将是现有功能的直接解决方案),然后我们将看看我们是否可以利用相同的 offset 数组被用于多个调用(而 array 总是变化的)。
不同的偏移量
一个大问题是gcc(旧的或新的)只是为你的函数的当前实现生成awful code:
lea r10, [rsp+8]
and rsp, -32
push QWORD PTR [r10-8]
push rbp
mov rbp, rsp
push r15
push r14
push r13
push r12
push r10
push rbx
sub rsp, 40
movzx eax, WORD PTR [rsi+40]
movzx r14d, WORD PTR [rsi+60]
movzx r12d, WORD PTR [rsi+56]
movzx ecx, WORD PTR [rsi+44]
movzx r15d, WORD PTR [rsi+62]
movzx r13d, WORD PTR [rsi+58]
mov QWORD PTR [rbp-56], rax
movzx eax, WORD PTR [rsi+38]
movzx ebx, WORD PTR [rsi+54]
movzx r11d, WORD PTR [rsi+52]
movzx r10d, WORD PTR [rsi+50]
movzx r9d, WORD PTR [rsi+48]
movzx r8d, WORD PTR [rsi+46]
mov QWORD PTR [rbp-64], rax
movzx eax, WORD PTR [rsi+36]
movzx edx, WORD PTR [rsi+42]
mov QWORD PTR [rbp-72], rax
movzx eax, WORD PTR [rsi+34]
mov QWORD PTR [rbp-80], rax
movzx eax, WORD PTR [rsi+32]
mov QWORD PTR [rbp-88], rax
movzx eax, WORD PTR [rsi+30]
movzx r15d, BYTE PTR [rdi+r15]
mov QWORD PTR [rbp-96], rax
movzx eax, WORD PTR [rsi+28]
vmovd xmm2, r15d
vpinsrb xmm2, xmm2, BYTE PTR [rdi+r14], 1
mov QWORD PTR [rbp-104], rax
movzx eax, WORD PTR [rsi+26]
mov QWORD PTR [rbp-112], rax
movzx eax, WORD PTR [rsi+24]
mov QWORD PTR [rbp-120], rax
movzx eax, WORD PTR [rsi+22]
mov QWORD PTR [rbp-128], rax
movzx eax, WORD PTR [rsi+20]
mov QWORD PTR [rbp-136], rax
movzx eax, WORD PTR [rsi+18]
mov QWORD PTR [rbp-144], rax
movzx eax, WORD PTR [rsi+16]
mov QWORD PTR [rbp-152], rax
movzx eax, WORD PTR [rsi+14]
mov QWORD PTR [rbp-160], rax
movzx eax, WORD PTR [rsi+12]
mov QWORD PTR [rbp-168], rax
movzx eax, WORD PTR [rsi+10]
mov QWORD PTR [rbp-176], rax
movzx eax, WORD PTR [rsi+8]
mov QWORD PTR [rbp-184], rax
movzx eax, WORD PTR [rsi+6]
mov QWORD PTR [rbp-192], rax
movzx eax, WORD PTR [rsi+4]
mov QWORD PTR [rbp-200], rax
movzx eax, WORD PTR [rsi+2]
movzx esi, WORD PTR [rsi]
movzx r13d, BYTE PTR [rdi+r13]
movzx r8d, BYTE PTR [rdi+r8]
movzx edx, BYTE PTR [rdi+rdx]
movzx ebx, BYTE PTR [rdi+rbx]
movzx r10d, BYTE PTR [rdi+r10]
vmovd xmm7, r13d
vmovd xmm1, r8d
vpinsrb xmm1, xmm1, BYTE PTR [rdi+rcx], 1
mov rcx, QWORD PTR [rbp-56]
vmovd xmm5, edx
vmovd xmm3, ebx
mov rbx, QWORD PTR [rbp-72]
vmovd xmm6, r10d
vpinsrb xmm7, xmm7, BYTE PTR [rdi+r12], 1
vpinsrb xmm5, xmm5, BYTE PTR [rdi+rcx], 1
mov rcx, QWORD PTR [rbp-64]
vpinsrb xmm6, xmm6, BYTE PTR [rdi+r9], 1
vpinsrb xmm3, xmm3, BYTE PTR [rdi+r11], 1
vpunpcklwd xmm2, xmm2, xmm7
movzx edx, BYTE PTR [rdi+rcx]
mov rcx, QWORD PTR [rbp-80]
vpunpcklwd xmm1, xmm1, xmm5
vpunpcklwd xmm3, xmm3, xmm6
vmovd xmm0, edx
movzx edx, BYTE PTR [rdi+rcx]
mov rcx, QWORD PTR [rbp-96]
vpunpckldq xmm2, xmm2, xmm3
vpinsrb xmm0, xmm0, BYTE PTR [rdi+rbx], 1
mov rbx, QWORD PTR [rbp-88]
vmovd xmm4, edx
movzx edx, BYTE PTR [rdi+rcx]
mov rcx, QWORD PTR [rbp-112]
vpinsrb xmm4, xmm4, BYTE PTR [rdi+rbx], 1
mov rbx, QWORD PTR [rbp-104]
vpunpcklwd xmm0, xmm0, xmm4
vpunpckldq xmm0, xmm1, xmm0
vmovd xmm1, edx
movzx edx, BYTE PTR [rdi+rcx]
vpinsrb xmm1, xmm1, BYTE PTR [rdi+rbx], 1
mov rcx, QWORD PTR [rbp-128]
mov rbx, QWORD PTR [rbp-120]
vpunpcklqdq xmm0, xmm2, xmm0
vmovd xmm8, edx
movzx edx, BYTE PTR [rdi+rcx]
vpinsrb xmm8, xmm8, BYTE PTR [rdi+rbx], 1
mov rcx, QWORD PTR [rbp-144]
mov rbx, QWORD PTR [rbp-136]
vmovd xmm4, edx
vpunpcklwd xmm1, xmm1, xmm8
vpinsrb xmm4, xmm4, BYTE PTR [rdi+rbx], 1
movzx edx, BYTE PTR [rdi+rcx]
mov rbx, QWORD PTR [rbp-152]
mov rcx, QWORD PTR [rbp-160]
vmovd xmm7, edx
movzx eax, BYTE PTR [rdi+rax]
movzx edx, BYTE PTR [rdi+rcx]
vpinsrb xmm7, xmm7, BYTE PTR [rdi+rbx], 1
mov rcx, QWORD PTR [rbp-176]
mov rbx, QWORD PTR [rbp-168]
vmovd xmm5, eax
vmovd xmm2, edx
vpinsrb xmm5, xmm5, BYTE PTR [rdi+rsi], 1
vpunpcklwd xmm4, xmm4, xmm7
movzx edx, BYTE PTR [rdi+rcx]
vpinsrb xmm2, xmm2, BYTE PTR [rdi+rbx], 1
vpunpckldq xmm1, xmm1, xmm4
mov rbx, QWORD PTR [rbp-184]
mov rcx, QWORD PTR [rbp-192]
vmovd xmm6, edx
movzx edx, BYTE PTR [rdi+rcx]
vpinsrb xmm6, xmm6, BYTE PTR [rdi+rbx], 1
mov rbx, QWORD PTR [rbp-200]
vmovd xmm3, edx
vpunpcklwd xmm2, xmm2, xmm6
vpinsrb xmm3, xmm3, BYTE PTR [rdi+rbx], 1
add rsp, 40
vpunpcklwd xmm3, xmm3, xmm5
vpunpckldq xmm2, xmm2, xmm3
pop rbx
pop r10
vpunpcklqdq xmm1, xmm1, xmm2
pop r12
pop r13
vinserti128 ymm0, ymm0, xmm1, 0x1
pop r14
pop r15
pop rbp
lea rsp, [r10-8]
ret
基本上,它会尝试预先读取offset 的所有内容,并且只是用完寄存器,因此它开始溢出一些,然后在它只是读取大部分 16 位元素的地方进行狂欢offset,然后立即将它们(作为 64 位零扩展值)存储到堆栈中。本质上,它无缘无故地复制了大部分 offset 数组(零扩展至 64 位):稍后它会读取溢出的值,当然它可能只是从 offset 读取的。
同样可怕的代码在您使用的旧版4.9.2 以及最近的7.2 中都很明显。
icc 和 clang 都没有任何此类问题 - 它们都生成几乎相同的非常合理的代码,只使用 movzx 从每个 offset 位置读取一次,然后使用带有内存的 vpinsrb 插入字节源操作数基于offset 刚刚阅读:
gather256(char*, unsigned short*): # @gather256(char*, unsigned short*)
movzx eax, word ptr [rsi + 30]
movzx eax, byte ptr [rdi + rax]
vmovd xmm0, eax
movzx eax, word ptr [rsi + 28]
vpinsrb xmm0, xmm0, byte ptr [rdi + rax], 1
movzx eax, word ptr [rsi + 26]
vpinsrb xmm0, xmm0, byte ptr [rdi + rax], 2
movzx eax, word ptr [rsi + 24]
...
vpinsrb xmm0, xmm0, byte ptr [rdi + rax], 14
movzx eax, word ptr [rsi]
vpinsrb xmm0, xmm0, byte ptr [rdi + rax], 15
movzx eax, word ptr [rsi + 62]
movzx eax, byte ptr [rdi + rax]
vmovd xmm1, eax
movzx eax, word ptr [rsi + 60]
vpinsrb xmm1, xmm1, byte ptr [rdi + rax], 1
movzx eax, word ptr [rsi + 58]
vpinsrb xmm1, xmm1, byte ptr [rdi + rax], 2
movzx eax, word ptr [rsi + 56]
vpinsrb xmm1, xmm1, byte ptr [rdi + rax], 3
movzx eax, word ptr [rsi + 54]
vpinsrb xmm1, xmm1, byte ptr [rdi + rax], 4
movzx eax, word ptr [rsi + 52]
...
movzx eax, word ptr [rsi + 32]
vpinsrb xmm1, xmm1, byte ptr [rdi + rax], 15
vinserti128 ymm0, ymm1, xmm0, 1
ret
非常好。 vinserti128 两个 xmm 向量加上一半的结果有少量额外开销,显然是因为 vpinserb 无法写入高 128 位。似乎在您正在使用的现代 uarch 上,这会同时在 2 个读取端口和端口 5(随机播放)上以每个周期 1 个元素出现瓶颈。因此,这可能具有每 32 个周期约 1 的吞吐量,以及接近 32 个周期的延迟(主要依赖链是通过正在接收 pinsrb 的工作进行中的xmm 寄存器,但列出的延迟为该指令的内存源版本只有1个周期1.
我们可以在 gcc 上接近这 32 的性能吗?似乎是这样。这是一种方法:
uint64_t gather64(char *array, uint16_t *offset) {
uint64_t ret;
char *p = (char *)&ret;
p[0] = array[offset[0]];
p[1] = array[offset[1]];
p[2] = array[offset[2]];
p[3] = array[offset[3]];
p[4] = array[offset[4]];
p[5] = array[offset[5]];
p[6] = array[offset[6]];
p[7] = array[offset[7]];
return ret;
}
__m256i gather256_gcc(char *array, uint16_t *offset) {
return _mm256_set_epi64x(
gather64(array, offset),
gather64(array + 8, offset + 8),
gather64(array + 16, offset + 16),
gather64(array + 24, offset + 24)
);
}
这里我们依靠堆栈上的一个临时数组一次从array 收集8 个元素,然后我们将其用作_mm256_set_epi64x 的输入。总体而言,每个 8 字节元素使用 2 次加载和 1 次存储,每个 64 位元素使用几个额外指令,因此每个元素吞吐量应该接近 1 个周期2。
它在gcc 中生成“预期的”内联code:
gather256_gcc(char*, unsigned short*):
lea r10, [rsp+8]
and rsp, -32
push QWORD PTR [r10-8]
push rbp
mov rbp, rsp
push r10
movzx eax, WORD PTR [rsi+48]
movzx eax, BYTE PTR [rdi+24+rax]
mov BYTE PTR [rbp-24], al
movzx eax, WORD PTR [rsi+50]
movzx eax, BYTE PTR [rdi+24+rax]
mov BYTE PTR [rbp-23], al
movzx eax, WORD PTR [rsi+52]
movzx eax, BYTE PTR [rdi+24+rax]
mov BYTE PTR [rbp-22], al
...
movzx eax, WORD PTR [rsi+62]
movzx eax, BYTE PTR [rdi+24+rax]
mov BYTE PTR [rbp-17], al
movzx eax, WORD PTR [rsi+32]
vmovq xmm0, QWORD PTR [rbp-24]
movzx eax, BYTE PTR [rdi+16+rax]
movzx edx, WORD PTR [rsi+16]
mov BYTE PTR [rbp-24], al
movzx eax, WORD PTR [rsi+34]
movzx edx, BYTE PTR [rdi+8+rdx]
movzx eax, BYTE PTR [rdi+16+rax]
mov BYTE PTR [rbp-23], al
...
movzx eax, WORD PTR [rsi+46]
movzx eax, BYTE PTR [rdi+16+rax]
mov BYTE PTR [rbp-17], al
mov rax, QWORD PTR [rbp-24]
mov BYTE PTR [rbp-24], dl
movzx edx, WORD PTR [rsi+18]
vpinsrq xmm0, xmm0, rax, 1
movzx edx, BYTE PTR [rdi+8+rdx]
mov BYTE PTR [rbp-23], dl
movzx edx, WORD PTR [rsi+20]
movzx edx, BYTE PTR [rdi+8+rdx]
mov BYTE PTR [rbp-22], dl
movzx edx, WORD PTR [rsi+22]
movzx edx, BYTE PTR [rdi+8+rdx]
mov BYTE PTR [rbp-21], dl
movzx edx, WORD PTR [rsi+24]
movzx edx, BYTE PTR [rdi+8+rdx]
mov BYTE PTR [rbp-20], dl
movzx edx, WORD PTR [rsi+26]
movzx edx, BYTE PTR [rdi+8+rdx]
mov BYTE PTR [rbp-19], dl
movzx edx, WORD PTR [rsi+28]
movzx edx, BYTE PTR [rdi+8+rdx]
mov BYTE PTR [rbp-18], dl
movzx edx, WORD PTR [rsi+30]
movzx edx, BYTE PTR [rdi+8+rdx]
mov BYTE PTR [rbp-17], dl
movzx edx, WORD PTR [rsi]
vmovq xmm1, QWORD PTR [rbp-24]
movzx edx, BYTE PTR [rdi+rdx]
mov BYTE PTR [rbp-24], dl
movzx edx, WORD PTR [rsi+2]
movzx edx, BYTE PTR [rdi+rdx]
mov BYTE PTR [rbp-23], dl
movzx edx, WORD PTR [rsi+4]
movzx edx, BYTE PTR [rdi+rdx]
mov BYTE PTR [rbp-22], dl
...
movzx edx, WORD PTR [rsi+12]
movzx edx, BYTE PTR [rdi+rdx]
mov BYTE PTR [rbp-18], dl
movzx edx, WORD PTR [rsi+14]
movzx edx, BYTE PTR [rdi+rdx]
mov BYTE PTR [rbp-17], dl
vpinsrq xmm1, xmm1, QWORD PTR [rbp-24], 1
vinserti128 ymm0, ymm0, xmm1, 0x1
pop r10
pop rbp
lea rsp, [r10-8]
ret
在尝试读取堆栈缓冲区时,这种方法将遭受 4 个(非依赖)存储转发停顿,这将使延迟比 32 个周期更差,可能在 40 年代中期(如果您假设这是最后一次停顿)将是没有隐藏的那个)。您也可以删除gather64 函数并将整个内容展开到一个32 字节的缓冲区中,最后只加载一次。这只会导致一次停顿,并且消除了一次将每个 64 位值加载到结果中的小开销,但总体效果可能更糟,因为较大的负载似乎有时会遭受较大的转发停顿。
我很确定您可以提出更好的方法。例如,您可以在 clang 和 icc 使用的 vpinsrb 方法的内在函数中写出“长手”。这很简单,gcc 应该做对了。
重复偏移
如果offset 数组被重复用于多个不同的array 输入会怎样?
我们可以看一下预处理offset 数组,以便我们的核心加载循环可以更快。
一种可行的方法是使用vgatherdd 有效地加载元素,而不会在端口 5 上出现瓶颈以进行 shuffle。我们也可以在单个 256 位加载中加载整个聚集索引向量。不幸的是,最细粒度的vpgather 是vpgatherdd,它使用 32 位偏移量加载 8 个 32 位元素。所以我们需要 4 个这样的集合来获取所有 32 个字节元素,然后需要以某种方式混合得到的向量。
我们实际上可以通过交错和调整偏移量来避免组合结果数组的大部分成本,以便每个 32 位值中的“目标”字节实际上是其正确的最终位置。所以你最终得到了 4 个 256 位向量,每个向量都有 8 个字节,你想要的,在正确的位置,24 个字节你不想要。您可以将vpblendw 两对向量放在一起,然后将vpblendb 这些结果放在一起,总共 3 个端口 5 微指令(必须有更好的方法来减少这种情况?)。
把它们加在一起,我得到了类似的东西:
- 4
movups 加载 4 个 vpgatherdd 索引寄存器(可吊装)
- 4
vpgatherdd
- 2
vpblendw(4 个结果 -> 2)
- 1
movups 加载vpblendb 掩码(可吊装)
- 1
vpblendb(2 个结果 -> 1)
除了vpgatherdds 之外,它看起来大约有 9 个微指令,其中 3 个进入端口 5,因此该端口有 3 个周期瓶颈,如果没有瓶颈,则大约 2.25 个周期(因为 vpgatherdd 可能不会使用端口 5)。在 Broadwell 上,vpgather 系列比 Haswell 有了很大改进,但对于 vpgatherdd,每个元素仍然需要大约 0.9 个周期,所以那里大约有 29 个周期。所以我们马上回到我们开始的地方,大约 32 个周期。
不过,还是有希望的:
- 每个元素 0.9 个周期用于大部分纯
vpgatherdd 活动。也许那时混合代码或多或少是免费的,我们大约有 29 个周期(实际上,movups 仍将与集合竞争)。
-
vpgatherdd 在 Skylake 中再次变得更好,每个元素大约 0.6 个周期,因此当您将硬件升级到 Skylake 时,此策略将开始显着帮助。 (并且使用 AVX512BW 的策略可能会稍微领先于 vpinsrb,其中与 k-register 掩码混合的字节是有效的,并且 vpgatherdd zmm 每个元素的收集吞吐量略高于 ymm (InstLatx64 ).)
- 预处理使您有机会检查是否从
array 读取重复元素。在这种情况下,您可能会减少聚集的数量。例如,如果offset 中只有一半的元素是唯一的,那么您只能进行两次聚集以收集 16 个元素,然后 pshufb 根据需要注册以复制元素。 “减少”必须更笼统,但实际上看起来并不更贵(而且可能更便宜),因为 pshufb 非常笼统地完成了大部分工作。
扩展最后一个想法:您将在运行时分派到一个例程,该例程知道如何根据需要多少元素进行 1、2、3 或 4 次收集。这是相当量化的,但是您始终可以在这些截止点之间以更细粒度的方式调度标量负载(或具有更大元素的聚集,这更快)。你会很快达到收益递减。
您甚至可以将其扩展到处理 nearby 元素 - 毕竟,您要抓取 4 个字节来获取一个字节:因此,如果这 3 个浪费的字节中的任何一个实际上是另一个使用的 offset 值,那么您几乎可以免费获得它。现在,这需要一个更通用的缩减阶段,但看起来pshufb 仍然会完成繁重的工作,并且大部分艰苦的工作都仅限于预处理。
1 这是少数 SSE/AVX 指令之一,其中指令的内存源形式比 reg-reg 形式更有效:reg-reg 形式需要 2端口 5 上的 uops 将其限制为每个周期 0.5 的吞吐量,并使其延迟为 2。显然,内存加载路径避免了端口 5 所需的洗牌/混合之一。vpbroadcastd/q 也是如此。
2 每个周期有两次加载和一次存储,这将非常接近最大理论性能的参差不齐的边缘:它最大化了 L1 操作吞吐量,这通常会导致打嗝:例如,可能没有任何空闲周期来接受来自 L2 的传入高速缓存行。