现代CPU上整数乘法的速度是否与加法一样快?

61

我经常听到这样的说法,即在现代硬件上,乘法已经被优化到与加法速度相同。这是真的吗?

我从未得到过任何权威的确认。我的研究只会引发更多问题。速度测试通常会展示让我困惑的数据。以下是一个例子:

#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

然而,使用其他编译器、不同的编译器参数和不同写法的内部循环,结果可能会有所不同,我甚至无法得到一个近似值。


7
值得指出的是,您的循环计数器和加法逻辑也会消耗加法器资源。 - Mysticial
2
但请记住,尽管乘法速度较慢,在现代硬件上,加法和乘法都非常快,因此您很少需要担心它 - 看看您展示它有多难。 - Tim Bergel
4
看起来你的CPU能够同时进行加法和乘法,但不能同时进行两次加法。你的第一个循环每次迭代中使用了加法器两次;你的第二个循环则一次使用加法器和一次使用乘法器。第一个循环依次执行两次加法;第二个循环并行执行它们,所以你可能会发现乘法比两次加法更快。 - Sergey Kalinichenko
1
@TimBergel:那是错误的。我几天前就很容易地观察到了这种差异,事实上我对此感到非常惊讶(请看我的回答,我在一个9秒的任务中节省了1.8秒)。 - user541686
2
@TimBergel:我的情况与3D渲染毫不相似。它非常典型——我所做的只是实现和使用堆(基本上是重新实现了自己版本的std::push_heap等),以便我可以按优先顺序处理项目。我已经没有想法让它更快了;我进行了很多猜测和调试/分析才找出原因,当我看到一个意外的imul指令时,我感到困惑。我知道这很令人惊讶,但我的任务非常典型,而堆中的指针减法确实是瓶颈。 - user541686
显示剩余12条评论
12个回答

48
两个n位数的乘积实际上可以像加法那样在O(log n)电路深度内完成。
O(log n)的加法是通过将数字分成两半,并在“并行”中(递归地)添加这两部分来完成的,其中上半部分解决了“0进位”和“1进位”的情况。一旦添加了下半部分,就会检查进位,其值用于在0进位和1进位之间选择。
O(log n)深度的乘法也是通过“并行化”来完成的,其中每三个数字的总和都被减少到只有两个数字的总和,在某种方式下完成上述操作。
我不会在这里进行解释,但您可以通过查找“carry-lookahead”和“carry-save”加法的阅读材料来了解快速加法和乘法。
因此,从理论上讲,由于电路显然本质上是并行的(不像软件),乘法在渐近上慢的唯一原因是前面的常数因子,而不是渐近复杂度。

4
我听到了你说的一点,但是看这里,你会发现乘法仍然依赖于多次加法!幻灯片53上的数字来自AMD。你可能是正确的,但这也涉及到芯片尺寸和实现成本与统计使用(与仅使用加法相比)的问题。http://www.cis.upenn.edu/~milom/cis371-Spring08/lectures/04_integer.pdf - trumpetlicks
2
@trumpetlicks:我有点困惑,我是不是说过乘法不依赖于加法?我记得我自己明确地说过乘法依赖于加法。 - user541686
4
@trumpetlicks说:“啥……?你在说什么?我在回答中根本没有使用“cycle”、“clock”或“pipeline”等字眼。同时我也从未声称乘法与加法速度相同(相反,我说了相反的话)。我不知道你正在阅读哪个回答来判断这些观点是正确的还是错误的,但肯定不是我的回答……”。 - user541686
我认为实际芯片实现使用类似Karatsuba的方案,可以将表面*周期从O(N^2)减少到O(N^1.5),并且始终具有更高的常数前缀。 O()复杂度分析很有用,但经常会误导。 - paperclip optimizer
1
@paperclipoptimizer:你是基于什么来源认为这个的?这个答案说他们使用Dadda乘法器,它们类似但比Wallace乘法器稍快,而且具有O(log n)深度 - user541686
显示剩余2条评论

35

整数乘法速度较慢。

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


6
“谁告诉你的?”-- 公正地说,可能是一个把它误认为是FP加法/乘法的人。也许是可以理解的错误。 - Dolda2000
9
另外,虽然MUL指令需要2-4个时钟周期完成,但这只是延迟时间。MUL很可能具有每个时钟周期至少可以执行一条指令的吞吐量。至少在Sandy Bridge上是这种情况,我怀疑Haswell的表现不会比这更差。在Sandy Bridge处理器中,ADDSS/MULSS指令的吞吐量也为每个时钟周期执行一条指令,但延迟时间分别为3和5个时钟周期。 - Dolda2000
1
@Dolda2000,这是Agner列出的仅使用寄存器的32位MUL/IMUL的吞吐量。有趣的是,他将64位变体列为1个周期,而不是2个——我想知道这是否是一个打字错误。 - Cory Nelson
@Cory Nelson,我给你点了赞,但是Agner Fog的页面链接并没有提到时钟周期数,对吧? - Nike
2
@user1271772,“延迟”列定义了每个指令的最小延迟,以时钟周期为单位。请注意,流水线技术意味着指令可以重叠,因此这并不表示吞吐量 - Cory Nelson
显示剩余4条评论

14

这比简单的乘法与加法之间的选择更为复杂。实际情况下,答案很可能永远不会是肯定的。在电子方面,乘法是一个更为复杂的电路。大部分原因是乘法是乘法步骤后跟随的加法步骤,还记得在使用计算器之前如何计算十进制数吗?

另一件需要记住的事情是,乘法将根据您正在运行它的处理器架构而长短不同。这可能与公司有关但也可能不是。尽管 AMD 与 Intel 的处理器架构不同,但即使在同一代内,Intel i7 也可能与 core 2 不同,而且在不同的处理器代之间差异更大(特别是越往前走差异越大)。

从技术上讲,如果仅执行乘法(没有循环、计数等操作),那么乘法速度将比加法慢2倍到35倍(如我在 PPC 架构上所看到的)。这更多地是了解您的架构和电子学的练习。

此外: 值得注意的是,可以构建一种处理器,在该处理器中,包括乘法在内的所有操作都只需一次时钟。这种处理器需要做的是,消除所有流水线,并减慢时钟速度,以使任何操作电路的硬件延迟小于或等于时钟定时提供的延迟。

这样做将消除我们在将流水线引入处理器时所能获得的固有性能提升。流水线是将任务分解为更小的子任务,以便更快地执行的思想。通过在子任务之间存储和转发每个子任务的结果,现在可以运行更快的时钟速率,只需要允许最长的子任务延迟时间,而不是整个任务延迟时间。

乘法所需时间的示意图:

|--------------------------------------------------| 未使用流水线

|--Step 1--|--Step 2--|--Step 3--|--Step 4--|--Step 5--| 使用流水线

在上图中,非流水线电路需要50个时间单位。在流水线版本中,我们将50个单位分成5个步骤,每个步骤需要10个时间单位,并插入一个存储步骤。非常重要的一点是,在流水线示例中,每个步骤都可以完全独立且并行工作。为了完成一项操作,它必须按顺序通过所有5个步骤,但同一操作的另一个操作数可以在步骤2中进行,而一个操作数可以在步骤1、3、4和5中进行。
总之,这种流水线方法允许我们每个时钟周期持续填充运算符,并在每个时钟周期获得结果,如果我们能够按顺序执行所有操作,直到我们切换到另一个操作,那么我们只需要花费原始时钟数量来获取第一个操作的结果。
Mystical提出了另一个很好的观点。从系统层面来看架构也很重要。事实上,新的Haswell体系结构是为了改进处理器内的浮点乘法性能而构建的。出于这个原因,在系统级别上,它被设计成允许多个乘法同时发生,而加法只能在每个系统时钟中发生一次。
所有这些可以总结如下: 1. 每种架构在较低级别的硬件和系统角度都是不同的。 2. 从功能上讲,乘法始终需要比加法花费更多时间,因为它将一个真正的乘法与一个真正的加法步骤相结合。 3. 了解您要在其上运行代码的架构,并在可读性和获得该架构真正最佳性能之间找到正确的平衡。

4
值得注意的是,在Haswell处理器上,浮点乘法吞吐量是浮点加法的两倍。这是因为端口0和1都可以用于乘法,但只有端口1可以用于加法。话虽如此,你可以使用融合乘加指令来欺骗处理器,因为两个端口都可以执行该指令。 - Mysticial
@Mysticial - 很好的观点,另一个原因是我的答案更一般化,可以谈论机器的架构 :-) 很棒的笔记。 - trumpetlicks
1
@Mysticial - 需要注意的是,虽然架构支持2个FP乘法器,而不是单个加法器,但这并不意味着在比较单个乘法运算符和单个加法运算符时速度更快。再次强调,这是硬件系统级架构,而不是直接衡量单个加法与单个乘法的性能。 - trumpetlicks
我们知道,在计算机中,乘法比加法要慢,那么为什么在算法分析中我们会将它们的单位成本视为相同呢? - Shajeel Afzal
“的确,新一代的Haswell架构是为了提高处理器内部的浮点乘法性能而设计的。因此,在系统级别上,它被设计成允许多个乘法同时发生,而加法每个系统时钟只能发生一次。” 一个单独的Haswell线程可以同时执行多少个乘法和加法? - Nike
显示剩余14条评论

13

自Haswell以来,英特尔增加了每个时钟周期 4 次操作吞吐量和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内存的存储/重载延迟上瓶颈。请参见Why does clang produce inefficient asm with -O0 (for this simple floating point sum)?以获取更多关于结果汇编语言有多糟糕以及原因的信息。clang++默认为-O0(调试模式:在任何C++语句之间保留变量,供调试器修改)。

现代x86(包括Haswell和Skylake)上的存储转发延迟约为3至5个时钟周期,具体取决于重新加载的时间。因此,在其中有1个时钟周期的ALU add时,您正在寻找大约两个6个时钟周期延迟步骤作为此循环的关键路径。(足够隐藏所有基于存储/重新加载和i计算以及循环计数器更新的操作)。

参见在无优化编译的情况下添加多余赋值会加速代码另一个对优化的基准测试。在那个测试中,通过在循环中增加更多独立的工作,延迟重载尝试,实际上减少了存储转发延迟。


现代x86 CPU具有每时钟周期1次乘法吞吐量,因此即使进行优化,您也不会从中看到吞吐量瓶颈。或者在Bulldozer系列中,其吞吐量未完全流水线化,为每2个时钟周期1个吞吐量。

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

虽然lea允许非常高效的复制和加法,并且使用单个指令执行i + i + 1。虽然好的编译器会看到循环仅使用2*i并优化为增量为2。即通过2的重复加法而不必在循环内部进行移位的强度削弱。

当然,使用优化,额外的sum ++可以折叠到sum + = stuff中,其中stuff已经包括一个常量。但乘法不行。


更新:Ice Lake增加了第二个存储端口,并将前端扩展到5个宽度。Alder Lake P-cores添加了第五个整数ALU端口和第三个加载端口,前端为6个uops宽度。正如https://uops.info/所示,每个时钟周期有5个“add”,还有一个用于加载或存储等其他uop的空间。 - Peter Cordes

7

我来到这个线程是为了了解现代处理器在整数数学方面所做的工作以及完成它们所需的周期数。在1990年代,我曾经研究过如何加速65c816处理器上的32位整数乘除法。使用下面的方法,我能够将当时ORCA/M编译器可用的标准数学库的速度提高三倍。

因此,乘法比加法快的想法并不正确(除非极少数情况),但正如人们所说,这取决于架构的实现方式。如果在时钟周期之间执行的步骤足够多,则乘法可以有效地与基于时钟的加法具有相同的速度,但会浪费大量时间。在这种情况下,最好拥有一条指令,可以执行多个(相关)加/减,并给出一个指令和多个值。人们可以梦想。

在65c816处理器上,没有乘法或除法指令。乘法和除法是通过移位和加法完成的。
要执行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

由于AppleIIgs的数据总线仅有8位,因此要加载16位值需要5个周期从内存中加载,再额外使用一个周期获取指针,并再使用一个周期获取第二个字节。

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

现代处理器能够更快地执行此操作,因为它们最多拥有32位数据总线。在处理器逻辑中,与数据总线延迟相比,门电路系统根本没有额外的延迟,因为整个值会一次性加载。

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

无论如何,很明显,与加法相比,乘法需要更多的工作量。处理CPU时钟周期之间的操作步骤数量将决定需要多少个时钟周期。如果时钟速度足够慢,则加法看起来就与乘法具有相同的速度。

敬礼, 肯恩


现代CPU(例如Intel的i7)上,执行ADD或MUL需要多少个周期? - adieux

6

乘法运算需要进行至少与数字大小相同的加法步骤,因此比加法运算需要更长时间。在十进制中:

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

同样适用于二进制,矩阵会有更详细的缩减。然而,它们可能需要相同的时间的原因如下:
1. 为了简化流水线架构,所有常规指令都可以被设计成需要相同数量的周期(例如,内存移动指令则取决于与外部内存通信所需时间)。
2. 由于乘法器的最后一步所使用的加法器就像加法指令所使用的加法器一样...没有必要通过生成和缩减矩阵来使用不同的加法器。如果它们使用相同的加法器,那么显然它们将需要相同的时间。
当然,还有更复杂的架构,这种情况下你可能会得到完全不同的值。你也有架构可以在彼此不依赖时并行运行多个指令,这时你会受到编译器和操作系统的影响。
唯一严格运行此测试的方法是使用汇编语言并且不使用操作系统——否则就会有太多的变量。

3
即使如此,这基本上告诉我们时钟对硬件的限制。我们不能提高时钟速度是因为热量(?),但在一个时钟周期内,信号可以通过很多个ADD指令门,但是单个ADD指令只能利用其中的一个。因此,虽然在某些时候可能需要相同数量的时钟周期,但并没有利用所有信号的传播时间。
如果我们可以加快时钟速度,我们可以让ADD更快,可能会提高几个数量级。

2
这真的取决于你的机器。当然,与加法相比,整数乘法要复杂得多,但是有不少AMD CPU可以在一个时钟周期内执行乘法。这就和加法一样快。
其他CPU需要三到四个周期来完成乘法,比加法慢一些。但它远远不是十年前你必须承受的性能惩罚(当时某些CPU上的32位乘法可能需要30多个周期)。
因此,是的,现在乘法处于同一速度级别,但不是所有CPU上乘法都像加法那样快。

1
它不在同一速度类别中,因为时钟已经达到了速度极限。这意味着如果你执行ADD操作,CPU在一个时钟周期内更多时间处于空闲状态,因为随着晶体管的缩小,最大时钟频率保持不变,所以信号可以在一个时钟周期内通过更多的门。 - mathreadler
@mathreadler 硬件门需要多长时间才能得出正确的结果对软件来说完全无关紧要:在下一个时钟周期开始之前,结果不能生效。此外,您可以打赌芯片设计师不会构建比满足预期时钟频率要快得多的加法器:这意味着使用宝贵的芯片实际空间来制作毫无意义的复杂进位预测发生器。这些电路是必需的,因为即使在今天,也不可能在单个周期中涟漪传递64位进位。 - cmaster - reinstate monica

2

这里有很多关于你主要问题的好答案,但我想指出你的代码不是衡量操作性能的好方法。 首先,现代CPU会一直调整频率,因此您应该使用rdtsc来计算实际周期数,而不是经过的微秒数。 但更重要的是,您的代码具有人为的依赖链、不必要的控制逻辑和迭代器,这将使您的测量成为延迟和吞吐量的奇怪混合,加上一些没有理由的常数项。 为了真正衡量吞吐量,您应该显着展开循环,并同时添加多个部分和(比CPU管道中的加/乘步骤还要多的和)。


2
即使在以高效率和小巧设计著称的ARM上,整数乘法需要3-7个周期,而整数加法只需要1个周期。
然而,一种名为“加/移位技巧”的方法通常用于通过比乘法指令更快地计算整数常量的乘积。
这种方法在ARM上运行良好的原因是ARM具有“旋转移位器”,它允许许多指令在零成本下将其参数向左或向右移动或旋转1-31位,即x = a + bx = a + (b << s)所需的时间完全相同。
利用这个处理器特性,假设您想要计算a * 15。由于15 = 1111(二进制),以下伪代码(转换为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(二进制)进行乘法:

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 Archimedes和Acorn RISC PC(ARM处理器的起源)上使用。当时,由于从处理器中挤出每个周期都很重要,因此许多ARM汇编是手写的。 ARM demoscene中的编码人员开发了许多像这样加速代码的技术,其中大部分可能已经失传了,因为现在几乎不再手写任何汇编代码。编译器可能会包含许多像这样的技巧,但我确信还有许多技巧从“黑色艺术优化”未能过渡到编译器实现。
当然,您可以在任何编译语言中编写显式的加/移位乘法代码,而且一旦编译后,该代码可能运行得比直接乘法快或慢。
x86_64也可能受益于这种小常数乘法技巧,尽管我不认为在x86_64 ISA上移位是零成本的,无论是在英特尔还是AMD实现中(x86_64可能需要每个整数移位或旋转额外的一个周期)。

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