x86-64 SIMD 机制“比较”8 位无符号整数,给出 +1 / 0 / -1 结果(符号)的向量?

x86-64 SIMD mechanism to "compare" 8-bit unsigned integers, giving a vector of +1 / 0 / -1 results (signum)?

提问人:Vitali 提问时间:9/7/2023 最后编辑:Peter CordesVitali 更新时间:9/10/2023 访问量:184

问:

假设我有两个无符号整数(8 位)打包寄存器 a 和 b。我想比较它们并返回 +1 表示 > b,0 表示 a=b,或返回 -1 表示< b。或者,距离也可以工作(即返回实际差值而不是 -1/+1)。

SIMD(大概是 AVX2)如何有效地实现这一点?我不能使用 AVX512,但很高兴知道这是否是 AVX512 功能。

SIMD AVX AVX512

评论

2赞 Peter Cordes 9/7/2023
你打算用 / / 结果的向量做什么?您可能需要将其处理回两个向量,一个用于等于与否,一个用于小于,以使用它来掩盖其他内容。(或者减去,如果你知道你的数字在+-127范围内,设置?无论如何,您可能必须首先生成一个和向量作为生成 -1/0/+1 向量的一部分,因此在大多数情况下,只需返回它们而不是压缩/解压缩。-10+1_mm_sign_epi8>==
0赞 Peter Cordes 9/7/2023
(或使用 AVX-512,两个面罩。AVX-512 添加了无符号整数比较,将 2 个 XOR 运算保存到 range-shift unsigned to signed for 。_mm256_cmpgt_epi8

答:

4赞 aqrit 9/7/2023 #1

a <=> b对于有符号字节将是(使用有符号饱和度减去)。 结果可以被 normailzed 用于_mm256_subs_epi8(a, b)-1 / 0 / 1_mm256_sign_epi8(_mm256_set1_epi8(-1), res)

无符号字节可以“范围转换”[1]到有符号字节:

const __m256i x80 = _mm256_set1_epi8(-128);
__m256i res = _mm256_subs_epi8(_mm256_xor_si256(a, x80), _mm256_xor_si256(b, x80));

绝对差异可以通过以下方式找到

_mm256_or_si256(_mm256_subs_epu8(a, b), _mm256_subs_epu8(b, a))

_mm256_sub_epi8(_mm256_max_epu8(a, b), _mm256_min_epu8(a, b)).


[1] 范围转换:

SSE2(或更高版本)通常具有有符号整数的指令,而没有任何相应的无符号整数指令(反之亦然)。

要在有符号和无符号范围之间转换 8 位整数,只需添加或减去 128。

Signed 8-bit integers have the range -128..127
Unsigned 8-bit integers have the range 0..255

这种转换的微优化是使用 XOR 翻转通道的最高有效位。128 是 (0x80) 在 2 的补码中。没有第 9 位,因此无需借用或携带。在某些体系结构上,逻辑运算可能在比算术运算更多的执行端口上运行。大多数 SSE2 指令都具有破坏性的双操作数形式,因此 XOR 允许编译器自由地将 x ^= y 重新排序为 y ^= x,这与减法不同。0b10000000

评论

0赞 Peter Cordes 9/8/2023
我认为您回答中通过翻转 MSB 来解释“范围转移”的部分很好。对于初学者来说,这是一个有用的一般概念,它不是魔术。(包括提到为什么 xor 而不是 add 或 sub,因为它可能在某些 AVX2 CPU 上的更多端口上运行,并且当唯一的非零位位于顶部时,add-without-carry 等同于 add。
0赞 Peter Cordes 9/8/2023
你的绝对不同使用需要无符号饱和减法,不是吗?它可能在比 min/max/sub 更少的端口上运行 :/没有,所以我认为这是 的错别字,而不是 .在 Zen 3/4 上,min/max/sub 在任何端口上运行。 仅在 2 个端口上运行。在 Intel 上,从 Skylake 开始,在 p01 上运行,与 相同,而 和 可以在任何端口上运行。因此,sub(max,min) 在 AMD 上可能更好,在 Intel 上不会更糟。uops.infoor(sub, sub)sub_epu8subs_epu8sub_epi8vpsubusbvpminubpsubusbsubor
1赞 Vitali 9/9/2023
接受答案,因为这是我问的。@PeterCordes通过告诉我直接使用 / / / 来回答我实际在做什么的根本问题方面是正确的。_mm256_cmpeq_epi8_mask_mm256_cmplt_epi8_mask_mm256_cmpgt_epi8_mask
0赞 Peter Cordes 9/9/2023
@Vitali:请注意,这是 AVX-512。AVX2 比较生成的是向量,而不是 .我猜你的意思是 _mm256_cmpgt_epi8' 和 ._mm256_cmplt_epi8_mask__m256i__mmask32cmpeq
0赞 Vitali 9/11/2023
是的。感谢您的更正
3赞 Homer512 9/7/2023 #2

这令人惊讶地乏味,因为无符号 8 位整数没有得到很好的支持,例如没有无符号比较。对于有符号的 8 位,这将是相当简单的。

使用英特尔内部函数,我想出了这个例程:

#include <immintrin.h>
#include <stdint.h>
#include <stddef.h>

void compare(int8_t* out, const uint8_t* a, const uint8_t* b, ptrdiff_t n)
{
    ptrdiff_t i;
    for(i = 0; i + 32 <= n; i += 32) {
        __m256i ai = _mm256_loadu_si256((const __m256i*)(a + i));
        __m256i bi = _mm256_loadu_si256((const __m256i*)(b + i));
        __m256i min_u = _mm256_min_epu8(ai, bi);
        /* Set all bits 1 (== -1) if a <= b */
        __m256i a_le_b = _mm256_cmpeq_epi8(min_u, ai);
        /* And the reverse */
        __m256i b_le_a = _mm256_cmpeq_epi8(min_u, bi);
        /*
         * Three cases:
         * 1. a == b: Both masks are set. Subtraction results in zero
         * 2. a < b: Left mask is -1, right is 0. Result is -1
         * 3. a > b: Left mask is 0, right is -1. 0 - (-1) == 1
         */
        __m256i result = _mm256_sub_epi8(a_le_b, b_le_a);
        _mm256_storeu_si256((__m256i*)(out + i), result);
    }
    if(n > 32) {
        /* overlapping iteration to deal with trailing elements */
        i = n - 32;
        __m256i ai = _mm256_loadu_si256((const __m256i*)(a + i));
        __m256i bi = _mm256_loadu_si256((const __m256i*)(b + i));
        __m256i min_u = _mm256_min_epu8(ai, bi);
        __m256i a_le_b = _mm256_cmpeq_epi8(min_u, ai);
        __m256i b_le_a = _mm256_cmpeq_epi8(min_u, bi);
        __m256i result = _mm256_sub_epi8(a_le_b, b_le_a);
        _mm256_storeu_si256((__m256i*)(out + i), result);
    }
    else if(n < 32) {
        /* Scalar loop if entire input is less than 1 vector */
        for(; i < n; ++i) {
            uint8_t ai = a[i], bi = b[i];
            out[i] = (ai >= bi) - (ai <= b);
        }
    }
}

主循环的主体编译为:

        vmovdqu ymm1, YMMWORD PTR [rdx+rax]
        vmovdqu ymm0, YMMWORD PTR [rsi+rax]
        vpminub ymm2, ymm0, ymm1
        vpcmpeqb        ymm0, ymm0, ymm2
        vpcmpeqb        ymm1, ymm1, ymm2
        vpsubb  ymm0, ymm0, ymm1
        vmovdqu YMMWORD PTR [rdi+rax], ymm0

GCC 在给定标量循环时,会提出相同的解决方案。但它对循环的内容非常敏感。它不能将 if-else-if-else 构造(或链接的三元运算符)简化为相同的代码。

AVX512系列

AVX512 使代码更加简单明了。这是 AVX512 的主循环

        __m512i ai = _mm512_loadu_si512((const __m512i*)(a + i));
        __m512i bi = _mm512_loadu_si512((const __m512i*)(b + i));
        __mmask64 a_ne_b = _mm512_cmp_epu8_mask(ai, bi, _MM_CMPINT_NE);
        __mmask64 a_gt_b = _mm512_cmp_epu8_mask(bi, ai, _MM_CMPINT_LT);
        /* -1 if a != b */
        __m512i result = _mm512_movm_epi8(a_ne_b);
        /* negate -1 to 1 if a > b */
        result = _mm512_mask_abs_epi8(result, a_gt_b, result);
        _mm512_storeu_si512((__m512i*)(out + i), result);

评论

1赞 Peter Cordes 9/7/2023
OP提到了对AVX-512的兴趣。AVX-512 添加无符号整数比较,例如 .但是,是的,在那之前,没有无符号的比较,只有像和具有无符号饱和度的东西。否则,您需要将范围转移到 signed 才能设置 ,或者 / 就像您正在做的那样。(我们真的需要吗?这是英特尔的 2-uop 指令。我想可能是的,如果没有,至少需要 2 条指令才能替换它。vpcmpub_mm256_cmp_epu8_mask(a, b, _MM_CMPINT_LT)minsubvpcmpgtbmineqvpblendvbvpternlogd
1赞 Peter Cordes 9/7/2023
来自 GCC 的冗余向量负载可能是 gcc.gnu.org/bugzilla/show_bug.cgi?id=97366 ,这似乎是 GCC8 回归。对于那个 MCVE,我报告说碰巧修复了 128 位向量而不是 256 位向量,类似于您发现可以在这里有所作为的方式。-march=haswell-march
0赞 Homer512 9/7/2023
@PeterCordes在制作 AVX512 版本时,我偶然发现了更快的 AVX2 版本和标量循环版本,GCC 可以正确编译为最佳代码。这也消除了冗余负载的错误。我不确定 AVX512 版本是否最佳。
0赞 njuffa 9/7/2023
(+1)看到这个问题,我冲掉了这个 AVX2 代码:却发现这个答案是多余的......__m256i signum_compare (__m256i a, __m256i b) { __m256i maxab = _mm256_max_epu8 (a, b); __m256i maxa = _mm256_cmpeq_epi8 (maxab, a); __m256i maxb = _mm256_cmpeq_epi8 (maxab, b); return _mm256_sub_epi8 (maxb, maxa); }
1赞 Peter Cordes 9/7/2023
@Homer512:对于 AVX-512,我没有看到比 2 倍比较加 2 个屏蔽操作(或可以在英特尔的任何端口上运行的 mov mask to vector)更好的了。但是我们可以将向量常数简化为零(生成成本最低,不需要即时常量或内存常量),或者实际上完全避免它们:2x 比较(ne 和 gt):对于不相等的元素,得到 /。合并屏蔽 ,或从零减去,以否定我们想要 +1 而不是 -1 的元素。(令人惊讶的是,没有 AVX-512 ?vpmovm2b0-1_mm512_mask_abs_epi8vpsignb
2赞 Peter Cordes 9/7/2023 #3

首先,您很少真正想要或需要具有 + / 0 / - 值的向量;使用它可能需要将其解码回两个掩码或其他东西,因此只需比较两次即可获得两个掩码。(例如 / 获得大于或小于向量,并直接在两个输入上获得相等向量。_mm256_min_epu8_mm256_cmpeq_epi8cmpeq

如果要使用它来有条件地否定或将另一个字节向量归零,请使用 aqrit 的答案的结果,该结果生成 +/0/- 向量。(令人惊讶的是,没有 AVX-512 ,所以在这种情况下你需要其他东西。_mm256_sign_epi8vpsignb

如果你真的想有条件地否定一些浮点数或其他东西,不要乘以 1.0 或 -1.0,只需用 XOR 翻转符号位即可。(并用 0 或 0xffffffff 掩码,在用 符号扩展结果后 )。vpcmpeqbvpmovsxbd


既然您询问了 AVX-512 版本:

我上面说的关于首先不创建 +/0/- 向量的内容对 AVX-512 来说是双倍的;使用其他操作的屏蔽版本可以让您几乎免费地应用 A,或者使用更少的指令,无论您实际想用它做什么。也就是说,我们可以看看它:__mmask64

AVX-512 的新无符号比较指令只能比较到掩码寄存器中,并将其具体化为 0 / -1 的向量需要另一条指令。(但至少这是一个廉价的指令,例如在英特尔的任何执行端口上运行,并且不需要已经加载任何向量常量。在 AVX-512 之前,唯一可用的 SIMD 整数比较是 equality 和 signed greater-than。(但是有符号和无符号的最小/最大指令,以及有符号和无符号饱和减法。

使用 AVX-512 做到这一点的一种方法仍然是比 aqrit 的指令更多,但会产生 0 / -1。

  • 2x 比较(无符号和 )得到 2 个掩码。negt

  • vpmovm2b从掩码中获取 / 对于 / 不相等的元素。0-1ne

  • 使用掩码进行合并遮罩以否定我们想要 +1 而不是 -1 的元素。_mm512_mask_abs_epi8gt

(参见 Homer512 的答案,了解 C 的内在版本;感谢您在评论中写下我发布的建议。以及该答案的早期修订版,用于使用合并掩码来混合 a 和 a 常量的版本。set1(-1)set1(1)

不幸的是,英特尔 CPU 只能在一个执行端口上运行 compare-into-mask,因此它没有我们想要的指令级并行性。但是,只要比较先运行,就可以了;在结果准备好之前,我们不能使用掩码,因此可以与第二次比较同时进行。negtvpmovm2b

在 AVX-512 中使用 256 位向量宽度(除非您的整个程序大量使用 512 位向量,这通常是一个好主意),您可以使用 AVX2 和按位 NOT ( with ) 实现向量,以获得 2 个周期的延迟,但代价是需要一个向量常数。(https://uops.info/)。 然而,实现起来很便宜,只是一个针对自己的寄存器。nevpcmpeqbvpxor-1-1vpcmpeqd

但是,对于 256 位向量,我认为具有 4 个 AVX2 指令的 Homer 版本可能是最好的,并且与这两种方式进行比较,然后减去不相交的掩码以否定其中一个中的元素,同时合并它们。我看不出有什么方法可以用 AVX-512 击败它。或者更好的是,如果你不需要 +1/0/-1,aqrit 的符号饱和减法会得到一个 +/0/- 向量。vpminub-1