关于ADC的-1(0xFFFFFFFF)有什么特别之处吗?

Is there anything special about -1 (0xFFFFFFFF) regarding ADC?

本文关键字:什么 ADC 0xFFFFFFFF 关于      更新时间:2023-10-16

在我的一个研究项目中,我正在编写C++代码。但是,生成的程序集是项目的关键点之一。C++不提供对标志操作指令的直接访问,特别是对ADC但只要编译器足够聪明地使用它,这应该不是问题。考虑:

constexpr unsigned X = 0;
unsigned f1(unsigned a, unsigned b) {
b += a;
unsigned c = b < a;
return c + b + X;
}

可变c是一种解决方法,可以让我掌握进位标志并将其添加到bX中。看起来我很幸运,(g++ -O3,版本9.1)生成的代码是这样的:

f1(unsigned int, unsigned int):
add %edi,%esi
mov %esi,%eax
adc $0x0,%eax
retq 

对于我测试过的所有X值,代码如上(当然,除了相应更改的即时值$0x0)。不过我发现了一个例外:当X == -1(或0xFFFFFFFFu~0u时,...你怎么拼写真的无关紧要)生成的代码是:

f1(unsigned int, unsigned int):
xor %eax,%eax
add %edi,%esi
setb %al
lea -0x1(%rsi,%rax,1),%eax
retq 

这似乎不如间接测量建议的初始代码有效(虽然不是很科学)我说得对吗?如果是这样,这是一个值得报告的"错过优化机会"类型的错误吗?

值得一提的是,clang -O3,版本8.8.0,总是使用ADC(如我所愿)和icc -O3,版本19.0.1永远不会这样做。

我尝试使用内在_addcarry_u32但没有帮助。

unsigned f2(unsigned a, unsigned b) {
b += a;
unsigned char c = b < a;
_addcarry_u32(c, b, X, &b);
return b;
}

我想我可能没有正确使用_addcarry_u32(我找不到太多信息)。使用它有什么意义,因为由我来提供携带标志?(再次介绍c并祈祷编译器了解情况。

实际上,我可能正确地使用它。X == 0我很高兴:

f2(unsigned int, unsigned int):
add %esi,%edi
mov %edi,%eax
adc $0x0,%eax
retq 

X == -1我不开心:-(

f2(unsigned int, unsigned int):
add %esi,%edi
mov $0xffffffff,%eax
setb %dl
add $0xff,%dl
adc %edi,%eax
retq 

我确实得到了ADC但这显然不是最有效的代码。(dl在那里做什么?阅读携带标志并恢复它的两条指令?真?我希望我错了!

mov

+adc $-1, %eax在大多数 CPU 上的延迟和 uop 计数方面都比xor-0 +setc+ 3 分量lea更有效,在任何仍然相关的 CPU 上也不差。1


这看起来像是 gcc 错过的优化:它可能看到一个特殊情况并锁定它,在脚上开枪并阻止adc模式识别发生。

我不知道它到底看到了/正在寻找什么,所以是的,您应该将其报告为错过的优化错误。 或者,如果您想自己更深入地挖掘,您可以在优化通过后查看 GIMPLE 或 RTL 输出,看看会发生什么。 如果您对 GCC 的内部表示有所了解。 Godbolt有一个GIMPLE树转储窗口,您可以从与"克隆编译器"相同的下拉列表中添加。


clang用adc编译它的事实证明了它是合法的,即你想要的asm确实与C++源匹配,并且你没有错过一些阻止编译器进行优化的特殊情况。 (假设 clang 没有错误,这里就是这种情况。

如果你不小心,这个问题肯定会发生,例如,尝试编写一个一般情况的adc函数,该函数接受进位并提供从 3 输入加法中带出在 C 中是很困难的,因为两个加法中的任何一个都可以进位,所以你不能在将进位添加到其中一个输入后只使用sum < a+b习语。我不确定是否有可能让 gcc 或 clang 发出add/adc/adc中间adc必须携带并产生携带。

例如0xff...ff + 1环绕为 0,因此sum = a+b+carry_in/carry_out = sum < a无法优化为adc,因为它需要在a = -1carry_in = 1的特殊情况下忽略进位。

所以另一个猜测是,也许 gcc 考虑过更早地做+ X,并因为那个特殊情况而搬起石头砸自己的脚。 不过,这没有多大意义。


使用它有什么意义,因为由我来提供携带标志?

您正确使用_addcarry_u32

它存在的重点是让你表达一个带有进位和执行的加法,这在纯 C中很难。 GCC 和 clang 不能很好地优化它,通常不只是将进位结果保留在 CF 中。

如果你只想结转,你可以提供一个0作为进货,它将优化为add而不是adc,但仍然给你结转作为 C 变量。

例如,要在 32 位块中添加两个 128 位整数,您可以这样做

// bad on x86-64 because it doesn't optimize the same as 2x _addcary_u64
// even though __restrict guarantees non-overlap.
void adc_128bit(unsigned *__restrict dst, const unsigned *__restrict src)
{
unsigned char carry;
carry = _addcarry_u32(0, dst[0], src[0], &dst[0]);
carry = _addcarry_u32(carry, dst[1], src[1], &dst[1]);
carry = _addcarry_u32(carry, dst[2], src[2], &dst[2]);
carry = _addcarry_u32(carry, dst[3], src[3], &dst[3]);
}

(On Godbolt with GCC/clang/ICC)

这与unsigned __int128编译器只会使用 64 位 add/adc,但确实会让 clang 和 ICC 发出add/adc/adc/adc的链。 GCC 弄得一团糟,在某些步骤中使用setcc将 CF 存储为整数,然后add dl, -1将其放回 CF 中以进行adc

不幸的是,GCC 在用纯 C 编写的扩展精度/大整数上很糟糕。 Clang有时会做得更好,但大多数编译器都不擅长。 这就是为什么对于大多数架构,最低级别的gmplib函数都是用asm手写的。


脚注 1:或对于 uop 计数:在英特尔 Haswell 和更早版本上相等,其中adc为 2 uops,但 Sandybridge-Family 的解码器特例为零的直接情况为 1 uop。

但是带有base + index + disp的 3 分量 LEA 使其成为英特尔 CPU 上的 3 周期延迟指令,因此它肯定更糟。

在英特尔 Broadwell 及更高版本上,即使具有非零即时指令,adc也是 1-uop 指令,利用了 Haswell 为 FMA 引入的对 3 输入 uop 的支持。

因此,相同的总 uop 计数但更差的延迟意味着adc仍然是更好的选择。

https://agner.org/optimize/