有没有更有效的方法将 4 个连续的双精度广播到 4 个 YMM 寄存器中?

Posted

技术标签:

【中文标题】有没有更有效的方法将 4 个连续的双精度广播到 4 个 YMM 寄存器中?【英文标题】:Is there a more efficient way to broadcast 4 contiguous doubles into 4 YMM registers? 【发布时间】:2014-05-13 13:54:34 【问题描述】:

在一段类似于(但不完全是)矩阵乘法的 C++ 代码中,我将 4 个连续的双精度值加载到 4 个 YMM 寄存器中,如下所示:

# a is a 64-byte aligned array of double
__m256d b0 = _mm256_broadcast_sd(&b[4*k+0]);
__m256d b1 = _mm256_broadcast_sd(&b[4*k+1]);
__m256d b2 = _mm256_broadcast_sd(&b[4*k+2]);
__m256d b3 = _mm256_broadcast_sd(&b[4*k+3]);

我在 Sandy Bridge 机器上使用 gcc-4.8.2 编译了代码。硬件事件计数器 (Intel PMU) 表明 CPU 实际上从 L1 缓存发出 4 个单独的负载。尽管此时我不受 L1 延迟或带宽的限制,但我很想知道是否有一种方法可以通过一个 256 位负载(或两个 128 位负载)加载 4 个双打并将它们洗牌4 个 YMM 寄存器。我查看了Intel Intrinsics Guide,但找不到完成所需改组的方法。这可能吗?

(如果CPU不合并4个连续加载的前提实际上是错误的,请告诉我。)

【问题讨论】:

您可以执行 2 x _mm256_broadcast_pd 和 4 x _mm256_shuffle_pd - 您节省了两个负载但添加了两个指令。不过,我怀疑它会产生很大的不同。 @PaulR,我使用 2x mm256_permute2f128_pd 和 4x _mm256_permute_pd 发布了一个答案。不知道是不是比你的建议好。但在紧密的循环中,负载可能是杀手。 谢谢你们。目前这稍微减慢了我的代码,但它确实将负载数量减少到 1/4。当我受到负载的限制时,这种方法将非常有用。 在 Haswell 及更高版本上:广播负载 (_mm256_broadcast_sd) 具有更大的优势,请参阅我的回答。 【参考方案1】:

TL;DR: 最好只使用_mm256_set1_pd() 进行四次广播加载。 这在 Haswell 和更高版本上非常好,vbroadcastsd ymm,[mem] 不需要 ALU shuffle 操作,通常也是 Sandybridge/Ivybridge 的最佳选择(它是 2-uop 加载 + shuffle 指令) .

这也意味着您根本不需要关心对齐,除了 double 的自然对齐。

与执行两步加载 + 随机播放相比,第一个向量准备就绪更快,因此当第一个向量仍在加载时,使用这些向量的代码可能会开始乱序执行。 AVX512 甚至可以将广播加载折叠到 ALU 指令的内存操作数中,因此这样做将允许重新编译以稍微利用具有 256b 向量的 AVX512。


(通常最好使用set1(x),而不是_mm256_broadcast_sd(&x);如果vbroadcastsd的AVX2-only register-source形式不可用,编译器可以选择存储->广播加载或做两次随机播放。您永远不知道内联何时意味着您的代码将在已经在寄存器中的输入上运行。)

如果您确实在加载端口资源冲突或吞吐量方面遇到瓶颈,而不是总 uops 或 ALU / shuffle 资源,则将一对 64->256b 广播替换为 16B->32B 广播负载可能会有所帮助(vbroadcastf128/_mm256_broadcast_pd)和两个通道内随机播放(vpermilpdvunpckl/hpd (_mm256_shuffle_pd))。

或使用 AVX2:加载 32B 并使用 4 个_mm256_permute4x64_pd shuffle 将每个元素广播到一个单独的向量中。


来源Agner Fog's insn tables (and microarch pdf):

英特尔 Haswell 及更高版本:

vbroadcastsd ymm,[mem] 和其他广播加载 insn 是完全由加载端口处理的 1uop 指令(广播“免费”发生)。

以这种方式进行四次广播加载的总成本是 4 条指令。融合域:4uop。未融合域:p2/p3 为 4 微指令。吞吐量:每个周期两个向量。

Haswell 在端口 5 上只有一个 shuffle 单元。使用 load+shuffle 进行所有广播加载将成为 p5 的瓶颈。

最大广播吞吐量可能是vbroadcastsd ymm,m64 和随机播放的混合:

## Haswell maximum broadcast throughput with AVX1
vbroadcastsd    ymm0, [rsi]
vbroadcastsd    ymm1, [rsi+8]
vbroadcastf128  ymm2, [rsi+16]     # p23 only on Haswell, also p5 on SnB/IvB
vunpckhpd       ymm3, ymm2,ymm2
vunpcklpd       ymm2, ymm2,ymm2
vbroadcastsd    ymm4, [rsi+32]     # or vaddpd ymm0, [rdx+something]
#add             rsi, 40

这些寻址模式中的任何一个都可以是双寄存器索引寻址模式,因为they don't need to micro-fuse to be a single uop。

AVX1:每 2 个周期 5 个向量,使 p2/p3 和 p5 饱和。 (忽略 16B 负载上的高速缓存行拆分)。 6 个融合域微指令,每 2 个周期只留下 2 个微指令来使用 5 个向量……真正的代码可能会使用一些负载吞吐量来加载其他东西(例如,来自另一个阵列的非广播 32B 负载,可能作为ALU 指令的内存操作数),或者为存储留出空间来窃取 p23 而不是使用 p7。

## Haswell maximum broadcast throughput with AVX2
vmovups         ymm3, [rsi]
vbroadcastsd    ymm0, xmm3         # special-case for the low element; compilers should generate this from _mm256_permute4x64_pd(v, 0)
vpermpd         ymm1, ymm3, 0b01_01_01_01   # NASM syntax for 0x99
vpermpd         ymm2, ymm3, 0b10_10_10_10
vpermpd         ymm3, ymm3, 0b11_11_11_11
vbroadcastsd    ymm4, [rsi+32]
vbroadcastsd    ymm5, [rsi+40]
vbroadcastsd    ymm6, [rsi+48]
vbroadcastsd    ymm7, [rsi+56]
vbroadcastsd    ymm8, [rsi+64]
vbroadcastsd    ymm9, [rsi+72]
vbroadcastsd    ymm10,[rsi+80]    # or vaddpd ymm0, [rdx + whatever]
#add             rsi, 88

AVX2:每 4 个周期 11 个向量,使 p23 和 p5 饱和。 (忽略 32B 负载的缓存行拆分...)。融合域:12 微指令,超出此范围后每 4 个周期留下 2 微指令。

我认为 32B 未对齐负载在性能方面比未对齐的 16B 负载(如 vbroadcastf128)更脆弱。


英特尔 SnB/IvB:

vbroadcastsd ymm, m64 是 2 个融合域微指令:p5(随机播放)和 p23(加载)。

vbroadcastss xmm, m32movddup xmm, m64 是单 uop 仅加载端口。有趣的是,vmovddup ymm, m256也是单uop load-port-only指令,但和所有256b加载一样,它占用一个加载端口2个周期。它仍然可以在第二个周期生成一个存储地址。不过,这个 uarch 不能很好地处理未对齐的 32B 负载的缓存行拆分。 gcc 默认使用 movups / vinsertf128 进行未对齐的 32B 加载,-mtune=sandybridge / -mtune=ivybridge

4x 广播负载:8 个融合域微指令:4 个 p5 和 4 个 p23。吞吐量:每 4 个周期 4 个向量,端口 5 出现瓶颈。在同一周期内来自同一缓存行的多个加载不会导致缓存库冲突,因此这远不会使加载端口饱和(存储地址也需要一代)。这只发生在同一个周期内的两个不同高速缓存行的同一组上。

如果 uop-cache 是冷的,那么对于解码器来说,多条 2-uop 指令之间没有其他指令是最坏的情况,但是一个好的编译器会在它们之间混合使用单 uop 指令。

SnB 有 2 个 shuffle 单元,但只有 p5 上的一个可以处理 AVX 中具有 256b 版本的 shuffle。使用 p1 integer-shuffle uop 将 double 广播到 xmm 寄存器的两个元素不会让我们得到任何结果,因为 vinsertf128 ymm,ymm,xmm,i 需要 p5 shuffle uop。

## Sandybridge maximum broadcast throughput: AVX1
vbroadcastsd    ymm0, [rsi]
add             rsi, 8

每个时钟一个,使 p5 饱和但只使用 p23 的一半容量。

我们可以以多 2 个 shuffle uop 为代价节省一个负载 uop,吞吐量 = 每 3 个时钟两个结果:

vbroadcastf128  ymm2, [rsi+16]     # 2 uops: p23 + p5 on SnB/IvB
vunpckhpd       ymm3, ymm2,ymm2    # 1 uop: p5
vunpcklpd       ymm2, ymm2,ymm2    # 1 uop: p5

执行 32B 加载并使用 2x vperm2f128 解包 -> 4x vunpckh/lpd 如果商店参与竞争 p23 可能会有所帮助。

【讨论】:

【参考方案2】:

在我的matrix multiplication code 中,每个内核代码只需要使用一次广播,但如果你真的想在一条指令中加载四个双精度然后将它们广播到四个寄存器,你可以这样做

#include <stdio.h>
#include <immintrin.h>

int main() 
    double in[] = 1,2,3,4;
    double out[4];
    __m256d x4 = _mm256_loadu_pd(in);
    __m256d t1 = _mm256_permute2f128_pd(x4, x4, 0x0);
    __m256d t2 = _mm256_permute2f128_pd(x4, x4, 0x11);
    __m256d broad1 = _mm256_permute_pd(t1,0);
    __m256d broad2 = _mm256_permute_pd(t1,0xf);
    __m256d broad3 = _mm256_permute_pd(t2,0);
    __m256d broad4 = _mm256_permute_pd(t2,0xf);

    _mm256_storeu_pd(out,broad1);   
    printf("%f %f %f %f\n", out[0], out[1], out[2], out[3]);
    _mm256_storeu_pd(out,broad2);   
    printf("%f %f %f %f\n", out[0], out[1], out[2], out[3]);
    _mm256_storeu_pd(out,broad3);   
    printf("%f %f %f %f\n", out[0], out[1], out[2], out[3]);
    _mm256_storeu_pd(out,broad4);   
    printf("%f %f %f %f\n", out[0], out[1], out[2], out[3]);

编辑:这是基于 Paul R 建议的另一种解决方案。

__m256 t1 = _mm256_broadcast_pd((__m128d*)&b[4*k+0]);
__m256 t2 = _mm256_broadcast_pd((__m128d*)&b[4*k+2]);
__m256d broad1 = _mm256_permute_pd(t1,0);
__m256d broad2 = _mm256_permute_pd(t1,0xf);
__m256d broad3 = _mm256_permute_pd(t2,0);
__m256d broad4 = _mm256_permute_pd(t2,0xf);

【讨论】:

谢谢。这在 gcc-4.8.2 上完美运行,但 gcc-4.4.7 抱怨在 _mm256_permute_pd() 中“最后一个参数必须是 4 位立即数”。将 0xff 更改为 0xf 可以解决此问题。 关于您的编辑:需要类型转换(至少在 gcc-4.8.2 上)。即这有效:__m256d b0101 = _mm256_broadcast_pd((__m128d*)&amp;b[4*k]); @netvope,好的,我修复并测试了它。它有效!【参考方案3】:

这是基于 Z Boson 的原始答案(编辑前)构建的变体,使用两个 128 位加载而不是一个 256 位加载。

__m256d b01 = _mm256_castpd128_pd256(_mm_load_pd(&b[4*k+0]));
__m256d b23 = _mm256_castpd128_pd256(_mm_load_pd(&b[4*k+2]));
__m256d b0101 = _mm256_permute2f128_pd(b01, b01, 0);
__m256d b2323 = _mm256_permute2f128_pd(b23, b23, 0);
__m256d b0000 = _mm256_permute_pd(b0101, 0);
__m256d b1111 = _mm256_permute_pd(b0101, 0xf);
__m256d b2222 = _mm256_permute_pd(b2323, 0);
__m256d b3333 = _mm256_permute_pd(b2323, 0xf);

在我的情况下,这比使用一个 256 位加载稍快,可能是因为第一个置换可以在第二个 128 位加载完成之前开始。


编辑:gcc 编译两个加载和前两个置换成

vmovapd (%rdi),%xmm8
vmovapd 0x10(%rdi),%xmm4
vperm2f128 $0x0,%ymm8,%ymm8,%ymm1
vperm2f128 $0x0,%ymm4,%ymm4,%ymm2

Paul R 建议使用 _mm256_broadcast_pd() 可以写成:

__m256d b0101 = _mm256_broadcast_pd((__m128d*)&b[4*k+0]);
__m256d b2323 = _mm256_broadcast_pd((__m128d*)&b[4*k+2]);

编译成

vbroadcastf128 (%rdi),%ymm6
vbroadcastf128 0x10(%rdi),%ymm11

并且比执行两个 vmovapd+vperm2f128(已测试)更快。

在我的代码中,它由向量执行端口而不是 L1 缓存访问绑定,这仍然比 4 _mm256_broadcast_sd() 稍慢,但我认为 L1 带宽受限的代码可以从中受益匪浅。

【讨论】:

您可以通过使用 2 x _mm256_broadcast_pd 而不是两个负载和两个置换来保存前两个置换,这有效地使您的解决方案归结为我在上面 cmets 中的原始建议。我很想知道在性能方面与替代品相比如何。 @PaulR,我现在明白你的意思了。我根据您的建议更新了我的答案。我现在无法测试,但明天会。 感谢 PaulR。我没有意识到 _mm256_broadcast_pd() 也可以从内存中加载(我认为它只能从 XMM 寄存器中读取) 顺便说一句,在这种情况下,_mm256_permute_pd 和 _mm256_shuffle_pd 都编译成 vunpcklpd/vunpckhpd

以上是关于有没有更有效的方法将 4 个连续的双精度广播到 4 个 YMM 寄存器中?的主要内容,如果未能解决你的问题,请参考以下文章

如何从 PHP 中的双精度数组中计算第 n 个百分位数?

单精度、双精度各有几位小数?

代码指定的双精度和编译器选项双精度之间的差异

编译器使用的双精度表示的显式规范

打印精度高达小数点后 4 位的双精度数据类型 - Armadillo

格式化一个大的double以显示四位有效数字