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


77

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

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

因此,我正在尝试执行SWAR(寄存器中的SIMD)以手动取消a的字节之间的进位传播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 ++解决方案,或者只是其背后的理论,以便我可以实现自己的解决方案。


5
它们需要为8位还是可以为7位?
塔德曼

他们必须是8位抱歉:(
cam-white

12
对于这样的事情技术被称为SWAR
哈罗德


1
你期望一个包含零的字节包装到0xff吗?
Alnitak

Answers:


75

如果您的CPU带有有效的SIMD指令,那么SSE / MMX paddb_mm_add_epi8)也是可行的。彼得·科德斯(Peter Cordes)的答案还描述了GNU C(gcc / clang)矢量语法以及严格别名UB的安全性。我也强烈建议您也要复习该答案。

自己与做uint64_t是完全可移植的,但仍需要注意避免对准问题和访问时严格走样UB uint8_t一个阵列uint64_t*。您uint64_t已经通过从一个数据开始就把那部分排除在了问题之外,但是对于GNU C,一个may_aliastypedef解决了这个问题(有关此内容,请参见Peter的答案memcpy)。

否则,您可以将数据分配/声明为,uint64_tuint8_t*在需要单个字节时通过进行访问。 unsigned char*允许为任何东西加上别名,以便针对8位元素的特定情况回避问题。(如果uint8_t根本存在,则可以假定它是一个unsigned char。)


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

这是可能的,无需循环进行任意减法,并且对于1每个字节中的已知常量,效率更高。 主要技巧是通过设置高位来防止每个字节的进位,然后校正减法结果。

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

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

H定义为0x8080808080808080U(即每个打包整数的MSB)。递减y0x0101010101010101U

我们知道它的y所有MSB都已清除,因此我们可以跳过其中一个掩码步骤(即y & ~Hy我们的情况相同)。计算过程如下:

  1. 我们将的每个组件的MSB设置x为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);
}

最好由编译器内联(使用编译器指令强制执行此操作),或者将该表达式作为另一个函数的一部分内联编写。

测试用例:

in:  0000000000000000
out: ffffffffffffffff

in:  f200000015000013
out: f1ffffff14ffff12

in:  0000000000000100
out: ffffffffffff00ff

in:  808080807f7f7f7f
out: 7f7f7f7f7e7e7e7e

in:  0101010101010101
out: 0000000000000000

性能细节

这是该函数的单次调用的x86_64程序集。为了获得更好的性能,应该内联在一起,希望这些常数可以尽可能长地存在于寄存器中。在常量存放在寄存器中的紧密循环中,实际的减量需要执行5条指令:优化后的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将其加载到XMM reg中paddb,因此,看看它如何为RISC-V之类的ISA编译可能会更有趣。)


4
我需要我的代码在RISC-V的机器上运行不具有SIMD指令(但)我们对MMX单独的支持
凸轮白

2
@ cam-white知道了-这可能是您当时可以做的最好的事情。我也将跳上Godbolt来检查RISC的装配。编辑:没有RISC-V上godbolt :(支持
纳法

7
有上godbolt RISC-V的支持实际上,例如像这样 (E:看来编译器创建遮罩变得过于创意..)
哈罗德

4
有关如何在各种情况下使用奇偶校验(也称为“进位向量”)技巧的进一步阅读:emulators.com/docs/LazyOverflowDetect_Final.pdf
jpa

4
我又做了一次编辑;GNU C本机向量实际上避免了严格的混叠问题。uint8_t允许使用向量作为别名uint8_t数据。您的函数调用者(需要将uint8_t数据放入uint64_t)是那些担心严格混淆的调用者!因此,可能OP应该只声明/分配数组,uint64_t因为char*允许在ISO C ++中为任何东西加上别名,反之亦然。
Peter Cordes

16

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

有趣的事实:GCC知道其中一些SWAR bithack技巧(在其他答案中显示),并且在使用GNU C本机向量为没有硬件SIMD指令的目标编译代码时,可以使用它们。(但是RISC-V的clang只会天真地将其展开为标量运算,因此,如果您希望跨编译器具有良好的性能,则必须自己进行操作)。

本机向量语法的一个优点是,当针对具有硬件SIMD 的计算机,它将使用该向量,而不是自动向量化您的bithack或类似的恐怖内容。

它使编写vector -= scalar操作变得容易。语法Just Works,也为您隐式广播标量。


另请注意,uint64_t*来自uint8_t array[]严格别名UB 的负载,因此要小心。(另请参见为什么要快速运行glibc的原因如此复杂? re:在纯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个字节对齐;如果指定,则确保为16个字节alignas

由于uint8_t几乎可以肯定unsigned char*,访问一个过uint64_t孔的字节是安全的uint8_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.
}

对于没有任何硬件SIMD的RISC-V,您可以使用它vector_size(8)来表达您可以有效使用的粒度,并执行两倍于较小向量的操作。

但是vector_size(8)对于同时使用GCC和clang的x86来说,编译起来非常愚蠢:GCC在GP整数寄存器中使用SWAR bithack,将clang解压缩为2个字节的元素以填充16个字节的XMM寄存器,然后重新打包。(MMX已过时,以至于GCC / clang甚至都不会使用它,至少对于x86-64来说不是这样。)

但随着vector_size (16)Godbolt),我们得到预期的movdqa/ paddb。(使用生成的全矢量pcmpeqd same,same)。由于-march=skylake我们仍然得到两个单独的XMM ops而不是一个YMM,因此不幸的是,当前的编译器也不会将矢量ops“自动矢量化”为更宽的矢量:/

对于AArch64,使用起来还不错vector_size(8)Godbolt);ARM / AArch64可以使用dq寄存器以8或16字节的块形式进行本地工作。

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

vector_size(8)使查看asm更加容易(只有一个寄存器值的数据):Godbolt编译器资源管理器

# 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)。通过sub在关键路径上包含朴素的内容,可以使5周期循环的吞吐量成为瓶颈,而循环确实使延迟成为瓶颈。

但是,这对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

13

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

https://godbolt.org/z/J9DRzd


1
您能解释或参考那里发生的事情吗?看起来很有趣。
n314159

2
我试图在没有SIMD指令的情况下执行此操作,但是我仍然发现了这个有趣的问题:)
cam-white

8
另一方面,该SIMD代码非常糟糕。编译器完全误解了这里发生的事情。E:这是“显然是由编译器完成的,因为没有人会这么愚蠢”的示例
harold

1
@PeterCordes:我在考虑一种__vector_loop(index, start, past, pad)构造的思路,这种构造可以视为for(index=start; index<past; index++)[意味着任何实现都可以通过定义宏来处理使用该代码的任何实现],但是它的语义较宽松,可以邀请编译器处理以下内容:任何最大为的2的幂的块pad,如果它们尚未是块大小的倍数,则将其起始位置向下延伸并向上延伸。每个块内的副作用都不会排序,如果break循环内出现a ,则其他代表...
supercat

1
@PeterCordes:虽然restrict有帮助(并且如果标准认识到“至少潜在地基于”的概念,然后直接定义“基于”和“至少潜在地基于”的概念,而不会出现愚蠢且无法解决的极端情况,则会有所帮助)我的建议还允许编译器执行比请求更多的循环执行-这样做可以大大简化向量化,但标准对此未作规定。
超级猫

11

您可以确保减法不会溢出,然后修复高位:

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;
}

我认为它适用于一个字节的所有256个可能的值;我将其放在Godbolt(使用RISC-V 叮当声)上godbolt.org/z/DGL9aq来查看各种输入(例如0x0、0x7f,0x80和0xff(移至数字的中间))的恒定传播结果。看起来不错。我认为最重要的答案归结为同一件事,但它以更复杂的方式解释了它。
Peter Cordes

编译器可以在这里在寄存器中构造常量方面做得更好。clang花费了大量的指令来构建splat(0x01)splat(0x80),而不是通过转移而彼此取而代之。即使以这种方式在源代码Godbolt.org/z/6y9v-u中进行编写,也无法使编译器编写出更好的代码。它只是不断传播。
Peter Cordes

我想知道为什么它不只是从内存中加载常量。这就是Alpha(类似架构)的编译器所做的。
福克·胡夫纳

RISC-V的GCC 确实从内存加载常量。看起来clang需要一些调整,除非预期会发生数据高速缓存未命中,并且与指令吞吐量相比它是昂贵的。(自Alpha以来,这种平衡肯定可以改变,并且可能是RISC-V的不同实现方式也不同。如果编译器意识到这是一种重复模式,他们可以在从一个LUI / add开始后进行移位/或扩展,那么它们的性能也可以做得更好。 20 + 12 = 32位立即数。AArch64的位模式立即数甚至可以将其用作AND / OR / XOR,智能解码与密度选择的立即数)
Peter Cordes

添加了一个答案,该答案显示了GCC针对RISC-V的本机向量SWAR
Peter Cordes

7

不知道这是否是您想要的,但是它会并行执行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掩码什么都不做),并且编译器执行循环展开会容易得多。


for不会并行运行,您是否感到困惑for_each
LTPCGO

3
@LTPCGO不,我不是要并行化此for循环,这实际上会破坏算法。但是此代码可并行处理64位整数中的不同8位整数,即所有8个减法同时完成,但它们最多需要8步。
n314159

我意识到我的要求可能有点不合理,但这与我所需要的非常接近:)
cam-white

4
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次。以下部分摘自如何将64位数字拆分为八个8位值?我添加上面的功能

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

无论有人如何遇到,它都是有效的C或C ++


5
但是,这并没有使工作并行化,这是OP的问题。
Nicklpro

是的,@ nickelpro是正确的,这将一个接一个地相减,我想同时减去所有8位整数。非常感谢您的答复,谢谢兄弟
cam-white

2
@nickelpro当我开始回答时,还没有进行编辑,它指出了问题的平行部分,因此直到提交后我才注意到它,以免对其他人有用,因为它至少回答了问题部分进行按位运算,并且可以通过利用它for_each(std::execution::par_unseq,...来代替whiles
使其

2
这很不好,我提交了一个问题,然后意识到我并没有说需要并行编辑
cam-white

2

不会尝试提出代码,但是如果要递减1,则可以按8个1递减一组,然后检查以确保结果的LSB已“翻转”。任何未切换的LSB都表明在相邻的8位中发生了进位。应该有可能计算出一系列AND / OR / XOR,而无需任何分支。


这可能会起作用,但请考虑进位沿一组8位一直传播到另一位的情况。正确答案(首先设置MSB或其他方式)以确保进位不传播的策略可能至少像它可能那样有效。当前要击败的目标(即良好的非循环无分支答案)是5条RISC-V asm ALU指令,具有指令级并行性,因此关键路径仅需3个周期,并使用两个64位常量。
Peter Cordes

0

将工作完全集中在每个字节上,然后放回原处。

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;
   }
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.