在运行时从 simd 寄存器获取任意浮点数?

Get an arbitrary float from a simd register at runtime?

我想从 simd 寄存器访问任意浮点数。我知道我可以做这样的事情:

float get(const __m128i& a, const int idx){
    // editor's note: this type-puns the FP bit-pattern to int and converts to float
    return _mm_extract_ps(a,idx);
}

float get(const __m128i& a, const int idx){
    return _mm_cvtss_f32(_mm_shuffle_ps(a,_MM_SHUFFLE(0,0,0,idx));
}

甚至使用轮班而不是随机播放。问题是这些都需要在编译时知道 idx(shuffle、shift 和 extract 都需要 8 位立即数)。

我也可以使用 _mm_store_ps() 然后使用生成的数组来完成它,但这需要内存。有没有比这更快的方法?

编辑:忽略第一个代码片段,我想要那个位置的浮点数,而不是像 _mm_extract_ps returns.

这样的整数

首先你肯定不想要_mm_extract_ps,除非你想把FP打成int1.

但是无论如何,对于运行时变量索引,您可能不想分支到 select 具有正确 imm8 的指令。

source + asm output for gcc/icc/clang/msvc on the Godbolt compiler explorer 此答案中的所有功能。 包括(在底部)一些使用编译时常量 idx 的测试调用程序,这样您就可以看到在实际程序中发生内联 + 常量传播时会发生什么。 And/or 来自同一向量的两个索引(仅 gcc CSE 和从同一存储重新加载两次,其他编译器存储两次)。

Store/reload 使用 gcc/clang/ICC 进行优化(但可变 idx 版本的延迟更高)。 其他方法只能很好地优化带有 clang 的常量输入。 (clang 甚至可以看穿 pshufb 版本并将其变成 vshufps imm8vpermilps imm8,或者 idx=0 的空操作)。其他编译器做一些愚蠢的事情,比如用 vxorps 将向量归零并将其用作 vpermilps 控件!


128 位向量:如果您有 SSSE3 pshufb 或 AVX

,请使用变量洗牌

使用 AVX1,对于 128 位向量,使用 vpermilps,您只需 2 个 ALU 微指令即可完成,这是一个使用双字 [=203] 的变量洗牌=] 或元素,不像 pshufb.

这使您可以执行与 _mm_shuffle_ps 完全相同的随机播放(包括将低位元素复制到高位 3 个元素,这很好),但使用运行时索引而不是立即数。

// you can pass vectors by value.  Not that it matters when inlining
static inline
float get128_avx(__m128i a, int idx){
    __m128i vidx = _mm_cvtsi32_si128(idx);          // vmovd
    __m128  shuffled = _mm_permutevar_ps(a, vidx);  // vpermilps
    return _mm_cvtss_f32(shuffled);
}

gcc 和 clang 像这样为 x86-64 编译它(Godbolt 编译器资源管理器):

    vmovd           xmm1, edi
    vpermilps       xmm0, xmm0, xmm1
    ret

没有 AVX 但有 SSSE3,您可以为 pshufb 加载或创建掩码。索引 4 个 __m128i 向量的数组是相当常见的,尤其是使用 _mm_movemask_ps 结果作为索引时。但是这里我们只关心低32位的元素,所以我们可以做得更好。

事实上,模式的常规性质意味着我们可以使用两个 32 位立即数操作数通过乘法和加法来创建它。

static inline
float get128_ssse3(__m128 a, int idx) {
    const uint32_t low4 = 0x03020100, step4=0x04040404;
    uint32_t selector = low4 + idx*step4;
    __m128i vidx = _mm_cvtsi32_si128(selector);

    // alternative: load a 4-byte window into 0..15 from memory.  worse latency
    // static constexpr uint32_t shuffles[4] = { low4, low4+step4*1, low4+step4*2, low4+step4*3 };
    //__m128i vidx = _mm_cvtsi32_si128(shuffles[idx]);
    __m128i shuffled = _mm_shuffle_epi8(_mm_castps_si128(a), vidx);
    return _mm_cvtss_f32(_mm_castsi128_ps(shuffled));
}

-O3 -march=nehalem 的 gcc 输出(其他编译器也这样做,模块可能被浪费了 movaps):

get128_ssse3(float __vector(4), int):
    imul    edi, edi, 67372036        # 0x04040404
    add     edi, 50462976             # 0x03020100
    movd    xmm1, edi
    pshufb  xmm0, xmm1
    ret                     # with the float we want at the bottom of XMM0

因此,如果没有 AVX,store/reload 可以节省指令(和 uops),特别是如果编译器可以避免对索引进行符号扩展或零扩展。

从 idx 到结果的延迟 = imul(3) + add(1) + movd(2) + pshufb(1) 在来自 Core2(Penryn) 和更新版本的 Intel CPU 上。不过,从输入向量到结果的延迟仅为 pshufb。 (加上 Nehalem 上的旁路延迟延迟。)http://agner.org/optimize/


__m256 256 位向量:使用 AVX2 随机播放,否则可能 store/reload

与 AVX1 不同,AVX2 具有像 vpermps 这样的跨车道变量随机播放。 (AVX1 仅对整个 128 位通道进行立即洗牌。)我们可以使用 vpermps 作为 AVX1 vpermilps 的直接替代,从 256 位向量中获取元素。

vpermps 有两个内部函数(参见 Intel's intrinsics finder)。

  • _mm256_permutevar8x32_ps(__m256 a, __m256i idx):旧名称,操作数与 asm 指令相反。
  • _mm256_permutexvar_ps(__m256i idx, __m256 a):新名称,随 AVX512 引入,操作数顺序正确(匹配 asm 操作数顺序,与 _mm_shuffle_epi8_mm_permutevar_ps 相反). asm instruction-set reference manual entry只列出了这个版本,并且列出了错误的类型(__m256 i用于控制操作数)。

    gcc 和 ICC 在仅启用 AVX2 而不是 AVX512 的情况下接受此助记符。但不幸的是,clang 只接受 -mavx512vl(或 -march=skylake-avx512),所以你不能便携地使用它。因此,只需使用笨拙的 8x32 名称,它在任何地方都适用。

#ifdef __AVX2__
float get256_avx2(__m256 a, int idx) {
    __m128i vidx = _mm_cvtsi32_si128(idx);          // vmovd
    __m256i vidx256 = _mm256_castsi128_si256(vidx);  // no instructions
    __m256  shuffled = _mm256_permutevar8x32_ps(a, vidx256);  // vpermps
    return _mm256_cvtss_f32(shuffled);
}

    // operand order matches asm for the new name: index first, unlike pshufb and vpermilps
    //__m256  shuffled = _mm256_permutexvar_ps(vidx256, a);  // vpermps
#endif

_mm256_castsi128_si256 从技术上讲并没有留下上层通道未定义(因此编译器永远不需要花费指令零扩展),但无论如何我们不关心上层通道。

这编译为

    vmovd   xmm1, edi
    vpermps ymm0, ymm1, ymm0
     # vzeroupper        # these go away when inlining
     # ret

所以它在 Intel CPU 上非常棒,从输入向量到结果的延迟只有 3c,吞吐量成本为 2 uops(但两个 uops 都需要端口 5)。

AMD 上的车道交叉洗牌要贵得多。


Store/reload

store/reload实际上很好的情况:

  • 不带 AVX2 的 256 位向量,或不带 SSSE3 的 128 位向量。
  • 如果您需要同一向量中的 2 个或更多元素(但请注意,如果您实际调用 get128_reload,gcc 以外的编译器会存储多次。因此,如果您这样做,手动内联向量存储并对其进行多次索引。)
  • 当 ALU 端口压力(尤其是 shuffle 端口)成为问题时,吞吐量比延迟更重要。在 Intel CPU 上,movd xmm, eax 也在端口 5 上运行,因此它与 shuffle 竞争。但希望你只在内部循环之外使用标量提取,周围有很多代码可以做其他事情。

  • idx 通常是编译时常量并且您希望让编译器为您选择随机播放时。

一个糟糕的 idx 可能会使您的程序崩溃,而不仅仅是给您错误的元素。 将索引直接转换为随机播放控件的方法会忽略高位。

注意。在 Godbolt 示例中,ICC 可以使用 test_reload2

Store/reload 到本地数组对于吞吐量来说完全没问题(可能不是延迟),并且在典型的 CPU 上只有大约 6 个周期的延迟,这要归功于存储-转发。大多数 CPU 的前端吞吐量比矢量 ALU 多,因此如果您在 ALU 吞吐量而不是 store/load 吞吐量上接近瓶颈,那么在混合中加入一些 store/reload 一点也不坏。

宽存储可以转发到窄重新加载,但要遵守一些对齐约束。我认为在主流 Intel CPU 上,矢量的 4 或 8 个元素中的任何一个自然对齐的 dword 重新加载都很好,但您可以查看 Intel 的优化手册。请参阅 the x86 tag wiki 中的性能链接。

在 GNU C 中,您可以像数组一样索引向量。如果索引在内联后不是编译时常量,它将编译为 store/reload。

#ifdef __GNUC__                      // everything except MSVC
float get128_gnuc(__m128 a, int idx) {
    return a[idx]; 
    // clang turns it into idx&3
    // gcc compiles it exactly like get_reload
}
#endif

 # gcc8.1 -O3 -march=haswell
    movsx   rdi, edi                            # sign-extend int to pointer width
    vmovaps XMMWORD PTR [rsp-24], xmm0          # store into the red-zone
    vmovss  xmm0, DWORD PTR [rsp-24+rdi*4]      # reload

完全可移植的写入方式(256位版本)是:

float get256_reload(__m256 a, int idx) {
    // with lower alignment and storeu, compilers still choose to align by 32 because they see the store
    alignas(32) float tmp[8];
    _mm256_store_ps(tmp, a);
    return tmp[idx];
}

编译器需要多条指令来对齐函数的独立版本中的堆栈,但当然在内联之后,这只会发生在外部包含函数中,希望在任何小循环之外。

您可以考虑将 high/low 向量的一半与 vextractf128 和 128 位 vmovups 分开存储,就像 GCC 在不知道时对 _mm256_storeu_ps 所做的那样目的地已对齐,因为 tune=generic(有助于 Sandybridge 和 AMD)。这将避免需要 32 字节对齐的数组,并且对 AMD CPU 基本上没有不利影响。但在 Intel 上与对齐存储相比更糟,因为它需要额外的微指令,假设对齐堆栈的成本可以分摊到许多 get() 操作上。 (使用 __m256 的函数有时最终会对齐堆栈,因此您可能已经付出了代价。)除非您只针对 Bulldozer、Ryzen 和 Sandybridge 等进行调整,否则您可能应该只使用对齐数组。


脚注 1:_mm_extract_psreturns FP 位模式作为 int。底层 asm 指令 (extractps r/m32, xmm, imm8) 可用于将浮点数存储到内存,但不能将元素混洗到 XMM 寄存器的底部。这是 pextrd r/m32, xmm, imm8.

的 FP 版本

因此您的函数实际上是将整数位模式转换为 FP,使用编译器生成的 cvtsi2ss,因为 C 允许从 int 隐式转换为 float