优化x64汇编MUL循环

21

我正在编写需要快速乘大数的数学代码。它可以将一个整数数组与一个单独的整数相乘。在C++中,代码如下(对于无符号整数):

void muladd(unsigned* r, const unsigned* a, unsigned len, unsigned b) {
   unsigned __int64 of = 0;  // overflow
   unsigned i = 0;  // loop variable
   while (i < len) {
      of += (unsigned __int64)a[i] * b + r[i];
      r[i] = (unsigned)of;
      of >>= 32;
      ++i;
   }
   r[i] = (unsigned)of;  // save overflow
}

我手动展开了这个循环,将其转换为64位,并对.asm编译器输出进行了进一步的优化。现在,主要的.asm循环看起来像这样:

mov   rax, rdi                             ; rdi = b
mul   QWORD PTR [rbx+r10*8-64]             ; rdx:rax = a[i] * b; r10 = i
mov   rsi, QWORD PTR [r14+r10*8-64]        ; r14 = r; rsi = r[i]
add   rax, rsi
adc   rdx, 0
add   rax, r11                             ; r11 = of (low part)
adc   rdx, 0
mov   QWORD PTR [r14+r10*8-64], rax        ; save result
mov   r11, rdx

; this repeats itself 8 times with different offsets

当我对此进行基准测试时,在我的Core2 Quad上每次乘法平均需要约6.3个周期。

我的问题是:有没有什么方法可以加速这个过程?不幸的是,我看不出如何避免其中一个加法和乘法总是需要RDX:RAX,因此我需要移动数据而不能“并行乘法”。

有任何想法吗?

更新: 经过进一步测试,我已经成功将速度提升到每个64位MUL大约需要5.4个周期(包括所有的add、move和loop开销)。我想这可能是在Core2上最好的效果,因为Core2没有非常快的MUL指令:它的吞吐量为3,延迟为6(或7)个周期。Sandy Bridge将更好,吞吐量为1,延迟为3(或4)个周期。

关于GMP的较低数字:我从他们的源代码中得到了这个数字,我认为它是一个理论上的数字。但确切的是,这是为AMD K9 CPU计算出来的数字。从我所读到的内容中可以得知,AMD比(旧款)Intel芯片有一个更快的MUL单元。


7
你可能想看一下 GMP 中的一些汇编例程。它们有一个函数可以完全做到这一点,并且适用于大多数处理器,包括 x64,在汇编语言中编写。 - Mysticial
GMP 确实为快速 mul_basecase 提供了良好的支持,显然每个 MUL 大约需要花费 2.35 个时钟周期,非常棒。如果我理解正确,他们会交错地乘以两个向量,这似乎可以降低依赖性并改善溢出处理。 - cxxl
4个回答

1

我曾经写过一个循环,看起来非常像这样,在大量数据上进行最小化的处理,结果是循环受到了内存速度的限制。

我建议尝试预取a[i]和r[i]

如果使用gcc,请使用函数__builtin_prefetch()或汇编中的PREFETCHT0指令

http://gcc.gnu.org/onlinedocs/gcc-3.3.6/gcc/Other-Builtins.html

当这个程序正常工作时,结果可能会非常显著。只要循环大约有一千次迭代,我会预取a[i+64]和r[i+64]作为起点,并观察它对你的CPU产生了多少影响。你可能需要尝试更大的预取距离。


1
我尝试过了,结果在我的Core2 Quad上没有任何区别。浏览了一下CPU手册和Agner Fog的指南,我得出的结论是今天的处理器有很好的预取逻辑,可以很好地检测到简单的循环,因此不需要手动预取。 - cxxl

0

在调用之前,r是否包含任何重要内容?

如果是,并且您正在累加它,则立即停止阅读。

如果没有(即您始终在累加零),并且假设您在比缓存大小大得多的数组上调用此函数,则应寻找一种消除从r读取和将“保存结果”MOV转换为MOVNT(intrinsics中的_mm_stream_ps)的需要的方法。

这可以显着提高性能。如何?当前,您的缓存正在从a获取缓存行,从r获取缓存行并将缓存行写回r。使用所谓的流式存储,您只需从a获取缓存行并直接写入r:总线流量要少得多。如果查看任何现代CRT的memcpy实现,它将在某些与缓存大小相关的阈值以上切换到使用流式存储(并且运行几乎比使用常规移动的memcpy快两倍)。


这非常有趣。在调用函数时,r 是空的,但会逐渐填充。此外,在函数完成后,我期望它将被用于某些事情(因为它是结果 :))。我原以为 MOVNT 不会有优势,因为我们按顺序填充 r。Agner Fog 写道:“如果可以预期发生二级缓存未命中,则无缓存存储数据的方法具有优势”(http://www.agner.org/optimize/optimizing_cpp.pdf)。我认为 L2 缓存未命中的可能性可以排除在 99% 之外。 - cxxl

0
我想指出的是,循环计数在编程中并不实用。因为你的指令将被转换为微代码,而微代码会根据CPU正在执行的其他任务无序执行或暂停。如果你已经拥有一个快速的程序,而且你并不知道你的程序是否总是独立运行,那么试图去减少理论上的一个时钟周期并没有多大意义。

1
OP 对自己的代码进行了基准测试,并且显然得到了可重复的结果。他没有计算理论周期,而是实际测量了实际周期。指令转换为微码并重新排序的方式是可以预测和相当了解的(请参见 www.agner.org)。此外,为了进行优化,不需要完全隔离,后台运行代码的操作系统通常不会削减超过几个百分点,如果有的话。 - Gunther Piez
这应该是一条注释,而不是回复。 - Mahmoud Al-Qudsi

-1

看起来你的程序可以从SSE中受益。PMULLD和PADDD似乎是相关的指令。不确定为什么你的编译器没有从中产生SSE。


这适用于32位x 32位的乘法。但不适用于64位x 64位的乘法。 - Mysticial
1
当您仅保留最高有效双字时,是否真的需要qword乘法? - Jens Björnhager
我将RAX保存回内存,RDX用作进位(通过R11),并添加到下一个元素中。不幸的是,我需要QWORD MUL。 - cxxl
糟糕。SSE似乎无法处理打包的四元组,真遗憾。不过,如果你能将那些四元组乘法拆分成双精度并一次性处理四个,或许可以提高一些性能。 - Jens Björnhager
1
如果GMP是答案,他们有使用PMULUDQ的SSE2代码,但注释表明它需要大约4.9个时钟周期每个QWORD。所以我最好尝试交错MULs。 - cxxl

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接