如何在C语言中实现最佳(最干净、最高效)的饱和加法?
该函数或宏应该将两个无符号输入相加(需要16位和32位版本),如果总和溢出,返回全1位(0xFFFF或0xFFFFFFFF)。
目标是在使用gcc(4.1.2)和Visual Studio(仅用于模拟,因此回退实现也可以)的x86和ARM上运行。
如何在C语言中实现最佳(最干净、最高效)的饱和加法?
该函数或宏应该将两个无符号输入相加(需要16位和32位版本),如果总和溢出,返回全1位(0xFFFF或0xFFFFFFFF)。
目标是在使用gcc(4.1.2)和Visual Studio(仅用于模拟,因此回退实现也可以)的x86和ARM上运行。
在这里,您可能需要可移植的C代码,您的编译器将把它转换为适当的ARM汇编代码。 ARM具有条件移动,并且这些条件可以基于溢出进行。 然后算法变为:如果检测到溢出,则添加并有条件地将目标设置为unsigned(-1)。
uint16_t add16(uint16_t a, uint16_t b)
{
uint16_t c = a + b;
if (c < a) /* Can only happen due to overflow */
c = -1;
return c;
}
请注意,这与其他算法不同之处在于它会纠正溢出,而不是依靠另一个计算来检测溢出。
add edi, esi
mov eax, -1
cmovae eax, edi
ret
adds r0, r0, r1 @ c, a, b
it cs
movcs r0, #-1 @ conditional-move
bx lr
add r1, r1, r0 @ tmp114, a
movw r3, #65535 @ tmp116,
uxth r1, r1 @ c, tmp114
cmp r0, r1 @ a, c
ite ls @
movls r0, r1 @,, c
movhi r0, r3 @,, tmp116
bx lr @
mov eax,-1
/ add
/ cmovnc
),而且与gcc大致相同,不像所有其他答案。 这是唯一一个使得gcc使用添加的标志结果,而不是之后再进行另一个测试(除了DGentry的答案,但gcc没有意识到两个测试是相同的)。 因此,可以说它是唯一一个让gcc“理解”正在发生什么的方案。 即使是内联汇编也无法在x86上做得更好:编译器知道你的程序正在发生什么,所以它知道它是可关联的,并且可以选择破坏哪个寄存器。 - Peter Cordeslikely
说服了clang生成与您展示的相同的代码,但gcc似乎更加坚持。32位版本按预期工作(再次为clang禁用likely),但我需要一个16位饱和加法。 - ricisum & (1UL<<16)
是否有进位可能是最优的。编译器在这方面做得不太好(绝非最佳),但clang6.0的分支版本在正常情况下没有溢出的情况下很有趣。https://godbolt.org/g/qrpPze。(它应该使用`lea`来复制和添加。)如果16位寄存器的部分寄存器停顿不存在(例如Haswell),则clang的分支版本也看起来不错,但gcc的版本有一个愚蠢的测试(应该报告错过的优化)。 - Peter Cordesz < clamped_subtract(h, 4)
,其中z
是一个size_t类型,而h
是一个uint16_t
类型。现有的代码是z + 4 < h
,但如果加法溢出(非常不可能,但这是一个故障,我想修复它),那么代码就会失败。虽然它不在关键路径上,所以我不太担心,但我正在寻找是否有比两个比较更好的方法。 - rici使用普通的C语言:
uint16_t sadd16(uint16_t a, uint16_t b) {
return (a > 0xFFFF - b) ? 0xFFFF : a + b;
}
uint32_t sadd32(uint32_t a, uint32_t b) {
return (a > 0xFFFFFFFF - b) ? 0xFFFFFFFF : a + b;
}
这几乎是宏定义过的,直接传达了意义。
sadd16
这个名字,我的第一反应会是s
代表有符号(signed)。 - Craig McQueen0xFF..
常量应该改为等价的UINTN_MAX
常量(或(uintN_t)-1
)。这样,只需要进行一次搜索和替换就可以编写sadd8
或sadd64
函数。同时也不需要计算0xFFFFFFFFFFFFFFFF
中有多少个F。 - Alexandros在没有条件跳转的IA32中:
uint32_t sadd32(uint32_t a, uint32_t b)
{
#if defined IA32
__asm
{
mov eax,a
xor edx,edx
add eax,b
setnc dl
dec edx
or eax,edx
}
#elif defined ARM
// ARM code
#else
// non-IA32/ARM way, copy from above
#endif
}
__asm
关键字是依赖于编译器的。标准没有为内联汇编指定关键字。因此,从这个意义上讲,它不是可移植的,因为它依赖于编译器。例如,英特尔 C++ 编译器仅适用于 Windows,因此,如果您编写使用英特尔 C++ 功能的可移植代码,则它将不具备可移植性。另一件事情:内联汇编会阻止编译器进行内联。因此,如果仍然存在函数调用开销,这种优化并不能真正帮助。 - Cole Tobin在ARM中,您可能已经内置了饱和算术。ARMv5 DSP扩展可以使寄存器饱和到任意位长。此外,在ARM上,饱和通常很便宜,因为您可以有条件地执行大多数指令。
即使对于32位和打包数字,ARMv6也具有饱和加法、减法和所有其他功能。
在x86上,您可以通过MMX或SSE获得饱和算术。
所有这些都需要汇编器,所以这不是您要求的内容。
还有一些C技巧可用于执行饱和算术。这段小代码对一个双字的四个字节执行了饱和加法。它基于并行计算32个半加器的想法,例如添加无进位溢出的数字。
首先执行此操作,然后计算进位值,将其加入并用掩码替换如果加法会导致溢出。
uint32_t SatAddUnsigned8(uint32_t x, uint32_t y)
{
uint32_t signmask = 0x80808080;
uint32_t t0 = (y ^ x) & signmask;
uint32_t t1 = (y & x) & signmask;
x &= ~signmask;
y &= ~signmask;
x += y;
t1 |= t0 & x;
t1 = (t1 << 1) - (t1 >> 7);
return (x ^ t0) | t1;
}
通过更改符号掩码常量和底部的移位,您可以获得16位(或任何类型的位域)相同的结果:
uint32_t SatAddUnsigned16(uint32_t x, uint32_t y)
{
uint32_t signmask = 0x80008000;
uint32_t t0 = (y ^ x) & signmask;
uint32_t t1 = (y & x) & signmask;
x &= ~signmask;
y &= ~signmask;
x += y;
t1 |= t0 & x;
t1 = (t1 << 1) - (t1 >> 15);
return (x ^ t0) | t1;
}
uint32_t SatAddUnsigned32 (uint32_t x, uint32_t y)
{
uint32_t signmask = 0x80000000;
uint32_t t0 = (y ^ x) & signmask;
uint32_t t1 = (y & x) & signmask;
x &= ~signmask;
y &= ~signmask;
x += y;
t1 |= t0 & x;
t1 = (t1 << 1) - (t1 >> 31);
return (x ^ t0) | t1;
}
上面的代码同样适用于16位和32位的值。
如果您不需要函数同时添加和饱和多个值的功能,只需屏蔽您需要的位即可。在ARM上,您还需要更改signmask常量,因为ARM无法在单个周期内加载所有可能的32位常量。
编辑:并行版本很可能比直接的方法更慢,但如果您需要同时饱和多个值,则它们将更快。
UQUADD16
and packed8。不过有一个带有有符号饱和的32位加法。另外,不幸的是,这个C代码在32位情况下编译成了可怕的代码:所有SWAR风格的开销,但只针对一个值。它不幸地不能被优化掉。请参见我在MSalters答案中的评论:godbolt链接包括您的版本。 - Peter Cordes零分支解决方案:
uint32_t sadd32(uint32_t a, uint32_t b)
{
uint64_t s = (uint64_t)a+b;
return -(s>>32) | (uint32_t)s;
}
一个优秀的编译器会进行优化,以避免执行任何实际的64位算术运算(s>>32
仅是进位标志,而 -(s>>32)
是 sbb %eax,%eax
的结果)。eax
和 ebx
中的 a
和 b
分别作为输入,结果存放在 eax
中):add %eax,%ebx
sbb %eax,%eax
or %ebx,%eax
8位和16位版本应该很明显。有符号版本可能需要更多的工作。
lea eax,[rdi + rsi] / mov edx,edi / mov ecx,esi / add rdx,rcx / shr rdx,32 / neg edx / or eax,edx
。 - Peter Cordes如果您关心性能,那么您真的希望在SIMD中进行此类操作,在其中x86具有本地饱和算术。
由于标量数学中缺乏饱和算术,因此可以出现情况,在4变量宽度的SIMD上执行的操作比等效的C更快(并且在8变量宽度的SIMD上也是如此):
sub8x8_dct8_c: 1332 clocks
sub8x8_dct8_mmx: 182 clocks
sub8x8_dct8_sse2: 127 clocks
uint32_t saturate_add32(uint32_t a, uint32_t b)
{
uint32_t sum = a + b;
if ((sum < a) || (sum < b))
return ~((uint32_t)0);
else
return sum;
} /* saturate_add32 */
uint16_t saturate_add16(uint16_t a, uint16_t b)
{
uint16_t sum = a + b;
if ((sum < a) || (sum < b))
return ~((uint16_t)0);
else
return sum;
} /* saturate_add16 */
编辑:现在你已经发布了你的版本,我不确定我的版本是否更加简洁/优秀/高效/酷炫。~((uint32_t)0)
?您已经包含了<limits.h>
以获取uint32_t
声明,那么为什么不直接使用UINT32_MAX
呢? - Cole Tobin#define sadd16(a, b) (uint16_t)( ((uint32_t)(a)+(uint32_t)(b)) > 0xffff ? 0xffff : ((a)+(b)))
#define sadd32(a, b) (uint32_t)( ((uint64_t)(a)+(uint64_t)(b)) > 0xffffffff ? 0xffffffff : ((a)+(b)))
我不确定这是否比Skizz的解决方案更快(始终进行性能分析),但这是一种替代的无分支汇编解决方案。请注意,这需要条件移动(CMOV)指令,我不确定您的目标平台是否可用。
uint32_t sadd32(uint32_t a, uint32_t b)
{
__asm
{
movl eax, a
addl eax, b
movl edx, 0xffffffff
cmovc eax, edx
}
}
mvn
(mov-NOT)创建小的负数。汇编器知道如何为您使用它,例如adds r0,r1
(加和设置标志位)/ movCS r0,#-1
(如果Carry Set,则mvn 0 = -1)。xD,MSalter的答案后来显示编译器已经完全做到了这一点。并且还会为x86发出此代码,因此您不必自己操作。而且以一种可以内联和常量传播的方式。 - Peter Cordes如果有人想知道使用补码32位整数的无分支实现,请看以下内容。
警告!此代码使用未定义的操作:"向右移-1",因此利用了Intel Pentium SAL指令的特性,将计数操作数掩码为5位。
int32_t sadd(int32_t a, int32_t b){
int32_t sum = a+b;
int32_t overflow = ((a^sum)&(b^sum))>>31;
return (overflow<<31)^(sum>>overflow);
}
这是我所知道的最好的实现方式
overflow&31
,它仍然会编译而不浪费and ecx, 31
,因为gcc和clang知道移位指令的工作方式(ISA定义了在自286以来的每个CPU上都是这样)。请参见从x86标签wiki链接的Intel insn ref手册。在移位方式不同的目标上,它们将发出必要的指令使其正常工作。当然,这仍然依赖于使用算术移位对有符号整数进行右移,而C标准并不保证。 - Peter Cordesa+b
的未定义操作溢出!在C和C++中,有符号溢出是UB。 - Peter Cordes