将 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,您编写的代码实际上会进行矢量化。
不确定这是否是您想要的,但它会并行执行 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)。对于减量,y
是 0x0101010101010101U
.
我们知道 y
的所有 MSB 都清除了,所以我们可以跳过掩码步骤之一(即 y & ~H
在我们的例子中与 y
相同)。计算过程如下:
- 我们将
x
的每个组件的 MSB 设置为 1,这样借位就无法通过 MSB 传播到下一个组件。称其为调整后的输入。
- 我们通过从校正后的输入中减去
0x01010101010101
,从每个分量中减去 1。由于步骤 1,这不会导致组件间借用。将此称为调整后的输出。
- 我们现在需要更正结果的 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
但不能使用 float
或 uint8_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 可以使用 d
或 q
寄存器在 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 来处理这个问题,没有任何分支。
如果我有一个 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,您编写的代码实际上会进行矢量化。
不确定这是否是您想要的,但它会并行执行 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
) 也是可行的。
用 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)。对于减量,y
是 0x0101010101010101U
.
我们知道 y
的所有 MSB 都清除了,所以我们可以跳过掩码步骤之一(即 y & ~H
在我们的例子中与 y
相同)。计算过程如下:
- 我们将
x
的每个组件的 MSB 设置为 1,这样借位就无法通过 MSB 传播到下一个组件。称其为调整后的输入。 - 我们通过从校正后的输入中减去
0x01010101010101
,从每个分量中减去 1。由于步骤 1,这不会导致组件间借用。将此称为调整后的输出。 - 我们现在需要更正结果的 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,因此请小心。 (另请参阅 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
.
因为 uint8_t
几乎肯定是 unsigned char*
,所以通过 uint8_t*
访问 uint64_t
的字节是安全的(但反之则不然 uint8_t 大批)。因此对于窄元素类型为 unsigned char
的这种特殊情况,您可以回避严格别名问题,因为 char
是特殊的。
GNU C 本机矢量语法示例:
GNU C 本机向量始终允许使用其基础类型作为别名(例如 int __attribute__((vector_size(16)))
可以安全地使用别名 int
但不能使用 float
或 uint8_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 可以使用 d
或 q
寄存器在 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 来处理这个问题,没有任何分支。