带符号饱和位运算技巧
注意饱和返回值(0x7FFFFFFF 和 0x80000000)是彼此按位取反的,我们可以通过 tmp ^ 0x7FFFFFFF
生成它们,其中 tmp = x>>31
,根据符号为全零或全一。还可以使用((unsigned)x>>31) + (unsigned)INT_MAX
,0x7FFFFFFFu + (0 or 1)
。(使用无符号来避免C中的有符号溢出UB)
这就是 Rust 所做的,对于
x.saturating_mul(2)
或
x.saturating_add(x)
。(没错,Rust 在所有原始整数类型上都有饱和加法和乘法作为基本操作。如果你使用 Rust,你会把这算作一个“操作”吗?CPU 不运行源代码,它们运行汇编语言。有些语言的操作比大多数 CPU 更受限制,特别是 C 语言,它也缺少旋转、popcount、clz/ctz;C++ 直到 C++20 才通过
#include <bit>
添加了这些功能。)
pub fn saturating_mul2(x: i32) -> i32 {
x.saturating_mul(2)
}
pub fn saturating_add_self(x: i32) -> i32 {
x.saturating_add(x)
}
使用x86-64、AArch64和
--target armv7-unknown-linux-gnueabi
的汇编代码,请参见
Godbolt:
example::saturating_mul2: @@ ARMv7
adds r1, r0, r0 @ add, updating flags (S suffix)
mvn r2, #-2147483648 @ r2 = ~0x80000000 = 0x7FFFFFFF
eorvs r1, r2, r0, asr #31 @ if (adds overflowed) r1 = 0x7FFFFFFF ^ (x>>31)
@ eorvs is predicated on V Set = signed overflow, otherwise r1 left unmodified holding x+x
mov r0, r1 @ get the result into the same reg as the input
bx lr
example::saturating_add_self:
qadd r0, r0, r0 @ yup, ARMv7 has an instruction for this!
bx lr @ the mul(2) version is a missed optimization
或者使用AArch64:
example::saturating_mul2:
asr w8, w0, #31
adds w9, w0, w0 // x+x and set flags
eor w8, w8, #0x7fffffff
csel w0, w8, w9, vs // select
ret
example::saturating_add_self:
adds w8, w0, w0 // x+x and set flags
asr w9, w8, #31 // (x<<1)>>31 is the opposite of the sign of the correct result, if there was overflow
eor w9, w9, #0x80000000
csel w0, w9, w8, vs
ret
请注意,LLVM的
add_self
版本策略类似于一般的
x+y
饱和运算。它使用与
adds
相反的符号来表示
x+x
结果,因此它具有更差的延迟和指令级并行性(右移必须等待
add
完成,而不能在同一输入上分别运行)。如果实际结果与数学上正确的结果有不同的符号,则有符号溢出。因此,如果您有两个可能具有不同符号的单独输入,这是一个好的技巧。但实际上这并不是必要的:只有当两个输入具有相同的符号时,
+
才会溢出,因此您可以选择任何一个输入。
x86-64版本与AArch64版本非常相似,但需要额外的
mov
指令。(或者使用
.saturating_add
版本,使用
lea
进行一次
+
运算以提供
sar
右移操作的输入,再使用
add
生成潜在的返回值并设置OF标志以进行
cmov
操作。)
有一个可选的/建议的C扩展,其中包含_Sat类型;请参见
SO答案,以及使用它进行加法的示例:
https://godbolt.org/z/5EdP1EnxT。
如何让C编译器生成这样的汇编代码?
将饱和结果生成为
(x>>31) ^ 0x7FFFFFFF
非常容易。至少如果您可以假设
>>
是算术右移的话。对于有符号负整数的
>>
是实现定义行为,但所有主流编译器都选择以有用的方式定义它,至少在2补码系统上。
因此,只是需要以某种方式检测左移中的有符号溢出。
不幸的是,在ISO C中,有符号整数溢出是未定义行为,包括在
x << 1
中。GNU C
定义了即使移位溢出也会发生的行为(与
x+x
不同,您必须使用
__builtin_sadd_overflow
),所以我不知道您关心的可移植性有多高。
如果您愿意使用GNU C溢出检测内置函数(通常可以编译成使用由加法设置的溢出标志,因此这确实是一种原始机器操作),请参见
Signed saturated add of 64-bit ints? - 对于AArch64,clang会发出4条指令,与
rustc
的饱和数学相当,尽管使用
add x9,x9,x1,lsr #63
来执行
INT64_MAX
+0或1。
如果您只关心二进制补码机器,可以在C源代码中使用无符号类型进行左移。整个位运算基本上已经假定了这一点,因此我们只需要对比较0使用有符号类型,或者进行算术右移。
#include <stdint.h>
int saturating_shl (int32_t x)
{
int32_t saturated = (x>>31) ^ 0x7FFFFFFF;
int32_t x2 = ((uint32_t)x) << 1;
int32_t overflow = (x ^ x2);
return (overflow < 0) ? saturated : x2;
}
这是次优的;编译器未能使用ALU加法或左移指令的有符号溢出结果,而实际上是与原始值进行异或运算。但它仍然只有源代码中的5个操作,例如x86-64的GCC(
Godbolt)。
saturating_shl:
mov edx, edi
lea eax, [rdi+rdi] # x<<1
sar edx, 31 # x>>31
xor edx, 2147483647 # saturated
xor edi, eax # x ^ x2 to set FLAGS
cmovs eax, edx # SF is set when (x^x2) < 0
ret
更新:我错过了问题任意禁用三元运算符的部分。我不会再进行编辑,因为@njuffa发布了一个避免它的答案,使用clang编译到与三元版本相同的指令。(虽然在GCC中生成更糟糕的汇编代码,它无法将混合惯用语排序回cmov / csel。)
计算操作 - CPU汇编语言,而非C运算符
真正的CPU运行机器码,而不是C运算符。在微观优化时,重要的是您的代码在特定的机器上能够高效地编译。现代机器通常具有条件选择指令,例如x86的cmov
或AArch64的csel
,因此像((~temp) & rval) | (temp & flow);
这样手动根据掩码进行混合的表达式可以希望编译为一个机器操作,使用FLAGS条件而不是从中生成整数掩码。
如果这是使用SIMD自动向量化,那么SIMD比较已经产生了一个所有0或所有1元素的掩码,许多指令集都有混合指令可以应用它们,例如x86的SSE4.1的。此外,许多指令集还有像x86的SSE2的andnot指令,它可以在单个指令中执行
(~mask) & rval
,因此混合需要3个廉价指令,而不是一个更少廉价的
pblendvb
,它在某些CPU上需要2个微操作;参见
https://uops.info/。
但另一方面,在像x86这样没有AVX只能执行z = x;
z &= y;
而不能在单个指令中执行z = x&y
的机器上,额外的寄存器-寄存器mov或movdqa指令也是您需要担心的成本的一部分。
TL:DR:在C语言中计算运算符的数量可能是成本的一个粗略代理,但并不是精确的。除了吞吐量之外,还有其他考虑因素,例如延迟(从输入到输出的关键路径长度)和指令级并行性。某些机器具有可以执行C语言没有单个运算符的指令。例如,32位ARM具有饱和带符号加法
qadd
,因此如果编写正确且编译器识别您正在使用的“惯用法”,则足够聪明的编译器可以将您的函数优化为一个指令。实际上,编译器可以为一些事情做到这一点,例如使用
(x >> (n&31)) | (x << ((-n)&31))
进行旋转编译为x86
rol
。许多真正的CPU基于有符号溢出或MSB设置FLAGS,因此比起右移并使用掩码,比较和三元操作有时对编译器来说更容易解决。
相关:
satMul2(0x60000000)
对我而言返回的是0x7FFFFFFF
而不是0x80000000
。在所有溢出情况下,返回 Tmin 或 Tmax 是否有条件限制,或者两者都可以吗? - Nelfeali32.saturating_mul
乘以2一样,或者x.saturating_add(x)
。(https://godbolt.org/z/4646v7noP 显示了rustc在 x86-64 上的实现)。0x60000000
没有设置它的 MSB(最高位十六进制数字<8),因此它表示非负的有符号整数值。 - Peter Cordes