在现代CPU上,整数乘法的速度真的和加法的速度一样吗

Is integer multiplication really done at the same speed as addition on a modern CPU?

本文关键字:速度 一样 真的 整数 CPU      更新时间:2023-10-16

我经常听到这样的说法,即现代硬件上的乘法是如此优化,以至于它实际上与加法的速度相同。这是真的吗?

我永远无法得到任何权威的证实。我自己的研究只会增加问题。速度测试通常显示的数据让我感到困惑

#include <stdio.h>
#include <sys/time.h>
unsigned int time1000() {
timeval val;
gettimeofday(&val, 0);
val.tv_sec &= 0xffff;
return val.tv_sec * 1000 + val.tv_usec / 1000;
}
int main() {
unsigned int sum = 1, T = time1000();
for (int i = 1; i < 100000000; i++) {
sum += i + (i+1); sum++;
}
printf("%u %un", time1000() - T, sum);
sum = 1;
T = time1000();
for (int i = 1; i < 100000000; i++) {
sum += i * (i+1); sum++;
}
printf("%u %un", time1000() - T, sum);
}

上面的代码可以表明乘法更快:

clang++ benchmark.cpp -o benchmark
./benchmark
746 1974919423
708 3830355456

但对于其他编译器,其他编译器参数,不同编写的内部循环,结果可能会有所不同,我甚至无法获得近似值。

两个n-比特数的相乘实际上可以在O(logn)电路深度中完成,就像加法一样。

O(logn)中的加法是通过将数字一分为二,并(递归)将中的两部分并行相加来完成的,其中上半部分是为求解的,"0-进位"answers"1-进位"情况。一旦加上下半部分,就检查进位,并使用其值在0进位和1进位之间进行选择。

O(log n)深度的乘法也通过并行化完成,其中3个数字的每一个和被并行化为仅2个数字的和,并且这些和以类似于上述的方式完成
我不在这里解释,但你可以通过查找"进位先行">《进位保存》加法来找到关于快速加法和乘法的阅读材料。

因此,从理论角度来看,由于电路显然本质上是并行的(与软件不同),乘法会渐近较慢的唯一原因是前面的常数因子,而不是渐近复杂性。

整数乘法会更慢。

Agner Fog的指令表显示,当使用32位整数寄存器时,Haswell的ADD/SUB需要0.25–1个周期(取决于指令的流水线处理程度),而MUL需要2–4个周期。浮点则相反:ADDSS/SUBSS需要1-3个周期,而MULSS需要0.5-5个周期。

这是一个比简单的乘法与加法更复杂的答案。事实上,答案很可能永远不会是肯定的。乘法,在电子方面,是一个复杂得多的电路。大多数原因是,乘法是一个乘法步骤,然后是加法步骤,记住在使用计算器之前乘以十进制数是什么感觉。

另一件需要记住的事情是,乘法运算需要更长或更短的时间,这取决于你运行它的处理器的架构。这可能是也可能不是公司特有的。虽然AMD很可能与英特尔不同,但即使是英特尔i7也可能与酷睿2不同(在同一代中),而且在不同代之间肯定也不同(尤其是走得更远)。

在所有技术中,如果乘法是你唯一要做的事情(没有循环、计数等),那么乘法将慢2到35倍(正如我在PPC架构中看到的那样)。这更多的是一种理解您的体系结构和电子产品的练习。

此外:需要注意的是,可以为包括乘法在内的所有操作构建一个占用单个时钟的处理器。该处理器所要做的是,消除所有流水线操作,降低时钟速度,使任何OP电路的HW延迟小于或等于时钟定时提供的延迟。

这样做可以消除我们在处理器中添加流水线时所能获得的固有性能增益。流水线是指将一项任务分解为更小的子任务,这些子任务可以更快地执行。通过在子任务之间存储和转发每个子任务的结果,我们现在可以运行更快的时钟速率,只需要考虑子任务的最长延迟,而不需要从总体任务开始。

时间通过倍数的图片:

|--------------------------------------------------|无管道

|--步骤1-|--步骤2-|--步骤3-|--步骤4-|--步骤5-|流水线

在上图中,非流水线电路占用50个时间单位。在流水线版本中,我们将50个单元划分为5个步骤,每个步骤耗时10个单元,中间有一个存储步骤。特别重要的是要注意,在流水线示例中,每个步骤都可以完全独立地并行工作。对于要完成的操作,它必须按顺序通过所有5个步骤,但另一个具有操作数的相同操作可以在步骤2中,就像在步骤1、3、4和5中一样。

话虽如此,这种流水线方法允许我们在每个时钟周期连续填充运算符,并在每个时钟循环上得到结果。如果我们能够对操作进行排序,以便在切换到另一个操作之前执行所有一个操作,我们所做的计时命中是从流水线中获得FIRST操作所需的原始时钟量。

神秘主义提出了另一个好观点。从更系统的角度来看体系结构也很重要。的确,较新的Haswell体系结构是为了更好地提高处理器内的浮点乘法性能而构建的。因此,作为系统级,它的架构允许同时进行多次乘法运算,而不是每个系统时钟只能进行一次加法运算。

所有这些可以总结如下:

  1. 从较低级别的硬件角度和系统角度来看,每个体系结构都不同
  2. 从功能上讲,乘法总是比加法花费更多的时间,因为它将真正的乘法与真正的加法步骤结合在一起
  3. 了解您试图运行代码的体系结构,并在可读性和从该体系结构中获得最佳性能之间找到正确的平衡

英特尔,因为Haswell有

  • add性能为4/clock吞吐量,1个周期延迟。(任意操作数大小)
  • imul性能为1/时钟吞吐量,3个周期延迟。(任意操作数大小)

Ryzen与此类似。Bulldozer系列的整数吞吐量要低得多,并且不是完全流水线乘法,包括64位操作数大小的乘法速度特别慢。看见https://agner.org/optimize/和中的其他链接https://stackoverflow.com/tags/x86/info

但是一个好的编译器可以自动向量化循环。(SIMD整数乘法吞吐量和延迟都比SIMD整数加法差)。或者只是不断地通过它们传播来打印出答案!Clang确实知道sum(i=0..n)的闭形式高斯公式,并且可以识别一些这样做的循环。


您忘记启用优化,因此两个循环都成为ALU+sum += independent stuffsum++之间将sum保持在内存中的瓶颈。请参阅为什么clang使用-O0(对于这个简单的浮点和)生成低效的asm?了解更多关于由此产生的asm有多糟糕,以及为什么会出现这种情况。clang++默认为-O0(调试模式:将变量保存在内存中,调试器可以在任何C++语句之间对其进行修改)。

在类似于现代x86的Sandybridge家族(包括Haswell和Skylake)上,存储转发延迟大约为3到5个周期,具体取决于重新加载的时间。因此,对于1周期延迟ALUadd,在这个循环的关键路径中,您会看到大约两个6周期延迟步骤。(大量隐藏所有基于i的存储/重新加载和计算,以及循环计数器更新)。

另请参阅添加冗余分配在未进行优化的情况下编译时加快代码速度了解另一个无优化基准测试。在这种情况下,通过在循环中进行更多独立的工作,延迟重新加载尝试,实际上可以减少存储转发延迟。


现代x86 CPU具有1/clock乘法吞吐量,因此即使进行了优化,您也不会从中看到吞吐量瓶颈。或者在推土机系列上,没有完全以1/2 clock吞吐量进行流水线传输。

更有可能的是,你会在每个周期发布所有工作的前端工作中遇到瓶颈。

尽管lea确实允许非常高效的复制和添加,并且使用单个指令执行i + i + 1。尽管真正优秀的编译器会看到循环只使用2*i并优化为增量2。即强度降低以进行2的重复加法,而不必在环路内移位。

当然,通过优化,额外的sum++可以折叠成sum += stuff,其中stuff已经包括一个常数。乘法不是这样。

我来到这个线程是为了了解现代处理器在整数数学方面所做的事情以及执行这些事情所需的周期数。20世纪90年代,我在65c816处理器上研究了加速32位整数乘法和除法的问题。使用下面的方法,我能够将当时ORCA/M编译器中可用的标准数学库的速度提高三倍。

因此,乘法比加法快的想法根本不是这样(除非很少),但正如人们所说,这取决于架构的实现方式。如果在时钟周期之间有足够的步骤可用,是的,乘法的速度可能与基于时钟的加法的速度相同,但会浪费很多时间。在这种情况下,如果有一条指令在给定一条指令和多个值的情况下执行多个(依赖的)加法/减法,那就太好了。一个人可以做梦。

在65c816处理器上,没有乘法或除法指令。Mult和Div完成了转换和添加
要执行16位添加,您需要执行以下操作:

LDA $0000 - loaded a value into the Accumulator (5 cycles)
ADC $0002 - add with carry  (5 cycles)
STA $0004 - store the value in the Accumulator back to memory (5 cycles)
15 cycles total for an add

如果处理像来自C的调用,那么在处理从堆栈中推送和提取值时会有额外的开销。例如,创建一次做两个倍数的例程可以节省开销。

传统的乘法方法是对一个数字的整个值进行移位和加法运算。每次进位左移时变成1,就意味着你需要再次加上这个值。这需要对每个比特进行测试并对结果进行移位。

我用256项的查找表代替了它,这样进位位就不需要检查了。也可以在进行乘法运算之前确定溢出,以免浪费时间。(在现代处理器上,这可以并行完成,但我不知道他们是否在硬件中这样做)。给定两个32位数字和预筛选溢出,其中一个乘法器总是16位或更少,因此只需要运行一次或两次8位乘法就可以执行整个32位乘法。这样做的结果是乘法的速度是原来的3倍。

16位乘法的速度范围从12个周期到大约37个周期

multiply by 2  (0000 0010)
LDA $0000 - loaded a value into the Accumulator  (5 cycles).
ASL  - shift left (2 cycles).
STA $0004 - store the value in the Accumulator back to memory (5 cycles).
12 cycles plus call overhead.
multiply by (0101 1010)
LDA $0000 - loaded a value into the Accumulator  (5 cycles) 
ASL  - shift left (2 cycles) 
ASL  - shift left (2 cycles) 
ADC $0000 - add with carry for next bit (5 cycles) 
ASL  - shift left (2 cycles) 
ADC $0000 - add with carry for next bit (5 cycles) 
ASL  - shift left (2 cycles) 
ASL  - shift left (2 cycles) 
ADC $0000 - add with carry for next bit (5 cycles) 
ASL  - shift left (2 cycles) 
STA $0004 - store the value in the Accumulator back to memory (5 cycles)
37 cycles plus call overhead

由于为其写入的AppleIIg的数据总线只有8位宽,因此要加载16位值,需要从内存加载5个周期,指针额外一个周期,第二个字节额外一个循环。

LDA指令(1个周期,因为它是一个8位值)$0000(16位值需要两个周期才能加载)存储器位置(由于8位数据总线,需要两个周期才能加载)

现代处理器能够更快地做到这一点,因为它们最坏的情况下有32位数据总线。在处理器逻辑本身中,门系统与数据总线延迟相比将根本没有额外的延迟,因为整个值将立即加载。

要进行完整的32位乘法运算,您需要进行两次以上操作,然后将结果相加,得到最终答案。现代处理器应该能够并行处理这两个问题,并将结果相加得出答案。与并行进行的溢出预检查相结合,可以最大限度地减少执行乘法所需的时间。

无论如何,很明显,乘法比加法需要付出更多的努力。在cpu时钟周期之间处理操作的步骤将决定需要多少个时钟周期。如果时钟足够慢,那么加法的速度将与乘法的速度相同。

谨致问候,Ken

乘法需要最后一步的加法,最小值为相同大小的数字;因此它将比添加花费更长的时间。十进制:

123
112
----
+246  ----
123      | matrix generation  
123    ----
-----
13776 <---------------- Addition

这同样适用于二进制,对矩阵进行了更精细的简化。

也就是说,它们可能需要相同时间的原因:

  1. 为了简化流水线结构,所有常规指令都可以设计为占用相同的周期(例如,内存移动是例外,这取决于与外部内存通信所需的时间)
  2. 由于乘法器最后一步的加法器就像加法指令的加法器。。。为什么不通过跳过矩阵生成和缩减来使用相同的加法器呢?如果他们使用相同的加法器,那么显然他们将花费相同的时间

当然,在更复杂的体系结构中,情况并非如此,您可能会获得完全不同的值。你也有一些体系结构,当它们不相互依赖时,可以并行执行几个指令,然后你就有点受编译器的支配了。。。以及操作系统的。

严格运行此测试的唯一方法是必须在没有操作系统的情况下在程序集中运行,否则变量太多。

即使是这样,它也主要告诉我们时钟对硬件的限制。我们不能因为heat(?)而时钟更高,但信号在时钟期间可以通过的ADD指令门的数量可能非常多,但单个ADD指令只能使用其中一个。因此,尽管在某个时刻可能需要同样多的时钟周期,但并不是所有信号的传播时间都被利用了。

如果我们能把时钟调高,我们就能使ADD更快,可能快几个数量级。

这实际上取决于您的机器。当然,整数乘法与加法相比相当复杂,但相当多的AMD CPU可以在单个周期内执行乘法。这和加法一样快。

其他CPU需要三到四个周期来进行乘法,这比加法慢一点。但这与十年前的性能损失相去甚远(当时在一些CPU上,32位乘法可能需要30多个周期)。

所以,是的,乘法现在的速度是一样的,但不,它仍然没有所有CPU上的加法那么快。

即使在ARM(以其高效、小巧、简洁的设计而闻名)上,整数乘法也需要3-7个周期,而整数加法则需要1个周期。

然而,加法/移位技巧通常用于将整数乘以常数,其速度快于乘法指令计算答案的速度。

这在ARM上工作良好的原因是ARM具有";桶形移位器";,这允许许多指令以零成本将其自变量之一移位或旋转1-31位(即x = a + bx = a + (b << s)花费完全相同的时间量)。

利用这个处理器特性,假设您想要计算a * 15。然后,由于15 = 1111 (base 2),以下伪代码(转换为ARM汇编)将实现乘法:

a_times_3 = a + (a << 1)                  // a * (0011 (base 2))
a_times_15 = a_times_3 + (a_times_3 << 2) // a * (0011 (base 2) + 1100 (base 2))

类似地,您可以使用以下任一项乘以13 = 1101 (base 2)

a_times_5 = a + (a << 2)
a_times_13 = a_times_5 + (a << 3)
a_times_3 = a + (a << 1)
a_times_15 = a_times_3 + (a_times_3 << 2)
a_times_13 = a_times_15 - (a << 1)

在这种情况下,第一个片段显然更快,但有时减法在将常数乘法转换为加法/移位组合时会有所帮助。

这种乘法技巧在80年代末的ARM汇编编码社区中被大量使用,在Acorn阿基米德和Acorn RISC PC(ARM处理器的起源)上。当时,很多ARM程序集都是手工编写的,因为从处理器中挤出最后一个周期很重要。ARM demoscene中的编码器开发了许多类似的技术来加速代码,其中大多数可能已经被历史所遗忘,因为几乎没有汇编代码是手工编写的。编译器可能包含了很多这样的技巧,但我相信还有更多的技巧从未从";黑艺术优化";到编译器实现。

当然,你可以在任何编译语言中编写这样的显式加法/移位乘法代码,一旦编译,代码的运行速度可能会也可能不会比直接乘法快。

x86_64也可能受益于这种针对小常数的乘法技巧,尽管我不认为在英特尔或AMD的实现中,移位在x86_64ISA上是零成本的(x86_64可能每个整数移位或旋转需要一个额外的周期)。

关于您的主要问题,这里有很多不错的答案,但我只是想指出,您的代码不是衡量操作性能的好方法。对于初学者来说,现代cpu一直在调整频率,所以你应该使用rdtsc来计算实际的周期数,而不是经过的微秒。但更重要的是,您的代码有人为的依赖链、不必要的控制逻辑和迭代器,它们会使您的度量变成延迟和吞吐量的奇怪组合,再加上一些无端添加的常量项。要真正测量吞吐量,您应该显著展开循环,并并行添加几个部分和(比add/mul-cpu管道中的步骤更多的和)。

不,它不是,事实上它明显较慢(这意味着我运行的特定现实世界程序的性能命中率为15%)。

就在几天前,当我在这里问这个问题时,我自己也意识到了这一点。

由于其他答案涉及真实的、当今的设备——随着时间的推移,这些设备必然会发生变化和改进——我认为我们可以从理论的角度来看待这个问题。

命题:当使用通常的算法在逻辑门中实现时,整数乘法电路比加法电路慢O(log N)倍,其中N是一个字中的位数。

证明:组合电路稳定的时间与从任何输入到任何输出的最长逻辑门序列的深度成比例。因此,我们必须证明一个等级学校乘法电路比加法电路深O(logN)倍。

加法通常被实现为半加法器,然后是N-1个全加法器,进位位从一个加法器链接到下一个加法器。这个电路显然具有深度O(N)。(该电路可以通过多种方式进行优化,但最坏情况下的性能将始终为O(N),除非使用大得离谱的查找表。)

要将A乘以B,我们首先需要将A的每个位乘以B的每个位。每个逐位相乘都只是一个与门。有N^2个逐位乘法要执行,因此有N^2"与"门——但所有这些门都可以并行执行,电路深度为1。这解决了gradeschool算法的乘法阶段,只剩下加法阶段。

在加法阶段,我们可以使用倒二叉树形电路来组合部分乘积,以并行进行许多加法。树将是(log N)个节点深,在每个节点,我们将把两个具有O(N)位的数字相加。这意味着每个节点都可以用深度为O(N)的加法器来实现,从而给出O(N log N)的总电路深度。QED。