跨 AVX 通道洗牌的最佳方式?
best way to shuffle across AVX lanes?
有些问题的标题相似,但我的问题涉及一个其他地方未涉及的非常具体的用例。
我有 4 个 __128d 寄存器(x0、x1、x2、x3),我想将它们的内容重新组合到 5 个 __256d 寄存器(y0、y1、y2、y3、y4)中作为下面是其他计算的准备:
on entry:
x0 contains {a0, a1}
x1 contains {a2, a3}
x2 contains {a4, a5}
x3 contains {a6, a7}
on exit:
y0 contains {a0, a1, a2, a3}
y1 contains {a1, a2, a3, a4}
y2 contains {a2, a3, a4, a5}
y3 contains {a3, a4, a5, a6}
y4 contains {a4, a5, a6, a7}
下面我的实现速度很慢。有没有更好的方法?
y0 = _mm256_set_m128d(x1, x0);
__m128d lo = _mm_shuffle_pd(x0, x1, 1);
__m128d hi = _mm_shuffle_pd(x1, x2, 1);
y1 = _mm256_set_m128d(hi, lo);
y2 = _mm256_set_m128d(x2, x1);
lo = hi;
hi = _mm_shuffle_pd(x2, x3, 1);
y3 = _mm256_set_m128d(hi, lo);
y4 = _mm256_set_m128d(x3, x2);
有了寄存器中的输入,您可以用 5 条随机指令完成:
- 3x
vinsertf128
通过连接 2 个 xmm 寄存器来创建 y0、y2 和 y4。
- 2x
vshufpd
(通道内洗牌)在这些结果之间创建 y1 和 y3。
请注意,y0 和 y2 的低通道包含 a1 和 a2,即 y1 的低通道所需的元素。同样的洗牌也适用于高车道。
#include <immintrin.h>
void merge(__m128d x0, __m128d x1, __m128d x2, __m128d x3,
__m256d *__restrict y0, __m256d *__restrict y1,
__m256d *__restrict y2, __m256d *__restrict y3, __m256d *__restrict y4)
{
*y0 = _mm256_set_m128d(x1, x0);
*y2 = _mm256_set_m128d(x2, x1);
*y4 = _mm256_set_m128d(x3, x2);
// take the high element from the first vector, low element from the 2nd.
*y1 = _mm256_shuffle_pd(*y0, *y2, 0b0101);
*y3 = _mm256_shuffle_pd(*y2, *y4, 0b0101);
}
很好地编译 (with gcc and clang -O3 -march=haswell
on Godbolt) 到:
merge(double __vector(2), double __vector(2), double __vector(2), double __vector(2), double __vector(4)*, double __vector(4)*, double __vector(4)*, double __vector(4)*, double __vector(4)*):
vinsertf128 ymm0, ymm0, xmm1, 0x1
vinsertf128 ymm3, ymm2, xmm3, 0x1
vinsertf128 ymm1, ymm1, xmm2, 0x1
# vmovapd YMMWORD PTR [rdi], ymm0
vshufpd ymm0, ymm0, ymm1, 5
# vmovapd YMMWORD PTR [rdx], ymm1
vshufpd ymm1, ymm1, ymm3, 5
# vmovapd YMMWORD PTR [r8], ymm3
# vmovapd YMMWORD PTR [rsi], ymm0
# vmovapd YMMWORD PTR [rcx], ymm1
# vzeroupper
# ret
我注释掉了内联时会消失的存储和内容,因此我们确实只有 5 条随机播放指令,而您问题中的代码有 9 条随机播放指令。 (也包含在 Godbolt 编译器资源管理器中 link)。
这在 AMD 上非常 好,其中 vinsertf128
超级便宜(因为 256 位寄存器实现为 2x 128 位的一半,所以它只是不需要特殊洗牌端口的 128 位副本。)256 位通道交叉洗牌在 AMD 上很慢,但像 vshufpd
这样的通道内 256 位洗牌只有 2 微码。
在 Intel 上它非常好,但是带有 AVX 的主流 Intel CPU 对于 256 位或 FP 随机播放只有每时钟 1 个随机播放吞吐量。 (Sandybridge 和更早的整数 128 位洗牌具有更高的吞吐量,但 AVX2 CPU 丢弃了额外的洗牌单元,并且它们对此无济于事。)
所以英特尔 CPU 根本无法利用指令级并行性,但总共只有 5 微指令,这很好。这是最低限度,因为您需要 5 个结果。
但特别是如果周围的代码在随机播放方面也存在瓶颈,值得考虑一个 store/reload 仅包含 4 个存储和 5 个重叠向量加载的策略。或者可能是 2x vinsertf128
构造 y0
和 y4
,然后是 2x 256 位存储 + 3 次重叠重载。这可以让乱序执行仅使用 y0
或 y4
开始执行相关指令,而存储转发停顿已解决 y1..3.
特别是如果您不太关心英特尔第一代 Sandybridge,其中未对齐的 256 位矢量加载效率较低。 (请注意,如果您使用的是 GCC,则需要使用 gcc -mtune=haswell
进行编译以关闭 -mavx256-split-unaligned-load
默认/sandybridge 调整。无论编译器如何,-march=native
都是一个好主意如果在编译它的机器上将二进制文件制作成 运行,以充分利用指令集并设置调整选项。)
但是如果前端的总uop吞吐量更多是瓶颈所在,那么shuffle实现是最好的。
(请参阅 https://agner.org/optimize/ and other performance links in the x86 tag wiki for more about performance tuning. Also What considerations go into predicting latency for operations on modern superscalar processors and how can I calculate them by hand?,但 Agner Fog 的指南实际上是一个更深入的指南,解释了吞吐量与延迟的实际含义。)
I do not even need to save, as data is also already available in contiguous memory.
然后简单地加载 5 个重叠负载几乎肯定是您可以做的最有效的事情。
Haswell 可以从 L1d 每个时钟执行 2 次加载,或者当任何跨越高速缓存行边界时更少。 因此,如果您可以将块按 64 位对齐,则完全没有缓存行拆分就非常高效。缓存未命中很慢,但是从 L1d 缓存重新加载热数据非常便宜,并且支持 AVX 的现代 CPU 通常具有高效的未对齐负载支持。
(就像我之前说的,如果使用 gcc,请确保使用 -march=haswell
或 -mtune=haswell
进行编译,而不仅仅是 -mavx
,以避免 gcc 的 -mavx256-split-unaligned-load
。)
4 loads + 1 vshufpd
(y0, y2) 可能是平衡负载端口压力与 ALU 压力的好方法,具体取决于周围代码中的瓶颈。甚至 3 次加载 + 2 次随机播放,如果周围代码对随机播放端口压力较低。
they are in registers from previous calculations which required them to be loaded.
如果之前的计算仍然在寄存器中有源数据,您可以首先完成 256 位加载,然后将它们的 128 位低半部分用于之前的计算。(XMM 寄存器是相应 YMM 寄存器的低位 128,读取它们不会打扰上层通道,因此 _mm256_castpd256_pd128
编译为零 asm 指令。)
对 y0、y2 和 y4 执行 256 位加载,并将它们的低半部分用作 x0、x1 和 x2。 (稍后使用未对齐的加载或混洗构造 y1 和 y3)。
只有 x3 不是您还需要的 256 位向量的低 128 位。
理想情况下,当您从同一地址执行 _mm_loadu_pd
和 _mm256_loadu_pd
时,编译器已经注意到此优化,但您可能需要通过执行
来手动处理它
__m256d y0 = _mm256_loadu_pd(base);
__m128d x0 = _mm256_castpd256_pd128(y0);
等等,以及提取 ALU 内在 (_mm256_extractf128_pd
) 或 x3
的 128 位加载,具体取决于周围的代码。如果它只需要一次,让它折叠成一个内存操作数以供任何指令使用它可能是最好的。
潜在的缺点:128 位计算开始之前的延迟稍高,或者如果 256 位加载是缓存线交叉,而 128 位加载不是,则延迟几个周期。但是如果你的数据块是按 64 字节对齐的,就不会发生这种情况。
有些问题的标题相似,但我的问题涉及一个其他地方未涉及的非常具体的用例。
我有 4 个 __128d 寄存器(x0、x1、x2、x3),我想将它们的内容重新组合到 5 个 __256d 寄存器(y0、y1、y2、y3、y4)中作为下面是其他计算的准备:
on entry:
x0 contains {a0, a1}
x1 contains {a2, a3}
x2 contains {a4, a5}
x3 contains {a6, a7}
on exit:
y0 contains {a0, a1, a2, a3}
y1 contains {a1, a2, a3, a4}
y2 contains {a2, a3, a4, a5}
y3 contains {a3, a4, a5, a6}
y4 contains {a4, a5, a6, a7}
下面我的实现速度很慢。有没有更好的方法?
y0 = _mm256_set_m128d(x1, x0);
__m128d lo = _mm_shuffle_pd(x0, x1, 1);
__m128d hi = _mm_shuffle_pd(x1, x2, 1);
y1 = _mm256_set_m128d(hi, lo);
y2 = _mm256_set_m128d(x2, x1);
lo = hi;
hi = _mm_shuffle_pd(x2, x3, 1);
y3 = _mm256_set_m128d(hi, lo);
y4 = _mm256_set_m128d(x3, x2);
有了寄存器中的输入,您可以用 5 条随机指令完成:
- 3x
vinsertf128
通过连接 2 个 xmm 寄存器来创建 y0、y2 和 y4。 - 2x
vshufpd
(通道内洗牌)在这些结果之间创建 y1 和 y3。
请注意,y0 和 y2 的低通道包含 a1 和 a2,即 y1 的低通道所需的元素。同样的洗牌也适用于高车道。
#include <immintrin.h>
void merge(__m128d x0, __m128d x1, __m128d x2, __m128d x3,
__m256d *__restrict y0, __m256d *__restrict y1,
__m256d *__restrict y2, __m256d *__restrict y3, __m256d *__restrict y4)
{
*y0 = _mm256_set_m128d(x1, x0);
*y2 = _mm256_set_m128d(x2, x1);
*y4 = _mm256_set_m128d(x3, x2);
// take the high element from the first vector, low element from the 2nd.
*y1 = _mm256_shuffle_pd(*y0, *y2, 0b0101);
*y3 = _mm256_shuffle_pd(*y2, *y4, 0b0101);
}
很好地编译 (with gcc and clang -O3 -march=haswell
on Godbolt) 到:
merge(double __vector(2), double __vector(2), double __vector(2), double __vector(2), double __vector(4)*, double __vector(4)*, double __vector(4)*, double __vector(4)*, double __vector(4)*):
vinsertf128 ymm0, ymm0, xmm1, 0x1
vinsertf128 ymm3, ymm2, xmm3, 0x1
vinsertf128 ymm1, ymm1, xmm2, 0x1
# vmovapd YMMWORD PTR [rdi], ymm0
vshufpd ymm0, ymm0, ymm1, 5
# vmovapd YMMWORD PTR [rdx], ymm1
vshufpd ymm1, ymm1, ymm3, 5
# vmovapd YMMWORD PTR [r8], ymm3
# vmovapd YMMWORD PTR [rsi], ymm0
# vmovapd YMMWORD PTR [rcx], ymm1
# vzeroupper
# ret
我注释掉了内联时会消失的存储和内容,因此我们确实只有 5 条随机播放指令,而您问题中的代码有 9 条随机播放指令。 (也包含在 Godbolt 编译器资源管理器中 link)。
这在 AMD 上非常 好,其中 vinsertf128
超级便宜(因为 256 位寄存器实现为 2x 128 位的一半,所以它只是不需要特殊洗牌端口的 128 位副本。)256 位通道交叉洗牌在 AMD 上很慢,但像 vshufpd
这样的通道内 256 位洗牌只有 2 微码。
在 Intel 上它非常好,但是带有 AVX 的主流 Intel CPU 对于 256 位或 FP 随机播放只有每时钟 1 个随机播放吞吐量。 (Sandybridge 和更早的整数 128 位洗牌具有更高的吞吐量,但 AVX2 CPU 丢弃了额外的洗牌单元,并且它们对此无济于事。)
所以英特尔 CPU 根本无法利用指令级并行性,但总共只有 5 微指令,这很好。这是最低限度,因为您需要 5 个结果。
但特别是如果周围的代码在随机播放方面也存在瓶颈,值得考虑一个 store/reload 仅包含 4 个存储和 5 个重叠向量加载的策略。或者可能是 2x vinsertf128
构造 y0
和 y4
,然后是 2x 256 位存储 + 3 次重叠重载。这可以让乱序执行仅使用 y0
或 y4
开始执行相关指令,而存储转发停顿已解决 y1..3.
特别是如果您不太关心英特尔第一代 Sandybridge,其中未对齐的 256 位矢量加载效率较低。 (请注意,如果您使用的是 GCC,则需要使用 gcc -mtune=haswell
进行编译以关闭 -mavx256-split-unaligned-load
默认/sandybridge 调整。无论编译器如何,-march=native
都是一个好主意如果在编译它的机器上将二进制文件制作成 运行,以充分利用指令集并设置调整选项。)
但是如果前端的总uop吞吐量更多是瓶颈所在,那么shuffle实现是最好的。
(请参阅 https://agner.org/optimize/ and other performance links in the x86 tag wiki for more about performance tuning. Also What considerations go into predicting latency for operations on modern superscalar processors and how can I calculate them by hand?,但 Agner Fog 的指南实际上是一个更深入的指南,解释了吞吐量与延迟的实际含义。)
I do not even need to save, as data is also already available in contiguous memory.
然后简单地加载 5 个重叠负载几乎肯定是您可以做的最有效的事情。
Haswell 可以从 L1d 每个时钟执行 2 次加载,或者当任何跨越高速缓存行边界时更少。 因此,如果您可以将块按 64 位对齐,则完全没有缓存行拆分就非常高效。缓存未命中很慢,但是从 L1d 缓存重新加载热数据非常便宜,并且支持 AVX 的现代 CPU 通常具有高效的未对齐负载支持。
(就像我之前说的,如果使用 gcc,请确保使用 -march=haswell
或 -mtune=haswell
进行编译,而不仅仅是 -mavx
,以避免 gcc 的 -mavx256-split-unaligned-load
。)
4 loads + 1 vshufpd
(y0, y2) 可能是平衡负载端口压力与 ALU 压力的好方法,具体取决于周围代码中的瓶颈。甚至 3 次加载 + 2 次随机播放,如果周围代码对随机播放端口压力较低。
they are in registers from previous calculations which required them to be loaded.
如果之前的计算仍然在寄存器中有源数据,您可以首先完成 256 位加载,然后将它们的 128 位低半部分用于之前的计算。(XMM 寄存器是相应 YMM 寄存器的低位 128,读取它们不会打扰上层通道,因此 _mm256_castpd256_pd128
编译为零 asm 指令。)
对 y0、y2 和 y4 执行 256 位加载,并将它们的低半部分用作 x0、x1 和 x2。 (稍后使用未对齐的加载或混洗构造 y1 和 y3)。
只有 x3 不是您还需要的 256 位向量的低 128 位。
理想情况下,当您从同一地址执行 _mm_loadu_pd
和 _mm256_loadu_pd
时,编译器已经注意到此优化,但您可能需要通过执行
__m256d y0 = _mm256_loadu_pd(base);
__m128d x0 = _mm256_castpd256_pd128(y0);
等等,以及提取 ALU 内在 (_mm256_extractf128_pd
) 或 x3
的 128 位加载,具体取决于周围的代码。如果它只需要一次,让它折叠成一个内存操作数以供任何指令使用它可能是最好的。
潜在的缺点:128 位计算开始之前的延迟稍高,或者如果 256 位加载是缓存线交叉,而 128 位加载不是,则延迟几个周期。但是如果你的数据块是按 64 字节对齐的,就不会发生这种情况。