整数乘法与现代CPU上的加法速度完全相同

时间:2014-02-17 02:23:46

标签: c++ performance cpu multiplication addition

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

我永远无法获得任何权威确认。我自己的研究只会增加问题。速度测试通常会显示让我感到困惑的数据。这是一个例子:

#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 %u\n", time1000() - T, sum);
    sum = 1;
    T = time1000();
    for (int i = 1; i < 100000000; i++) { sum += i * (i+1); sum++; }
    printf("%u %u\n", time1000() - T, sum);
}

上面的代码可以显示乘法更快:

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

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

12 个答案:

答案 0 :(得分:22)

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

O(log n)中的加法是通过将数字分成两半并(递归地)将 parallel 中的两个部分相加来完成的,其中上半部分是两个“0-carry”和“1-carry”案例。一旦添加下半部分,就会检查进位,其值用于在0进位和1进位情况之间进行选择。

O(log n)深度的乘法通过并行化完成,其中3个数字的每个和被减少为并行的仅2个数的总和,并且总和是以上述某种方式完成的 我不会在这里解释,但你可以通过查找“carry-lookahead”“carry-save”来找到关于快速加法和乘法的阅读材料。< / p>

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

答案 1 :(得分:19)

不,他们的速度不一样。是谁告诉你的?

Agner Fog's instruction tables表明当使用32位整数寄存器时,Haswell的ADD / SUB需要0.25-1个周期(取决于指令的流水线程度),而MUL需要2-4个周期。浮点是另一种方式:ADDSS / SUBSS需要1-3个周期,而MULSS需要0.5-5个周期。

答案 2 :(得分:6)

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

要记住的另一件事是乘法将花费更长或更短的时间,具体取决于您运行它的处理器的体系结构。这可能是也可能不是公司特定的。虽然AMD很可能与英特尔不同,但即使是英特尔i7也可能与核心2(在同一代内)不同,而且几代人之间肯定会有所不同(特别是你走得更远)。

在所有的技术性中,如果乘法是你唯一做的事情(没有循环,计数等......),乘法将是2到(如在PPC架构上看到的那样)慢35倍。这更像是了解您的架构和电子设备的练习。

另外: 应该注意,可以构建一个处理器,包括乘法的所有操作都需要一个时钟。这个处理器必须做的是,摆脱所有流水线操作,并减慢时钟速度,以便任何OP电路的硬件延迟小于或等于时钟时序提供的延迟。

要做到这一点,可以摆脱我们在将流水线添加到处理器时能够获得的固有性能提升。流水线技术是将任务分解为更小的子任务的想法,这些子任务可以更快地执行。通过在子任务之间存储和转发每个子任务的结果,我们现在可以运行更快的时钟速率,只需要允许子任务的最长延迟,而不是整个总体任务。< / p>

通过乘法的时间图片:

| ---------------------------------------------- ---- |非流水线

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

在上图中,非流水线电路需要50个单位时间。在流水线版本中,我们将50个单元分成5个步骤,每个步骤占用10个单位时间,其间有一个存储步骤。值得注意的是,在流水线示例中,每个步骤都可以完全独立工作。对于要完成的操作,它必须按顺序遍历所有5个步骤,但操作数的另一个操作可以在步骤2中,如步骤1,3,4和5中那样。

说到这一切,这种流水线方法允许我们在每个时钟周期连续填充运算符,并在每个时钟周期得到结果如果我们能够订购我们的操作,以便我们可以执行所有的一个操作在我们切换到另一个操作之前,我们所做的所有时间命中都是将FIRST操作从管道中取出所需的原始时钟量。

神秘提出了另一个好点。从更系统的角度来看这个架构也很重要。确实,较新的Haswell架构是为了提高处理器内的浮点乘法性能而构建的。出于这个原因,作为系统级别,它被设计为允许多个乘法同时发生,而添加只能在每个系统时钟发生一次。

所有这一切可归纳如下:

  1. 每个架构都不同于较低级别的硬件角度以及系统角度
  2. 功能性,乘法将总是比添加更多的时间,因为它结合了真正的乘法和真正的加法步骤。
  3. 了解您尝试运行代码的体系结构,并在可读性和从该体系结构中获得真正最佳性能之间找到适当的平衡。

答案 3 :(得分:5)

自Haswell以来拥有英特尔

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

Ryzen与此类似。推土机系列的整数吞吐量低得多,并且乘法没有完全流水线化,对于64位操作数大小的乘法,速度特别慢。请参阅https://agner.org/optimize/https://stackoverflow.com/tags/x86/info

中的其他链接

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


您忘了启用优化,因此两个循环都绕过了ALU的瓶颈,并且存储/重载延迟sum保留在{{1} }和sum += independent stuff。请参阅Why does clang produce inefficient asm with -O0 (for this simple floating point sum)?,以获取有关生成的asm的严重程度以及为何如此的更多信息。 sum++的默认值为clang++(调试模式:将变量保存在内存中,调试器可以在其中在任何C ++语句之间修改变量)。

在像Sandybridge系列(包括Haswell和Skylake)这样的现代x86上,存储转发延迟大约为3到5个周期,具体取决于重新加载的时间。因此,在其中还具有1个周期的延迟ALU -O0,您正在此循环的关键路径中查看大约两个6个周期的延迟步骤。 (大量隐藏所有基于add的存储/重新加载和计算,以及循环计数器更新)。

另请参见Adding a redundant assignment speeds up code when compiled without optimization ,以获取另一个没有优化的基准。在这种情况下,实际上,通过在循环中进行更多的独立工作来减少存储转发延迟,从而延迟了重新加载的尝试。


现代x86 CPU的吞吐量是1时钟倍增,因此即使进行了优化,您也不会看到吞吐量瓶颈。或在Bulldozer系列中,每2时钟吞吐量中没有1个完全流水线化。

您很有可能在前端工作上遇到瓶颈,因为每个周期都要发布所有工作。

尽管i确实允许非常高效的复制和添加,并且只需一条指令即可完成lea。尽管确实是一个很好的编译器,但是该循环仅使用i + i + 1并进行优化以增加2。即降低强度以进行2的重复加法,而不必在循环内移动。

当然,通过优化,多余的2*i可以折叠成sum++,其中sum += stuff已经包含一个常数。乘法不是这样。

答案 4 :(得分:2)

我来到这个线程的目的是了解现代处理器在整数数学方面的工作以及执行它们所需的周期数。我曾在1990年代致力于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的调用,则将有更多开销来处理从堆栈中推入和拉出值。例如,创建一次执行两个倍数的例程将节省开销。

进行乘法的传统方式是移位并加一数字的整个值。每次向左移动进位时,都意味着您需要再次添加该值。这需要对每个位进行测试,并对结果进行移位。

我将其替换为包含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

由于为此编写的AppleIIgs的数据总线只有8位宽,因此要加载16位值,需要从内存中加载5个周期,为指针增加1个周期,为第二个字节增加1个周期。

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

现代处理器将能够更快地执行此操作,因为它们在最坏的情况下具有32位数据总线。与数据总线延迟相比,在处理器逻辑本身中,门系统完全没有附加延迟,因为整个值将立即被加载。

要执行完整的32位乘法,您需要执行两次以上并将结果加在一起以获得最终答案。现代处理器应该能够并行执行这两个操作,并将结果相加以获得答案。结合并行执行的溢出预检查,可以最大程度地减少乘法所需的时间。

无论如何,很明显,乘法比加法需要更多的精力。在cpu时钟周期之间处理操作的多少步骤将确定需要多少个时钟周期。如果时钟足够慢,则加法运算速度似乎与乘法运算相同。

关于, 肯

答案 5 :(得分:1)

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

其他CPU需要三到四个周期才能进行乘法,这比加法要慢一些。但它远不及你十年前遭受的性能损失(当时32位乘法可能会在一些CPU上花费三十个周期)。

所以,是的,乘法现在处于相同的速度等级,但不是,它仍然不如所有CPU上的增加速度快。

答案 6 :(得分:1)

乘法需要最后一步添加至少相同数量的数字;所以它需要更长的时间。十进制:

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

同样适用于二进制,更精细地减少矩阵。

那就是他们可能花费相同时间的原因:

  1. 为了简化流水线架构,可以将所有常规指令设计为采用相同数量的周期(例外情况是内存移动,这取决于与外部存储器通信所需的时间)。
  2. 由于乘法器最后一步的加法器就像加法指令的加法器一样...为什么不通过跳过矩阵生成和缩减来使用相同的加法器呢?如果他们使用相同的加法器,那么显然他们将花费相同的时间。
  3. 当然,有些更复杂的架构并非如此,您可能会获得完全不同的值。你也有一些架构,当它们不相互依赖时,并行地接受几条指令,然后你有点受编译器......和操作系统的支配。

    严格运行此测试的唯一方法是必须在汇编和没有操作系统的情况下运行 - 否则变量太多。

答案 7 :(得分:1)

即使它是,这主要告诉我们时钟对我们的硬件有什么限制。由于热量(?),我们无法提高时钟,但是信号在时钟期间可能传递的ADD指令门的数量可能非常多,但单个ADD指令只能使用其中一个。因此,虽然它可能在某些时候需要相同的时钟周期,但并不是所有信号的传播时间都被利用。

如果我们可以提高时间,我们可以def。使ADD更快可能达到几个数量级。

答案 8 :(得分:0)

不,不是,事实上它明显变慢了(这导致我运行的特定真实世界节目的性能提升了15%)。

我在几天前提出这个问题时自己意识到这一点here

答案 9 :(得分:0)

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

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

在ARM上运行良好的原因是ARM具有“桶形移位器”,它允许许多指令以零成本将x = a + b和{{ 1}}花费的时间完全相同。

利用此处理器功能,假设您要计算x = a + (b << s)。然后从a * 15开始,以下伪代码(转换为ARM程序集)将实现乘法:

15 = 1111 (base 2)

类似地,您可以使用以下任一方法乘以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)

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

这个乘法技巧在80年代末期在Acorn Archimedes和Acorn RISC PC(ARM处理器的起源)的ARM汇编编码社区中得到了广泛使用。那时,很多ARM组装都是手工编写的,因为从处理器中挤出每个最后的周期很重要。 ARM演示环境中的编码人员开发了许多类似的技术来加速代码,但由于几乎不再需要手工编写汇编代码,因此大多数技术可能已被遗忘。编译器可能包含许多这样的技巧,但是我敢肯定,还有更多的东西从未实现过从“妖术优化”到编译器实现的过渡。

您当然可以在任何编译语言中编写像这样的显式加/移位乘法代码,并且编译后的代码运行速度可能会快于直接乘法。

x86_64也可以从小常量的乘法技巧中受益,尽管我不认为在Intel或AMD实现中,x86_64 ISA上的移位成本为零(对于每个整数移位,x86_64可能需要一个额外的周期,或者旋转)。

答案 10 :(得分:0)

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

答案 11 :(得分:-2)

由于其他答案涉及真实的,现今的设备 - 随着时间的推移必然会改变和改进 - 我认为我们可以从理论方面看待这个问题。

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

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

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

要将A乘以B,我们首先需要将A的每个位与B的每个位相乘。每个按位乘法只是一个AND门。有N ^ 2个按位乘法运算,因此N ^ 2 AND门 - 但它们都可以并行执行,电路深度为1.这解决了gradechool算法的乘法阶段,只留下了加法阶段。

在加法阶段,我们可以使用倒置二进制树形电路组合部分产品,以并行执行许多添加。树将是(log N)个节点深,并且在每个节点,我们将两个数字加在一起,带有O(N)位。这意味着每个节点可以用深度为O(N)的加法器实现,给出总电路深度为O(N log N)。 QED。