在循环中广播 SIMD 寄存器的每个元素

Broadcasting each element of a SIMD register in a loop

我需要用另一个 SIMD 寄存器的一个元素填充一个 SIMD 寄存器。即 "broadcast" 或 "splat" 每个位置的单个元素。

我目前的代码是(它被简化了,我的真实函数被声明为 inline):

__m128
f4_broadcast_1(__m128 a, int i) {
    return _mm_set1_ps(a[i]);
}

这似乎在 clang 和 gcc 上生成了高效的代码,但 msvc 禁止索引访问。因此,我改为写:

__m128
f4_broadcast_2(__m128 a, int i) {
    union { __m128 reg; float f[4]; } r = { .reg = a };
    return _mm_set1_ps(r.f[i]);
}

它在 clang 和 gcc 上生成相同的代码,但在 msvc 上生成错误代码。神箭 link: https://godbolt.org/z/IlOqZl

有更好的方法吗?我知道关于 SO 已经有类似的问题,但我的用例涉及从寄存器中提取 float32 并将其放回另一个寄存器,这是一个稍微不同的问题。如果您可以在完全不接触主存储器的情况下执行此操作,那就太棒了。

索引是变量还是常量? 显然,它是否对 SIMD 性能很重要。在我的例子中,索引是一个循环变量:

for (int i = 0; i < M; i++) {
    ... broadcast element i of some reg
}

其中 M 是 4、8 或 16。也许我应该手动展开循环以使其成为常量? for循环中有很多代码,因此代码量会大大增加。

我也想知道除了现代 cpu:s.

上的 __m256__m512 寄存器如何做同样的事情

广播可以使用AVX2指令实现VBROADCASTSS,但是将值移动到输入位置(第一个位置)取决于你的指令集:

VBROADCASTSS (128 bit version VEX and legacy)

该指令将源 XMM 寄存器位置 [0] 上的源值广播到目标 XMM 寄存器的所有四个 FLOATS。它的内在是 __m128 _mm_broadcastss_ps(__m128 a);.

如果你的值的位置是不变的,你可以使用指令PSHUFD将值从当前位置移动到第一个位置。它的内在是__m128i _mm_shuffle_epi32(__m128i a, int n)。要将应广播的值移动到输入 XMM 向量的第一个位置,请对 int n 使用以下值:

1. : 0h
2. : 1h
3. : 2h
4. : 3h

这会将值从 0..3 位置移动到第一个位置。
因此,例如,使用以下内容将 input 向量的第四个位置移动到第一个:

__m128 newInput = _mm_shuffle_epi32(__m128i input, 3)

然后应用以下内在函数:

__m128 result = _mm_broadcastss_ps(__m128 newInput);

现在 input XMM 向量的第四个位置的值应该在 result 向量的所有位置上。

中的一些 shuffle 可以调整为广播一个元素,而不是只获取 1 个副本(如果它是低元素)。它更详细地讨论了随机播放与 store/reload 策略的权衡。


x86 在 AVX vpermilps 和 AVX2 车道交叉 vpermps / vpermd 之前没有 32 位元素变量控制洗牌。例如

// for runtime-variable i.  Otherwise use something more efficient.
_mm_permutevar_ps(v, _mm_set1_epi32(i));

或用vbroadcastss广播低元素(矢量源版本需要AVX2)

广播负载对于AVX1非常有效:_mm_broadcast_ss(float*)(或相同的_mm256/512)或简单的128/256/512 _mm_set1_ps(float) 恰好来自内存的浮点数,如果在启用 AVX1 的情况下进行编译,则让您的编译器使用广播加载。


使用编译时间常数控件,您可以使用 SSE1 广播任何单个元素
_mm_shuffle_ps(same,same, _MM_SHUFFLE(i,i,i,i));

或者对于整数,SSE2 pshufd: _mm_shuffle_epi32(v, _MM_SHUFFLE(i,i,i,i)).

根据您的编译器,i 可能必须是宏才能成为禁用优化的编译时常量。 shuffle-control 常量必须编译成嵌入机器代码中的立即字节(具有 4x 2 位字段),而不是作为数据或从寄存器加载。


在循环中迭代元素。

我在这部分使用的是 AVX2;这很容易适应 AVX512。如果没有 AVX2,store/reload 策略是 256 位向量的唯一好选择,或者 vpermilps 是 128 位向量的唯一好选择。

可能为 SSSE3 pshufb(在 __m128i__m128 之间进行转换)增加计数器(增加 4)`在没有 AVX 的情况下可能是个好主意有效的广播负载。

the index is a loop variable

编译器通常会为您完全展开循环,将循环变量转换为每次迭代的编译时常量。但只有在启用优化的情况下。在 C++ 中,您可以使用模板递归来迭代 constexpr.

MSVC 不优化内在函数,所以如果你写 _mm_permutevar_ps(v, _mm_set1_epi32(i)); 你实际上会在每次迭代中得到它,不是 4x vshufps .但是 gcc,尤其是 clang 确实优化了洗牌,所以他们应该在启用优化的情况下做得很好。

It's a lot of code in the for-loop

如果需要大量寄存器/花费大量时间,store/reload 可能是一个不错的选择,尤其是在 AVX 可用于广播重新加载的情况下。在当前的 Intel CPU 上,Shuffle 吞吐量(1/时钟)比加载吞吐量(2/时钟)更有限。

使用 AVX512 编译您的代码甚至允许广播内存源操作数,而不是单独的加载指令,因此如果只需要一次,编译器甚至可以将广播加载折叠到源操作数中。

/*********   Store/reload strategy ****************/
#include <stdalign.h>

void foo(__m256 v) {
   alignas(32)  float tmp[8];
   _mm256_store_ps(tmp, v);

   // with only AVX1, maybe don't peel first iteration, or broadcast manually in 2 steps
   __m256 bcast = _mm256_broadcastss_ps(_mm256_castps256_ps128(v));  // AVX2 vbroadcastss ymm, xmm
    ... do stuff with bcast ...

    for (int i=1; i<8 ; i++) {
        bcast = _mm256_broadcast_ss(tmp[i]);
        ... do stuff with bcast ...
    }
}

我手动剥离了第一次迭代,以仅通过 ALU 操作(较低的延迟)广播低元素,以便它可以立即开始。之后的迭代然后使用广播负载重新加载。

另一种选择是使用 SIMD 增量进行矢量随机播放控制(也称为掩码),如果您有 AVX2。

// Also AVX2
void foo(__m256 v) {

   __m256i shufmask = _mm256_setzero_si256();

    for (int i=1; i<8 ; i++) {
        __m256 bcast = _mm256_permutevar8x32_ps(v, shufmask);    // AVX2 vpermps
        // prep for next iteration by incrementing the element selectors
        shufmask = _mm256_add_epi32(shufmask, _mm256_set1_epi32(1));

        ... do stuff with bcast ...

    }
}

这在 shufmask 上做了一个冗余 vpaddd(在最后一次迭代中),但这可能很好并且比剥离第一次或最后一次迭代更好。显然比从 -1 开始并在第一次迭代中的随机播放之前进行添加要好。

车道交叉洗牌在 Intel 上有 3 个周期的延迟,所以将它放在洗牌之后可能是很好的调度,除非有其他不依赖于 bcast 的每次迭代工作;无论如何,乱序执行使这成为一个小问题。在第一次迭代中,vpermps 的掩码只是异或归零,基本上与 Intel 上的 vbroadcastss 一样好,乱序执行可以快速启动。

但是在 AMD CPU 上(至少在 Zen2 之前),车道交叉 vpermps 非常慢;粒度小于 128 位的跨车道洗牌非常昂贵,因为它必须解码为 128 位微指令。所以这个策略对 AMD 来说并不好。如果 store/reload 在 Intel 上对您周围的代码执行同样的操作,那么让您的代码也对 AMD 友好可能是更好的选择。

vpermps 还引入了一个与 AVX512 内在函数一起引入的新内在函数:_mm256_permutexvar_ps(__m256i idx, __m256 a),其操作数的顺序与 asm 匹配。如果您的编译器支持新版本,请使用您喜欢的任何一个。