将 64 位整数中的压缩 8 位整数并行减 1,SWAR 无硬件 SIMD

Subtracting packed 8-bit integers in an 64-bit integer by 1 in parallel, SWAR without hardware SIMD

如果我有一个 64 位整数,我将其解释为包含 8 个元素的压缩 8 位整数数组。我需要在处理溢出时从每个压缩整数中减去常量 1,而不是一个元素的结果影响另一个元素的结果。

我现在有这段代码,它可以工作,但我需要一个解决方案,它可以并行减去每个打包的 8 位整数,并且不进行内存访问。在 x86 上,我可以使用像 psubb 这样的 SIMD 指令,它并行地减去压缩的 8 位整数,但我正在编码的平台不支持 SIMD 指令。 (在这种情况下是 RISC-V)。

所以我正在尝试 SWAR (SIMD within a register) 手动取消 uint64_t 的字节之间的进位传播,做一些与此等效的事情:

uint64_t sub(uint64_t arg) {
    uint8_t* packed = (uint8_t*) &arg;

    for (size_t i = 0; i < sizeof(uint64_t); ++i) {
        packed[i] -= 1;
    }

    return arg;
}

我认为您可以使用按位运算符来做到这一点,但我不确定。我正在寻找不使用 SIMD 指令的解决方案。我正在寻找一种 C 或 C++ 的解决方案,它非常可移植,或者只是它背后的理论,所以我可以实现我自己的解决方案。

int subtractone(int x) 
{
    int f = 1; 

    // Flip all the set bits until we find a 1 at position y
    while (!(x & f)) { 
        x = x^f; 
        f <<= 1; 
    } 

    return x^f; // return answer but remember to flip the 1 at y
} 

你可以使用上面的按位运算来完成,你只需要将你的整数分成 8 位块来发送 8 次到这个函数。以下部分摘自How to split a 64-bit number into eight 8-bit values?,我在上面的函数中添加了

uint64_t v= _64bitVariable;
uint8_t i=0,parts[8]={0};
do parts[i++] = subtractone(v&0xFF); while (v>>=8);

它是有效的 C 或 C++,无论人们如何发现它

我要指出的是,一旦您开始处理多个 uint64_t,您编写的代码实际上会进行矢量化。

https://godbolt.org/z/J9DRzd

不确定这是否是您想要的,但它会并行执行 8 次减法:

#include <cstdint>

constexpr uint64_t mask = 0x0101010101010101;

uint64_t sub(uint64_t arg) {
    uint64_t mask_cp = mask;
    for(auto i = 0; i < 8 && mask_cp; ++i) {
        uint64_t new_mask = (arg & mask_cp) ^ mask_cp;
        arg = arg ^ mask_cp;
        mask_cp = new_mask << 1;
    }
    return arg;
}

解释:位掩码在每个 8 位数字中都以 1 开头。我们用我们的论点对它进行异或。如果我们在这个地方有一个1,我们减去1并且必须停止。这是通过将 new_mask 中的相应位设置为 0 来完成的。如果我们有一个 0,我们将它设置为 1 并且必须进行进位,所以该位保持 1 并且我们将掩码向左移动。你最好自己检查一下新面具的生成是否按预期工作,我想是这样,但第二个意见也不错。

PS:我实际上不确定在循环中检查 mask_cp 是否不为 null 可能会减慢程序速度。没有它,代码仍然是正确的(因为 0 掩码什么都不做)并且编译器进行循环展开会容易得多。

如果您的 CPU 具有高效的 SIMD 指令,SSE/MMX paddb (_mm_add_epi8) 也是可行的。 还描述了 GNU C (gcc/clang) 向量语法和严格别名 UB 的安全性。我也强烈建议您查看该答案。

uint64_t 自己做是完全可移植的,但在使用 uint64_t* 访问 uint8_t 数组时仍然需要注意避免对齐问题和严格别名 UB。您已经从 uint64_t 中的数据开始,将这部分排除在外,但是对于 GNU C,may_alias typedef 解决了问题(参见 Peter 的回答或 memcpy)。

否则,您可以将数据分配/声明为 uint64_t 并在需要单个字节时通过 uint8_t* 访问它。 unsigned char* 允许为任何东西起别名,从而回避 8 位元素的特定情况下的问题。 (如果 uint8_t 存在,则可以安全地假设它是一个 unsigned char。)


请注意,这是对先前错误算法的更改(请参阅修订历史记录)。

无需循环即可实现任意减法,并且对于每个字节中的已知常量(如 1)效率更高。 主要技巧是防止进位 -通过设置高位从每个字节中取出,然后校正减法结果。

我们将稍微优化给定 here 的减法技术。他们定义:

SWAR sub z = x - y
    z = ((x | H) - (y &~H)) ^ ((x ^~y) & H)

H 定义为 0x8080808080808080U(即每个压缩整数的 MSB)。对于减量,y0x0101010101010101U.

我们知道 y 的所有 MSB 都清除了,所以我们可以跳过掩码步骤之一(即 y & ~H 在我们的例子中与 y 相同)。计算过程如下:

  1. 我们将 x 的每个组件的 MSB 设置为 1,这样借位就无法通过 MSB 传播到下一个组件。称其为调整后的输入。
  2. 我们通过从校正后的输入中减去 0x01010101010101,从每个分量中减去 1。由于步骤 1,这不会导致组件间借用。将此称为调整后的输出。
  3. 我们现在需要更正结果的 MSB。我们将调整后的输出与原始输入的反转 MSB 进行异或,以完成对结果的修复。

操作可以写成:

#define U64MASK 0x0101010101010101U
#define MSBON 0x8080808080808080U
uint64_t decEach(uint64_t i){
      return ((i | MSBON) - U64MASK) ^ ((i ^ MSBON) & MSBON);
}

最好由编译器内联(使用 compiler directives 强制这样做),或者表达式作为另一个函数的一部分内联编写。

测试用例:

in:  0000000000000000
out: ffffffffffffffff

in:  f200000015000013
out: f1ffffff14ffff12

in:  0000000000000100
out: ffffffffffff00ff

in:  808080807f7f7f7f
out: 7f7f7f7f7e7e7e7e

in:  0101010101010101
out: 0000000000000000

性能详情

这是函数的单次调用的 x86_64 程序集。为了获得更好的性能,应该内联它,希望常量可以尽可能长地存在于寄存器中。在常量存在于寄存器中的紧密循环中,实际递减需要五个指令:优化后的 or+not+and+add+xor。我看不到可以超越编译器优化的替代方案。

uint64t[rax] decEach(rcx):
    movabs  rcx, -9187201950435737472
    mov     rdx, rdi
    or      rdx, rcx
    movabs  rax, -72340172838076673
    add     rax, rdx
    and     rdi, rcx
    xor     rdi, rcx
    xor     rax, rdi
    ret

通过对以下代码段进行一些 IACA 测试:

// Repeat the SWAR dec in a loop as a microbenchmark
uint64_t perftest(uint64_t dummyArg){
    uint64_t dummyCounter = 0;
    uint64_t i = 0x74656a6d27080100U; // another dummy value.
    while(i ^ dummyArg) {
        IACA_START
        uint64_t naive = i - U64MASK;
        i = naive + ((i ^ naive ^ U64MASK) & U64MASK);
        dummyCounter++;
    }
    IACA_END
    return dummyCounter;
}


我们可以证明,在 Skylake 机器上,执行递减、异或和比较+跳转可以在每次迭代不到 5 个周期的情况下执行:

Throughput Analysis Report
--------------------------
Block Throughput: 4.96 Cycles       Throughput Bottleneck: Backend
Loop Count:  26
Port Binding In Cycles Per Iteration:
--------------------------------------------------------------------------------------------------
|  Port  |   0   -  DV   |   1   |   2   -  D    |   3   -  D    |   4   |   5   |   6   |   7   |
--------------------------------------------------------------------------------------------------
| Cycles |  1.5     0.0  |  1.5  |  0.0     0.0  |  0.0     0.0  |  0.0  |  1.5  |  1.5  |  0.0  |
--------------------------------------------------------------------------------------------------

(当然,在 x86-64 上,您只需将 movq 加载到 paddb 的 XMM reg 中,因此查看它如何为 ISA 编译可能更有趣像 RISC-V。)

完全单独处理每个字节,然后将其放回原处。

uint64_t sub(uint64_t arg) {
   uint64_t res = 0;

   for (int i = 0; i < 64; i+=8) 
     res += ((arg >> i) - 1 & 0xFFU) << i;

    return res;
   }

你可以确保减法没有溢出然后修复高位:

uint64_t sub(uint64_t arg) {
    uint64_t x1 = arg | 0x80808080808080;
    uint64_t x2 = ~arg & 0x80808080808080;
    // or uint64_t x2 = arg ^ x1; to save one instruction if you don't have an andnot instruction
    return (x1 - 0x101010101010101) ^ x2;
}

对于 RISC-V,您可能正在使用 GCC/clang。

有趣的事实:GCC 知道其中一些 SWAR bithack 技巧(在其他答案中显示)并且可以在使用 GNU C native vectors 为没有硬件 SIMD 指令的目标编译代码时使用它们。 (但是 RISC-V 的 clang 只会天真地将它展开为标量操作,所以如果你想要跨编译器的良好性能,你必须自己做)。

本机矢量语法的一个优点是,当目标机器 具有 硬件 SIMD 时,它将使用它而不是自动矢量化你的 bithack 或类似可怕的东西。

可以轻松编写vector -= scalar操作;语法 Just Works,隐式广播,也就是为您喷洒标量。


另请注意,来自 uint8_t array[]uint64_t* 加载是严格别名 UB,因此请小心。 (另请参阅 回复:在纯 C 中使 SWAR bithacks 严格别名安全)。您可能需要这样的东西来声明一个 uint64_t,您可以通过指针转换来访问任何其他对象,例如 char* 在 ISO C/C++ 中的工作方式。

使用这些将 uint8_t 数据放入 uint64_t 以用于其他答案:

// GNU C: gcc/clang/ICC but not MSVC
typedef uint64_t  aliasing_u64 __attribute__((may_alias));  // still requires alignment
typedef uint64_t  aliasing_unaligned_u64 __attribute__((may_alias, aligned(1)));

另一种执行别名安全加载的方法是将 memcpy 放入 uint64_t,这也消除了 alignof(uint64_t) 对齐要求。但是在没有有效的未对齐加载的 ISA 上,gcc/clang 当它们不能证明指针对齐时,不要内联和优化掉 memcpy,这对性能来说将是灾难性的。

TL:DR:最好的办法是将数据声明为 uint64_t array[...] 或将其动态分配为 uint64_t 或最好是 alignas(16) uint64_t array[]; 这确保对齐至少 8 个字节,如果指定 alignas.

则为 16 个字节

因为 uint8_t 几乎肯定是 unsigned char*,所以通过 uint8_t* 访问 uint64_t 的字节是安全的(但反之则不然 uint8_t 大批)。因此对于窄元素类型为 unsigned char 的这种特殊情况,您可以回避严格别名问题,因为 char 是特殊的。


GNU C 本机矢量语法示例:

GNU C 本机向量始终允许使用其基础类型作为别名(例如 int __attribute__((vector_size(16))) 可以安全地使用别名 int 但不能使用 floatuint8_t 或其他任何东西。

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

// assumes array is 16-byte aligned
void dec_mem_gnu(uint8_t *array) {
    typedef uint8_t v16u8 __attribute__ ((vector_size (16), may_alias));
    v16u8 *vecs = (v16u8*) array;
    vecs[0] -= 1;
    vecs[1] -= 1;   // can be done in a loop.
}

对于没有任何 HW SIMD 的 RISC-V,您可以使用 vector_size(8) 来表达您可以有效使用的粒度,并使用两倍的小向量。

但是 vector_size(8) 对带有 GCC 和 clang 的 x86 编译非常愚蠢:GCC 在 GP 整数寄存器中使用 SWAR bithacks,clang 解包为 2 字节元素以填充 16 字节 XMM 寄存器,然后重新打包。 (MMX 太过时了,以至于 GCC/clang 甚至懒得使用它,至少对于 x86-64 是这样。)

但是使用 vector_size (16) (Godbolt) 我们得到预期的 movdqa / paddb。 (使用 pcmpeqd same,same 生成的全一向量)。使用 -march=skylake 我们仍然得到两个单独的 XMM 操作而不是一个 YMM,因此不幸的是当前的编译器也不会 "auto-vectorize" 将向量操作转换为更宽的向量:/

对于AArch64来说,用vector_size(8)也不错(Godbolt); ARM/AArch64 可以使用 dq 寄存器在 8 或 16 字节块中工作。

因此,如果您想要跨 x86、RISC-V、ARM/AArch64 和 POWER 的可移植性能,您可能希望 vector_size(16) 实际编译。但是,其他一些 ISA 在 64 位整数寄存器中执行 SIMD,我认为像 MIPS MSA。

vector_size(8) 使查看 asm 更容易(只有一个寄存器值的数据):Godbolt compiler explorer

# GCC8.2 -O3 for RISC-V for vector_size(8) and only one vector

dec_mem_gnu(unsigned char*):
        lui     a4,%hi(.LC1)           # generate address for static constants.
        ld      a5,0(a0)                 # a5 = load from function arg
        ld      a3,%lo(.LC1)(a4)       # a3 = 0x7F7F7F7F7F7F7F7F
        lui     a2,%hi(.LC0)
        ld      a2,%lo(.LC0)(a2)       # a2 = 0x8080808080808080
                             # above here can be hoisted out of loops
        not     a4,a5                  # nx = ~x
        and     a5,a5,a3               # x &= 0x7f... clear high bit
        and     a4,a4,a2               # nx = (~x) & 0x80... inverse high bit isolated
        add     a5,a5,a3               # x += 0x7f...   (128-1)
        xor     a5,a4,a5               # x ^= nx  restore high bit or something.

        sd      a5,0(a0)               # store the result
        ret

我认为这与其他非循环答案的基本思想相同;防止进位然后修正结果。

这是 5 个 ALU 指令,比我认为的最佳答案还差。但看起来关键路径延迟只有 3 个周期,两条 2 条指令链分别通向 XOR。 @Reinstate Monica - ζ-- 的答案编译为 4 周期 dep 链(对于 x86)。 5 周期循环吞吐量因在关键路径上还包括一个天真 sub 而受到瓶颈,并且循环在延迟上确实存在瓶颈。

但是,这对 clang 来说是没有用的。它甚至没有按照加载的相同顺序添加和存储,所以它甚至没有做好软件流水线工作!

# RISC-V clang (trunk) -O3
dec_mem_gnu(unsigned char*):
        lb      a6, 7(a0)
        lb      a7, 6(a0)
        lb      t0, 5(a0)
...
        addi    t1, a5, -1
        addi    t2, a1, -1
        addi    t3, a2, -1
...
        sb      a2, 7(a0)
        sb      a1, 6(a0)
        sb      a5, 5(a0)
...
        ret

不会尝试编写代码,但对于减 1,您可以减 8 个 1,然后检查以确保结果的 LSB 具有 "flipped"。任何未切换的 LSB 表示相邻 8 位发生了进位。应该可以计算出一系列 ANDs/ORs/XORs 来处理这个问题,没有任何分支。