在保持 YMM 部分不变的情况下对 XMM 寄存器执行 PSRLDQ 的正确内在序列是什么?

What is the correct intrinsic sequence to do PSRLDQ to an XMM register while keeping the YMM part unchanged?

假设 xmm0 是第一个参数,这就是我想要生成的代码类型。

psrldq xmm0, 1
vpermq ymm0, ymm0, 4eh
ret

我用内在函数写的。

__m256i f_alias(__m256i p) {
    *(__m128i *)&p = _mm_bsrli_si128(*(__m128i *)&p, 1);
    return _mm256_permute4x64_epi64(p, 0x4e);
}

这是clang的结果,没问题。

f_alias: #clang
        vpsrldq xmm1, xmm0, 1
        vperm2i128      ymm0, ymm0, ymm1, 33
        ret

但是 gcc 产生错误代码。

f_alias: #gcc
        push    rbp
        vpsrldq xmm2, xmm0, 1
        mov     rbp, rsp
        and     rsp, -32
        vmovdqa YMMWORD PTR [rsp-32], ymm0
        vmovdqa XMMWORD PTR [rsp-32], xmm2
        vpermq  ymm0, YMMWORD PTR [rsp-32], 78
        leave
        ret

我尝试了不同的版本。

__m256i f_insert(__m256i p) {
    __m128i xp = _mm256_castsi256_si128(p);
    xp = _mm_bsrli_si128(xp, 1);
    p = _mm256_inserti128_si256(p, xp, 0);
    return _mm256_permute4x64_epi64(p, 0x4e);
}

clang 生成相同的代码。

f_insert: #clang
        vpsrldq xmm1, xmm0, 1
        vperm2i128      ymm0, ymm0, ymm1, 33
        ret

但是gcc在翻译内在函数时过于直白。

f_insert: #gcc
        vpsrldq xmm1, xmm0, 1
        vinserti128     ymm0, ymm0, xmm1, 0x0
        vpermq  ymm0, ymm0, 78
        ret

用内部函数编写此操作的好方法是什么?如果可能的话,我想让 gcc 产生像 clang 这样的好代码。

一些附带问题。

  1. PSRLDQ 与 AVX 代码混合使用是否不好?像 clang 那样使用 VPSRLDQ 更好吗?如果使用 PSRLDQ 没有任何问题,这似乎是一种更简单的方法,因为它不会像 VEX 版本那样将 YMM 部分归零。
  2. 同时使用 FI 指令的目的是什么,它们似乎无论如何都做同样的工作,例如,VINSERTI128/VINSERTF128VPERMI128/VPERMF128?

我是个傻瓜。 clang 给了我一个答案,为什么我没有注意到?

vpsrldq xmm1, xmm0, 1
vperm2i128      ymm0, ymm0, ymm1, 33
ret

这个序列很简单,

__m256i f_____(__m256i p) {
    __m128i xp = _mm256_castsi256_si128(p);
    xp = _mm_bsrli_si128(xp, 1);
    __m256i _p = _mm256_castsi128_si256(xp);
    return _mm256_permute2x128_si256(p, _p, 0x21);
}

而且确实 gcc 也能产生高效的代码..

f_____:
        vpsrldq xmm1, xmm0, 1
        vperm2i128      ymm0, ymm0, ymm1, 33
        ret

Skylake 上的最佳 asm 将使用旧版 SSE psrldq xmm0, 1,其效果是将向量的其余部分作为数据依赖项处理而保持不变。 (在寄存器上,指令无论如何都会读取,因为这不是 movdqa 或其他东西)。但这将是 disastrous on Haswell, or on Ice Lake,当 legacy-SSE 指令在任何 YMM 具有“脏”上半部分时写入 XMM 寄存器时,两者都会向“已保存的上半部分”状态进行代价高昂的转换。我不确定 Zen1 或 Zen2/3/4... 如何处理它。


在 Skylake 上几乎一样好,在其他任何地方都是最佳的,是 copy-and-shift 然后 vpblendd 复制原始的高半部分,因为你不需要在 128- 之间移动任何数据位通道。 (您版本中的 _mm256_permute4x64_epi64(p, 0x4e); 与您在标题中询问的操作分开 lane-swap 。如果您还想要其他内容,请继续使用 vperm2i128 合并作为其中的一部分lane-swap。如果不是,就是一个错误。)

vpblendd 比任何 shuffle 更有效,能够 运行 在多个执行端口中的任何一个上,在 Intel CPU 上有 1 个周期延迟。 (Lane-crossing 像 vperm2i128 这样的洗牌在主流 Intel 上是 1 uop / 3 周期延迟,在 AMD 和 Alder Lake 的 E-cores 上明显更差。https://uops.info/)相比之下, 可变 带有矢量控制的混合通常更昂贵,但即时混合非常好。

是的,在某些 CPU 上使用 XMM (__m128i) 移位比移位两半然后与原始混合更有效。这将减少使用 cast intrinsics 的输入,但如果编译器没有优化它,你会在 Zen1 和 Alder Lake E-cores 上浪费 uops,其中 vpsrldq ymm 的每一半都需要一个单独的 uop .

__m256i rshift_lowhalf_by_1(__m256i v)
{
    __m128i low = _mm256_castsi256_si128(v);
   low = _mm_bsrli_si128(low, 1);
   return _mm256_blend_epi32(v, _mm256_castsi128_si256(low), 0x0F);
}

gcc/clang 使用 xmm byte-shift 和 YMM vpblendd 编译它(Godbolt)。 (Clang 翻转立即数并使用相反的源寄存器,但相同的区别。)

vpblendd 在 Zen1 上是 2 微指令,因为它必须处理向量的两半。对于特殊情况,解码器不会立即查看,例如保留整个矢量的一半。而且它仍然可以复制到一个单独的目的地,不一定会覆盖任何一个源 in-place。出于类似的原因,不幸的是,vinserti128 也是 2 微指令。 (vextracti128 在 Zen1 上只有 1 uop;我希望 vinserti128 只会是 1,并在检查 uops.info 之前写了以下版本):

// don't use on any CPU *except* Zen1, or an Alder Lake pinned to an E-core.
__m256i rshift_alder_lake_e(__m256i v)
{
    __m128i low = _mm256_castsi256_si128(v);
   low = _mm_bsrli_si128(low, 1);
   return _mm256_inserti128_si256(v, low, 0);  // still 2 uops on Zen1 or Alder Lake, same as vpblendd
    // clang optimizes this to vpblendd even with -march=znver1.  That's good for most uarches, break-even for Zen1, so that's fine.
}

Alder Lake E-cores 可能会有一点好处,其中 vinserti128 延迟被列为 [1;2] 而不是 vpblendd 的平坦 2 .但是由于任何 Alder Lake 系统也会有 P 核心,你实际上并不想要用户 vinserti128 因为它在其他方面都更糟糕。


What is the purpose of having both VINSERTI128/VPERMI128 and VINSERTF128/VPERMF128?

vinserti128 与内存源仅进行 128 位加载,vperm2i128 进行 256 位加载,这可能会跨越高速缓存行或页面边界以获取您甚至不去的数据使用。

在 load/store 执行单元只有 128 位宽数据路径缓存的 AVX CPU 上(如 Sandy/Ivy Bridge),这是一个显着的好处。

在洗牌单元只有 128 位宽的 CPU 上(如本答案中讨论的 Zen1),vperm2i128 的 2 个完整源输入和任意洗牌让它变得更昂贵(除非我猜你有更聪明的解码器,它们发出许多微指令来移动向量的一半,这取决于立即数)。

例如Zen1 的 vperm2i/f128 为 8 微指令,延迟为 2c,吞吐量为 3c!。 (具有 256 位执行单元的 Zen2 将其提高到 1 uop,3c 延迟,1c 吞吐量)。参见 https://uops.info/


What is the purpose of having both F and I instructions which seems to do the same job anyway

与往常一样 (dating back to stuff like SSE1 orps vs. SSE2 pxor / orpd),让 CPU 在 SIMD-integer 和 SIMD-FP.

上有不同的 bypass-forwarding 域

Shuffle 单元很昂贵,因此通常值得在 FP 和整数之间共享它们(英特尔现在这样做的方式在 vpaddd 指令之间使用 vperm2f128 时不会产生额外的延迟)。

但是例如混合很简单,所以可能有不同的 FP 和整数混合单元,并且 paddd 指令之间的 blendvps 有延迟惩罚。 (参见 https://agner.org/optimize/