【问题标题】:Efficient C vectors for generic SIMD (SSE, AVX, NEON) test for zero matches. (find FP max absolute value and index)用于通用 SIMD(SSE、AVX、NEON)的高效 C 向量测试零匹配。 (求FP最大绝对值和指数)
【发布时间】:2022-01-24 23:07:12
【问题描述】:

我想看看是否可以编写一些可以高效编译的通用 SIMD 代码。主要用于 SSE、AVX 和 NEON。该问题的简化版本是:找到浮点数数组的最大绝对值并返回值和索引。导致问题的是最后一部分,即最大值的索引。似乎没有一个很好的方法来编写具有分支的代码。

使用一些建议的答案查看最后的更新以获取完成的代码。

这是一个示例实现(godbolt 上的更完整版本):

#define VLEN 8
typedef float vNs __attribute__((vector_size(VLEN*sizeof(float))));
typedef int vNb __attribute__((vector_size(VLEN*sizeof(int))));
#define SWAP128 4,5,6,7, 0,1,2,3
#define SWAP64 2,3, 0,1,  6,7, 4,5
#define SWAP32 1, 0,  3, 2,  5, 4,  7, 6

static bool any(vNb x) {
    x = x | __builtin_shufflevector(x,x, SWAP128);
    x = x | __builtin_shufflevector(x,x, SWAP64);
    x = x | __builtin_shufflevector(x,x, SWAP32);
    return x[0];
}

float maxabs(float* __attribute__((aligned(32))) data, unsigned n, unsigned *index) {
    vNs max = {0,0,0,0,0,0,0,0};
    vNs tmax;
    unsigned imax = 0;
    for (unsigned i = 0 ; i < n; i += VLEN) {
        vNs t = *(vNs*)(data + i);
        t = -t < t ? t : -t;  // Absolute value
        vNb cmp = t > max;
        if (any(cmp)) {
            tmax = t; imax = i;
            // broadcast horizontal max of t into every element of max
            vNs tswap128 = __builtin_shufflevector(t,t, SWAP128);
            t = t < tswap128 ? tswap128 : t;
            vNs tswap64 = __builtin_shufflevector(t,t, SWAP64);
            t = t < tswap64 ? tswap64 : t;
            vNs tswap32 = __builtin_shufflevector(t,t, SWAP32);
            max = t < tswap32 ? tswap32 : t;
        }
    }
    // To simplify example, ignore finding index of true value in tmax==max
    *index = imax; // + which(tmax == max);
    return max[0];
}

godbolt 上的代码允许将 VLEN 更改为 8 或 4。

这大多工作得很好。对于 AVX/SSE,绝对值使用 (v)andps 变为 t &amp; 0x7fffffff,即清除符号位。对于 NEON,使用 vneg + fmaxnm 完成。查找和广播水平最大值的块变成了置换和最大值指令的有效序列。 gcc 能够使用 NEON fabs 作为绝对值。

4 元素 SSE/NEON 目标上的 8 元素向量在 clang 上运行良好。它在两组寄存器上使用一对指令,对于 SWAP128 水平运算将 maxor 两个寄存器没有任何不必要的置换。另一方面,gcc 确实无法处理此问题,并且主要生成非 SIMD 代码。如果我们将向量长度减少到 4,gcc 可以很好地用于 SSE 和 NEON。

但是if (any(cmp)) 有问题。对于 clang + SSE/AVX,它运行良好,vcmpltps + vptestorps 从 SSE 的 8->4 开始。

但是 NEON 上的 gcc 和 clang 会完成所有的置换和 OR,然后将结果移动到 gp 寄存器进行测试。

除了架构特定的内在函数之外,是否有一些代码可以通过 gcc 获得 ptest 以及通过 clang/gcc 和 NEON 获得 vmaxvq

我尝试了一些其他方法,例如if (x[0] || x[1] || ... x[7]),但效果更差。

更新

我创建了一个updated example,它显示了两种不同的实现,包括原始方法和 chtz 建议的“向量中的索引”方法,并在 Aki Suihkonen 的回答中显示。可以看到生成的 SSE 和 NEON 输出。

虽然有些人可能持怀疑态度,但编译器确实从通用 SIMD(不是自动矢量化!)C++ 代码生成了非常好的代码。在 SSE/AVX 上,我认为改进循环中的代码的空间很小。 NEON 版本仍然受到“any()”的次优实现的困扰。

除非数据通常按升序排列,或者几乎按升序排列,否则我的原始版本在 SSE/AVX 上仍然是最快的。我没有在NEON上测试过。这是因为大多数循环迭代都找不到新的最大值,最好针对这种情况进行优化。 “向量中的索引”方法产生更紧密的循环,编译器也做得更好,但在 SSE/AVX 上,常见情况只是慢了一点。 NEON 上的常见情况可能相同或更快。

关于编写通用 SIMD 代码的一些注意事项。

浮点向量的绝对值可以通过以下方式找到。它在 SSE/AVX(并带有清除符号位的掩码)和 NEON(fabs 指令)上生成最佳代码。

static vNs vabs(vNs x) {
    return -x < x ? x : -x;
}

这将在 SSE/AVX/NEON 上有效地实现垂直最大值。它不做比较;它会生成架构的“max”指令。在 NEON 上,将其更改为使用 &gt; 而不是 &lt; 会导致编译器生成非常糟糕的标量代码。我猜是有异常或异常的东西。

template <typename v>  // Deduce vector type (float, unsigned, etc.)
static v vmax(v a, v b) {
    return a < b ? b : a; // compiles best with "<" as compare op
}

此代码将通过寄存器广播水平最大值。它在 SSE/AVX 上编译得很好。在 NEON 上,如果编译器可以使用水平 max 指令然后广播结果可能会更好。令我印象深刻的是,如果在 SSE/NEON 上使用 8 个元素向量,它们只有 4 个元素寄存器,编译器足够聪明,只使用一个寄存器来广播结果,因为前 4 个元素和底部 4 个元素是相同的.

template <typename v>
static v hmax(v x) {
    if (VLEN >= 8)
        x = vmax(x, __builtin_shufflevector(x,x, SWAP128));
    x = vmax(x, __builtin_shufflevector(x,x, SWAP64));
    return vmax(x, __builtin_shufflevector(x,x, SWAP32));
}

这是我找到的最好的“any()”。它在 SSE/AVX 上是最佳的,使用单个 ptest 指令。在 NEON 上,它执行置换和 OR,而不是水平最大指令,但我还没有找到在 NEON 上得到更好的方法。

static bool any(vNb x) {
    if (VLEN >= 8)
        x |= __builtin_shufflevector(x,x, SWAP128);
    x |= __builtin_shufflevector(x,x, SWAP64);
    x |= __builtin_shufflevector(x,x, SWAP32);
    return x[0];
}

同样有趣的是,在 AVX 上,代码 i = i + 1 将被编译为 vpsubd ymmI, ymmI, ymmNegativeOne,即减去 -1。为什么?因为使用vpcmpeqd ymm0, ymm0, ymm0 会生成-1s 的向量,这比广播1s 的向量要快。

这是我想出的最好的which()。这为您提供了布尔向量中第一个真值的索引(0 = 假,-1 = 真)。使用 movemask 在 AVX 上可以做得更好。我不知道最好的 NEON。

// vector of signed ints
typedef int vNi __attribute__((vector_size(VLEN*sizeof(int))));
// vector of bytes, same number of elements, 1/4 the size
typedef unsigned char vNb __attribute__((vector_size(VLEN*sizeof(unsigned char))));
// scalar type the same size as the byte vector
using sNb = std::conditional_t<VLEN == 4, uint32_t, uint64_t>;
static int which(vNi x) {
    vNb cidx = __builtin_convertvector(x, vNb);
    return __builtin_ctzll((sNb)cidx) / 8u;
}

【问题讨论】:

  • 为什么要在循环内而不是在最后搜索向量的水平最大值?即,还保留一个 8 大小的索引寄存器,其中包含所有 k + 8*i 元素的最大值的索引(对于 k=0..8)。
  • 您也可以使用 movemask+popcount 指令进行任何操作,以便更快地找到位置(并进行检查)。我想这可能会更快,但这可能需要 VLEN 更大。
  • 最后我想到了水平最大值,但没有找到一种方法来跟踪哪个元素在哪个i。但是向量索引寄存器,它可以工作,并且可以避免if(any(cmp))(我仍然想优化)。变成max = cmp ? t : max;imax = cmp ? i : imax;,最后找到max的水平最大值和imax的对应元素。
  • 我还没有找到不使用特定于体系结构的内在函数来获取移动掩码的方法。 movemask + ctz 给出了我发现的最好的which(),它对any() 很有效。
  • 您的编辑看起来应该作为答案发布,而不是问题的一部分。

标签: c gcc simd sse neon


【解决方案1】:

正如 chtz 所说,最通用和最典型的方法是使用另一个掩码来收集索引:

Vec8s indices = { 0,1,2,3,4,5,6,7};
Vec8s max_idx = indices;
Vec8f max_abs = abs(load8(ptr)); 

for (auto i = 8; i + 8 <= vec_length; i+=8) { 
    Vec8s data = abs(load8(ptr[i]));
    auto mask = is_greater(data, max_abs);
    max_idx = bitselect(mask, indices, max_idx);
    max_abs = max(max_abs, data);    
    indices = indices + 8;
}

另一种选择是将值和索引交错:

auto data = load8s(ptr) & 0x7fffffff; // can load data as int32_t
auto idx = vec8s{0,1,2,3,4,5,6,7};
auto lo = zip_lo(idx, data);
auto hi = zip_hi(idx, data);

for (int i = 8; i + 8 <= size; i+=8) {
    idx = idx + 8;
    auto d1 = load8s(ptr + i) & 0x7fffffff;
    auto lo1 = zip_lo(idx, d1);
    auto hi1 = zip_hi(idx, d1);
    lo = max_u64(lo, lo1);
    hi = max_u64(hi, hi1);
}

如果输入范围小到足以将输入左移,同时将索引中的几位附加到同一单词的 LSB 位,这种方法尤其有利可图。

即使在这种情况下,我们也可以重新利用浮点数中的 1 位,从而节省一半的位/索引选择操作。

auto data0 = load8u(ptr) << 1; // take abs by shifting left 
auto data1 = (load8u(ptr + 8) << 1) + 1;  // encode odd index to data
auto mx = max_u32(data0, data1);  // the LSB contains one bit of index

看起来可以使用 double 作为存储,因为即使 SSE2 也支持 _mm_max_pd(需要注意 Inf/Nan 处理,当重新解释为64 位双精度的高位)。

【讨论】:

  • 我用另一个向量来收集索引进行了基准测试。这导致vmovd + vpbroadcastd 在寄存器中获取索引,vcmpps + vpblendvb 更新索引向量,vmaxps 更新最大数据向量。这是新的最大值与否的这 5 条指令。但是对于if(any(t &gt; max)),当没有新的最大值时,只有vcmppsvtestpsje。如果新的最大值很少,即按降序排列的数据,则速度会更快。
  • @TrentP:循环中不需要任何广播。您是否没有将idx 设置为像 Aki 显示的计数向量,并使用paddd 进行更新?我想看看它如何与 Agner Fog 的 vectorclass 包装器一起编译,这些包装器看起来有点像 GNU C 本机矢量语法:godbolt.org/z/nG8Ys7hb1 显示了一个非常合理的无分支循环,5 个 SIMD 指令(SKL 上的 6 个微指令来自索引 addr 模式的非分层在 vandps 上)和 2 uop 的循环开销(添加 + 宏融合 cmp/jcc)。瓶颈是 maxps dep 链;通过并行使用更多的累加器来解决这个问题。
  • (更正,vblendvps 在 Skylake 上是 2 uop,与 vpblendvb 相同。每个旧版 SSE 版本只有 1 uop,但是循环需要在某处进行额外的 movups。uops.info
  • 不,我没有使用当前索引的向量。我将对此进行重新基准测试。那应该将vmovd + vpbroadcastd 换成vpadd。更好,在没有新的 max 情况下还有更多指令,但没有分支,它们通常是更快的指令。
  • 仍然是类似的结果,如果最大值很少,使用if(any(t &gt; max)) 优化常见情况比跟踪向量中的最大索引并避免循环中的水平最大值更快。我认为移动和广播与添加并没有太大区别,因为可以隐藏广播的延迟。
【解决方案2】:

UPD:错过了 ABS

非常抱歉,我错过了定义中的绝对值。 我没有测量值,但这里有所有 3 个矢量化函数:

Asm 隐藏在 gist

关于方法

simd做max element的方法是先找值,再找索引。

或者,您必须保留索引寄存器并混合索引。 这就需要保留索引,做更多的操作,溢出的问题需要解决。

这是我在 avx2 上按类型(char、short 和 int)对 10'000 字节数据的计时

min_element 是我保留索引的实现。 reduce(min) + find 正在执行两个循环 - 首先获取值,然后查找位置。

对于整数(应该表现得像浮点数),两个循环解决方案的性能提高了 25%,至少根据我的测量结果。

为了完整起见,对这两种方法的标量进行比较 - 这绝对是一个应该向量化的操作。

怎么做

如果您将最大值写为 reduce,则会在所有平台上自动矢量化查找最大值

if (!arr.size()) return {};

// std::reduce is also ok, just showing for more C ppl
float res = arr[0];
for (int i = 1; i != (int)arr.size(); ++i) {
    res = res > arr[i] ? res : arr[i];
}

return res;

https://godbolt.org/z/EsazWf1vT

现在find 部分比较棘手,我所知道的编译器都没有自动向量化查找

我们有为您提供查找算法的eve 库:https://godbolt.org/z/93a98x6Tj

如果你想自己做的话,我会在talk 中解释如何实现查找。

更新: UPD2:将混合更改为最大

cmets 中的@Peter Cordes 说,如果数据更大,可能有必要进行一次性解决方案。 我没有这方面的证据 - 我的测量点是减少 + 查找。

但是,我大致了解了保持索引的外观(目前存在对齐问题,我们绝对应该在此处对齐读取) https://godbolt.org/z/djrzobEj4

AVX2 主循环:

.L6:
        vmovups ymm6, YMMWORD PTR [rdx]
        add     rdx, 32
        vcmpps  ymm3, ymm6, ymm0, 30
        vmaxps  ymm0, ymm6, ymm0
        vpblendvb       ymm3, ymm2, ymm1, ymm3
        vpaddd  ymm1, ymm5, ymm1
        vmovdqa ymm2, ymm3
        cmp     rcx, rdx
        jne     .L6

ARM-64 主循环:

.L6:
        ldr     q3, [x0], 16
        fcmgt   v4.4s, v3.4s, v0.4s
        fmax    v0.4s, v3.4s, v0.4s
        bit     v1.16b, v2.16b, v4.16b
        add     v2.4s, v2.4s, v5.4s
        cmp     x0, x1
        bne     .L6

如果 Godbolt 失效,链接到 ASM:https://gist.github.com/DenisYaroshevskiy/56d82c8cf4a4dd5bf91d58b053ea80f2

【讨论】:

  • 您正在使用小型阵列进行计时,对吗?数据的第二次循环仍然在 L1d 缓存中命中。对于大型数组,情况会有所不同,其中内存或 L3$ 带宽是瓶颈,并且您将有足够的 CPU 周期从内存中每个向量来更新索引向量。 (而且它还确保你不会消耗额外的系统带宽。)所以我想如果你的数组有时很小,有时很大,我想可能会根据大小来选择一个策略?
  • 对于短裤,我认为这很好 - 你在溢出之前达到 64k,所以每 64k 处理一次。如您所说,对于字符,可以转换为短裤。我做了完全忽略溢出的措施,reduce + find 仍然更快。如果您认为有用,我将发布带有索引的代码版本。
  • 对于字符,也许缓存阻止您的 2 循环想法:搜索高达 8K 或 16KiB,然后,如果它包含任何元素的新 max,请返回并查找类似的位置内存条(或者实际上调用memchr,这样你就可以使用现有的目标优化构建块。)我想这需要外循环中的水平最大值来知道要搜索哪个特定字符。或者 2k 或 4k 可能是很好的调整大小。
  • 那个.L7 循环似乎在vpcmpgtb 比较结果上使用vpblendvb(大多数CPU 上2 微指令)而不是vpmaxb,后者只有1 微指令并且可以并行运行。所以这很不幸。我认为缓存阻塞 2-pass 对于char 数据将是两全其美的。 (除非 L1d 带宽上的其他逻辑线程瓶颈而不是 shuffle 端口,并且这个 max-finding 并不是真的在关键路径上,所以可以运行得更慢但对其他线程更友好。IDK 如果有一个现实的这样做的用例。)
  • vpmaxsb 至少在 Skylake、Ice Lake、Alder Lake 和 Zen2 上显然更好。例如在 SKL 上,这可能是当今最常见的 uarch。 p01 为 1 uop,而 p015 为 2 uop。 (或 Haswell 上的端口更少)。在 Alder Lake P 核上,p015 为 3 微指令; E-cores 以 8 uop 运行。Zen2 以单个 uop 运行,但仅在一个端口上运行,而 vpmaxsb 为 3/clock。 uops.info/table.html 通常,变量混合不如简单的整数算术比较或 min/max 便宜,部分原因是它们有 3 个输入(以及用于单独输出的第 4 个操作数)。
【解决方案3】:

我不相信这是可能的。编译器不够聪明,无法有效地做到这一点。

将另一个答案(使用类似 NEON 的伪代码)与下面的 SSE 版本进行比较:

// Compare vector absolute value with aa, if greater update both aa and maxIdx
inline void updateMax( __m128 vec, __m128i idx, __m128& aa, __m128& maxIdx )
{
    vec = _mm_andnot_ps( _mm_set1_ps( -0.0f ), vec );
    const __m128 greater = _mm_cmpgt_ps( vec, aa );
    aa = _mm_max_ps( vec, aa );
    // If you don't have SSE4, emulate with bitwise ops: and, andnot, or
    maxIdx = _mm_blendv_ps( maxIdx, _mm_castsi128_ps( idx ), greater );
}

float maxabs_sse4( const float* rsi, size_t length, size_t& index )
{
    // Initialize things
    const float* const end = rsi + length;
    const float* const endAligned = rsi + ( ( length / 4 ) * 4 );

    __m128 aa = _mm_set1_ps( -1 );
    __m128 maxIdx = _mm_setzero_ps();
    __m128i idx = _mm_setr_epi32( 0, 1, 2, 3 );

    // Main vectorized portion
    while( rsi < endAligned )
    {
        __m128 vec = _mm_loadu_ps( rsi );
        rsi += 4;
        updateMax( vec, idx, aa, maxIdx );
        idx = _mm_add_epi32( idx, _mm_set1_epi32( 4 ) );
    }

    // Handle the remainder, if present
    if( rsi < end )
    {
        __m128 vec;
        if( length > 4 )
        {
            // The source has at least 5 elements
            // Offset the source pointer + index back, by a few elements
            const int offset = (int)( 4 - ( length % 4 ) );
            rsi -= offset;
            idx = _mm_sub_epi32( idx, _mm_set1_epi32( offset ) );
            vec = _mm_loadu_ps( rsi );
        }
        else
        {
            // The source was smaller than 4 elements, copy them into temporary buffer and load vector from there
            alignas( 16 ) float buff[ 4 ];
            _mm_store_ps( buff, _mm_setzero_ps() );
            for( size_t i = 0; i < length; i++ )
                buff[ i ] = rsi[ i ];
            vec = _mm_load_ps( buff );
        }

        updateMax( vec, idx, aa, maxIdx );
    }

    // Reduce to scalar
    __m128 tmpMax = _mm_movehl_ps( aa, aa );
    __m128 tmpMaxIdx = _mm_movehl_ps( maxIdx, maxIdx );
    __m128 greater = _mm_cmpgt_ps( tmpMax, aa );
    aa = _mm_max_ps( tmpMax, aa );
    maxIdx = _mm_blendv_ps( maxIdx, tmpMaxIdx, greater );

    // SSE3 has 100% market penetration in 2022
    tmpMax = _mm_movehdup_ps( tmpMax );
    tmpMaxIdx = _mm_movehdup_ps( tmpMaxIdx );
    greater = _mm_cmpgt_ss( tmpMax, aa );
    aa = _mm_max_ss( tmpMax, aa );
    maxIdx = _mm_blendv_ps( maxIdx, tmpMaxIdx, greater );

    index = (size_t)_mm_cvtsi128_si32( _mm_castps_si128( maxIdx ) );
    return _mm_cvtss_f32( aa );
}

如您所见,几乎所有事情都完全不同。不仅仅是关于余数和最终归约的样板,主循环也非常不同。

SSE 没有位选择; blendvps 不完全一样,它根据选择器的高位选择 32 位通道。与 NEON 不同,SSE 没有绝对值的指令,需要用 bitwise andnot 来模拟。

最终的减少也将完全不同。 NEON 的 shuffle 非常有限,但它具有更好的水平操作,例如 vmaxvq_f32,它在整个 SIMD 向量上找到水平最大值。

【讨论】:

  • 我不认为内部循环本身是根本上不同的。 SIMD条件选择是同一件事,无论是在选择器是0 / -1比较结果时,无论是位还是每个元素。循环后的清理很可能必须简化为可移植的东西,也许是非 SIMD,比如可能只是在 8 个元素的数组上存储和标量循环。
  • (vmaxvq_f32 不会告诉您最大值来自哪个索引,因此它不能单独使用;您是否考虑过使用 bcast_max == aa 来选择匹配的 FP 元素,然后使用 @987654326 @ 查找具有该最大值的槽,然后水平整数 min 查找输入数组中该最大值的第一次出现?)
  • @PeterCordes 我同意差异不是根本性的,但仍然非常重要。也许自动向量化在技术上是可行的,但是我们今天拥有的 AFAIK C++ 编译器并没有完全解决它。关于 NEON 的最终缩减——水平最大值、广播、FP32 比较小于、按位或与找到的索引向量,最后vminvq_u32 用于水平整数最小值,即 5 条指令,而 SSE 为 10 条。
  • Abs(用于最大比较)也可以通过将 float 重新解释为 int32_t、左移 1 的建议技巧来模拟;在 SSE2 中也将支持将 int32_t 与 int32_t (punpack{lo|hi}_epi32) 索引交错以生成可以在_mm_max_pd 中进行比较的double。 -- 这将是 SSE2 和 Neon 中的 6 条指令。
  • 嗯,Inf 和 Nans 将被排序,以便 NaN > Inf,但是然后 abs 不需要将符号位移出。 0x7f800000 == Inf in float 将是 0x7f80000000000000 in double,这只是一个很大的数字。
猜你喜欢
  • 2018-01-04
  • 2013-03-21
  • 1970-01-01
  • 1970-01-01
  • 2014-06-04
  • 1970-01-01
  • 1970-01-01
  • 2014-01-04
  • 2014-03-10
相关资源
最近更新 更多