使用intel内联汇编程序对bigint-add进行进位编码

Using intel inline assembler to code bigint add with carry

本文关键字:进位 编码 bigint-add intel 汇编程序 使用      更新时间:2023-10-16

我想做一个在大整数中添加64位数字的快速代码:

uint64_t ans[n];
uint64_t a[n], b[n]; // assume initialized values....
for (int i = 0; i < n; i++)
ans[i] = a[i] + b[i];

但以上内容不适用于carry。

我在这里看到了另一个问题,建议使用if语句来检查哪一个是优雅的:

ans[0] = a[0] + b[0];
int c = ans[0] < a[0];
for (int i = 0; i < n; i++) {
ans[i] = a[i] + b[i] + c;
c = ans[i] < a[i];
}

然而,我想学习如何嵌入内联(intel)程序集并更快地完成它。我确信有64位操作码,相当于:

add eax, ebx
adc ...

但我不知道如何将剩余的c++代码中的参数传递给汇编程序。

,但以上内容不适用于进位。

如果您的意思是GCC不生成使用ADC指令的代码,那是因为它的优化器已经确定有更优化的方法来实现添加。

这是你的代码的测试版本。我提取了数组作为传递给函数的参数,这样代码就不会被忽略,我们可以将研究限制在相关部分。

void Test(uint64_t* a, uint64_t* b, uint64_t* ans, int n)
{
for (int i = 0; i < n; ++i)
{
ans[i] = a[i] + b[i];
}
}

现在,事实上,当你用GCC的现代版本编译它并查看反汇编时,你会看到一堆看起来很疯狂的代码。

Godbolt编译器资源管理器非常有用,它对C源代码行及其相应的汇编代码进行了颜色编码(或者至少,它已经尽了最大的能力;这在优化的代码中并不完美,但在这里它已经足够好了)。紫色代码是在循环的内部实现64位加法的代码。GCC正在发出SSE2指令以进行添加。具体来说,您可以选择MOVDQU(将双四字从内存中不对齐地移动到XMM寄存器中)、PADDQ(对压缩整数四字进行加法)和MOVQ(将四字从XMM寄存器移动到内存中)。粗略地说,对于非汇编专家来说,MOVDQU是加载64位整数值的方式,PADDQ进行加法运算,然后MOVQ存储结果。

使该输出特别嘈杂和令人困惑的部分原因是GCC是展开for循环。如果禁用循环展开(-fno-tree-vectorize),则可以获得更易于读取的输出,尽管它仍然使用相同的指令执行相同的操作。(好吧,大部分。现在它在所有地方都使用MOVQ,用于加载和存储,而不是使用MOVDQU加载。)

另一方面,如果您特别禁止编译器使用SSE2指令(-mno-sse2),您会看到明显不同的输出。现在,因为它不能使用SSE2指令,所以它发出基本的x86指令来进行64位加法——而唯一的方法是ADD+ADC

我怀疑这是期望看到的代码。显然,GCC相信向量化操作会导致更快的代码,所以当使用-O2-O3标志进行编译时,它就是这样做的。在-O1,它总是使用ADD+ADC。这是一种指令较少并不意味着代码较快的情况。(或者至少,GCC不这么认为。实际代码的基准可能会讲述不同的故事。在某些人为的场景中,开销可能很大,但在现实世界中无关紧要。)

值得一提的是,Clang的行为方式与GCC在这里的行为方式非常相似。


如果您的意思是此代码不将上一次加法的结果传递到下一次加法,那么您是对的。您展示的第二段代码实现了该算法,GCC使用ADC指令编译该算法。

至少在针对x86-32时是这样。当以x86-64为目标时,如果您有本地64位整数寄存器可用,则甚至不需要"进位";简单的ADD指令就足够了,需要显著更少的代码。事实上,这只是32位体系结构上的"bigint"运算,这就是为什么我在前面的所有分析和编译器输出中都假设了x86-32。

在一条评论中,Ped7g想知道为什么编译器似乎没有ADD+ADC链习语的概念。我不完全确定他在这里指的是什么,因为他没有分享任何他尝试过的输入代码的例子,但正如我所展示的,编译器确实在这里使用ADC指令。然而,编译器不会在循环迭代之间进行链式进位。这在实践中太难实现了,因为太多的指令会清除标志。手工编写汇编代码的人可能能够做到,但编译器做不到。

(请注意,c可能应该是一个无符号整数,以鼓励某些优化。在这种情况下,它只是确保GCC在准备进行64位加法时使用XOR指令,而不是CDQ。虽然速度稍快,但并不是的巨大改进,但里程可能因实际代码而异。)

(此外,令人失望的是,GCC无法发出无分支代码来在循环内设置c。如果输入值足够随机,分支预测将失败,最终会得到相对低效的代码。几乎可以肯定,有一些方法可以编写C源代码来说服GCC发出无分支码,但答案完全不同。)


我想学习如何嵌入内联(intel)程序集并更快地完成它。

好吧,我们已经看到,如果你天真地导致发出一堆ADC指令,它可能不一定会更快。除非你确信你对性能的假设是正确的,否则不要手动优化!

此外,内联汇编不仅难以编写、调试和维护,而且它甚至可能使代码变得更慢,因为它会抑制编译器本可以进行的某些优化。您需要能够证明手工编码的程序集在性能上胜过编译器生成的程序集,从而降低这些考虑因素的相关性。您还应该确认,无论是通过更改标志还是巧妙地编写C源代码,都无法让编译器生成接近理想输出的代码。

但如果你真的想,你可以阅读各种在线教程中的任何一个,教你如何使用GCC的内联汇编程序。这是一个相当好的;还有很多其他的。当然,还有手册。所有这些都将解释"扩展asm"如何允许您指定输入操作数和输出操作数,这将回答您"如何将参数从c++代码的其余部分传递给汇编程序"的问题。

正如paddy和Christopher Oicles所建议的那样,您应该更喜欢内部组装而不是内联组装。不幸的是,没有导致ADC指令发出的内部函数。内联汇编是你唯一的途径——或者我已经建议编写C源代码,以便编译器做正确的事情™就其本身而言。

不过,也有_addcarry_u32_addcarry_u64内部函数。这些导致CCD_ 33或CCD_。这些是ADC的"扩展"版本,可以生成更高效的代码。它们是Broadwell微体系结构引入的英特尔ADX指令集的一部分。在我看来,Broadwell没有足够高的市场渗透率,你可以简单地发出ADCXADOX指令,然后到此为止许多用户仍然拥有较旧的机器,尽可能地支持它们符合您的利益。如果您正在准备针对特定体系结构进行调优的构建,那么它们非常棒,但我不建议将其用于一般用途。


我确信有64位操作码,相当于:add+adc

当您针对64位体系结构时,有64位版本的ADDADC(以及ADCXADOX)。这将允许您使用相同的模式来实现128位的"bigint"运算。

在x86-32上,基本指令集中没有这些指令的64位版本。你必须转向SSE2,就像我们看到的GCC和Clang一样。

我不完全确定这是否是您想要的,而且我的组装技能肯定不是最好的(例如缺少后缀),但这使用了ADC,应该可以解决您的问题。

注意省略了C++for循环;我们需要在asm中循环,因为我们需要CCD_。(GCC6有标志输出约束,但没有标志输入;没有办法要求编译器将FLAGS从一个asm语句传递到另一个,即使有语法,gcc也可能无法有效地使用setc/cmp。)

#include <cstdint>
#include <iostream>
#define N 4
int main(int argc, char *argv[]) {
uint64_t ans[N];
const uint64_t a[N] = {UINT64_MAX, UINT64_MAX, 0, 0};
const uint64_t b[N] = {2, 1, 3, 1};
const uint64_t i = N;
asm volatile (
"xor %%eax, %%eaxnt"      // i=0  and clear CF
"mov %3, %%rdint"         // N
".L_loop:nt"
"mov (%%rax,%1), %%rdxnt" // rdx = a[i]
"adc (%%rax,%2), %%rdxnt" // rdx += b[i] + carry
"mov %%rdx, (%%rax, %0)nt"// ans[i] = a[i] + b[i]
"lea 8(%%rax), %%raxnt"   // i += 8 bytes
"dec %%rdint"             // --i
"jnz .L_loopnt"   // if (rdi == 0) goto .L_loop;
: /* Outputs (none) */
: /* Inputs */ "r" (ans), "r" (a), "r" (b), "r" (i)
: /* Clobbered */ "%rax", "%rbx", "%rdx", "%rdi", "memory"
);
// SHOULD OUTPUT 1 1 4 1
for (int i = 0; i < N; ++i)
std::cout << ans[i] << std::endl;
return 0;
}

为了避免设置carry flag (CF),我需要倒计时到0以避免执行CMPDEC没有设置carry flag,因此它可能是该应用程序的完美竞争者然而,我不知道如何使用%rdiinc %rax所需的额外指令和寄存器更快地从数组的开头进行索引

volatile"memory"clobber是必要的,因为我们只要求编译器输入指针,而不告诉它我们实际读取和写入的内存。

在一些较旧的CPU上,特别是Core2/Nehalem,inc之后的adc将导致部分标志暂停。请参阅某些CPU上的紧密环路中的ADC/SBB和INC/DEC问题。但在现代CPU上,这是高效的。

编辑:正如@PeterCordes所指出的,我的inc %rax和用lea按8缩放的效率非常低(现在想想都很愚蠢)。现在,它只是lea 8(%rax), %rax


编者按:我们可以使用数组末尾的负索引来保存另一条指令,使用inc / jnz向上计数到0。

(这将数组大小硬编码为4。您可以通过将数组长度作为立即常数,并将-i作为输入,使其更加灵活。或者寻求指向终点的指针。)

// untested
asm volatile (
"mov   $-3, %[idx]nt"        // i=-3   (which we will scale by 8)
"mov   (%[a]), %%rdx  nt"
"add   (%[b]), %%rdx  nt"    // peel the first iteration so we don't have to zero CF first, and ADD is faster on some CPUs.
"mov    %%rdx, (%0) nt"
".L_loop:nt"                        // do{
"mov    8*4(%[a], %[idx], 8), %%rdxnt"   // rdx = a[i + len]
"adc    8*4(%[b], %[idx], 8), %%rdxnt"   // rdx += b[i + len] + carry
"mov    %%rdx,  8*4(%[ans], %[idx], 8)nt"  // ans[i] = rdx
"inc    %[idx]nt"
"jnz    .L_loopnt"                  // }while (++i);
: /* Outputs, actually a read-write input */ [idx] "+&r" (i)
: /* Inputs */ [ans] "r" (ans), [a] "r" (a), [b] "r" (b)
: /* Clobbered */ "rdx", "memory"
);

循环标签可能应该使用%%=,以防GCC重复此代码,或者使用编号的本地标签,如1:

使用缩放索引寻址模式并不比我们以前使用的常规索引寻址模式(2个寄存器)更昂贵。理想情况下,我们会对adc或存储使用一个寄存器寻址模式,通过减去输入上的指针,可能会对相对于ans的其他两个数组进行索引。

但是,我们需要一个单独的LEA来增加8,因为我们仍然需要避免破坏CF。尽管如此,在Haswell和更高版本上,索引商店不能使用端口7上的AGU,Sandybridge/Ivybridge将其取消层压为2个uops。因此,对于Intel SnB系列来说,避免在这里使用索引存储是很好的,因为我们每次迭代需要2次加载+1次存储。参见微融合和寻址模式

早期的Intel CPU(Core2/Nehalem)在上述循环中会有部分标志暂停,因此上述问题与它们无关。

AMD CPU可能对上述循环很满意。Agner Fog的优化和微阵列指南没有提到任何严重的问题。

不过,对于AMD或英特尔来说,推出一点也不会有什么坏处。