为什么GCC在实现整数除法时使用乘以奇数的乘法?


227

我一直在阅读divmul汇编操作,我决定通过用C编写一个简单程序来看到它们的作用:

文件分割

#include <stdlib.h>
#include <stdio.h>

int main()
{
    size_t i = 9;
    size_t j = i / 5;
    printf("%zu\n",j);
    return 0;
}

然后使用以下命令生成汇编语言代码:

gcc -S division.c -O0 -masm=intel

但是查看生成的division.s文件,它不包含任何div操作!取而代之的是,它执行某种带有移位和魔术数字的黑魔法。这是一个计算的代码片段i/5

mov     rax, QWORD PTR [rbp-16]   ; Move i (=9) to RAX
movabs  rdx, -3689348814741910323 ; Move some magic number to RDX (?)
mul     rdx                       ; Multiply 9 by magic number
mov     rax, rdx                  ; Take only the upper 64 bits of the result
shr     rax, 2                    ; Shift these bits 2 places to the right (?)
mov     QWORD PTR [rbp-8], rax    ; Magically, RAX contains 9/5=1 now, 
                                  ; so we can assign it to j

这里发生了什么?为什么GCC根本不使用div?它是如何产生这个幻数的?为什么一切正常?


29
gcc通过常数优化除法,尝试用2,3,4,5,6,7,8除法,您很可能会在每种情况下看到非常不同的代码。
Jabberwocky

28
注意:幻数-3689348814741910323转换CCCCCCCCCCCCCCCD为a uint64_t或约为(2 ^ 64)* 4/5。
chux-恢复莫妮卡

32
@qiubit:编译器也不会因为禁用优化而错误地生成低效率的代码。无论如何,都将执行不涉及代码重新排序或变量消除的琐碎“优化”。本质上,单个源语句将独立地转换为该操作的最有效代码。编译器优化考虑了周围的代码,而不是单个语句。
克利福德,

20
阅读这篇很棒的文章:部门劳动
小丑2016年

9
实际上,由于禁用了优化,某些编译器会错误地生成效率低下的代码。特别是,它们将使调试变得容易,例如能够在单独的代码行上设置断点。实际上,GCC非常不常见,因为它没有真正的“无优化”模式,因为它的许多优化都是有组织地启用的。这是使用GCC可以看到的示例。另一方面,Clang和MSVC 将在发出div指令-O0。(抄送@悬崖)
科迪·格雷

Answers:


169

整数除法是您可以在现代处理器上执行的最慢的算术运算之一,延迟高达数十个周期,并且吞吐量很差。(对于x86,请参阅Agner Fog的说明表和Microarch指南)。

如果您提前知道除数,则可以通过用一组等效的其他运算(乘法,加法和移位)代替除数来避免除数。即使需要执行几个运算,它通常仍然比整数除法本身快很多。

/这种方式实现C 运算符,而不是使用涉及多指令序列div的方式,只是GCC进行常数除法的默认方式。它不需要跨操作进行优化,即使调试也不会改变任何内容。(不过,使用-Os较小的代码大小确实可以使GCC使用div。)使用乘法逆而不是除法就像使用lea代替mul和一样。add

结果,只有在编译时不知道除数的情况下,您才倾向于看到dividiv在输出中。

有关编译器如何生成这些序列的信息,以及使您自己生成它们的代码(除非您使用Braindead编译器,否则几乎可以肯定是不需要的),请参见libdivide


5
我不确定在速度比较中将FP和整数运算结合在一起@fuz是否公平。也许Sneftel应该说除法运算是您可以在现代处理器上执行的最慢的整数运算?同样,在评论中提供了一些指向此“魔术”的进一步解释的链接。您认为它们适合收集您的答案以提高知名度吗?123
科迪灰色

1
由于操作顺序在功能上是相同的...即使在处,也始终是必要条件-O3。编译器必须编写能为所有可能的输入值提供正确结果的代码。仅对于带有的浮点会更改-ffast-math,并且AFAIK没有“危险的”整数优化。(启用优化功能后,编译器可能会证明有关值的可能范围的某些内容,从而使它使用仅适用于非负有符号整数的内容。)
Peter Cordes

6
真正的答案是gcc -O0 仍然通过内部表示将代码转换为将C转换为机器代码的一部分。碰巧的是,即使在处-O0(但不使用-Os),默认情况下也会启用模块化乘法逆。其他编译器(如clang)将对DI的非幂次方常量使用DIV -O0。相关:我觉得我包括这个段落我在Collat​​z-猜想手写ASM答案
彼得·科德斯

6
@PeterCordes是的,我认为GCC(以及许多其他编译器)忘记了提出一个很好的理由,即“禁用优化时会进行哪些类型的优化”。花了一天的大部分时间来追踪一个晦涩的codegen错误,此刻我对此有些恼火。
Sneftel

9
@Sneftel:这可能只是因为积极编译器开发人员抱怨其代码运行速度超出预期的应用程序开发人员的数量相对较少。
dan04 '16

121

除以5等于乘以1/5,再次等于乘以4/5并右移2位。相关值CCCCCCCCCCCCCCCD以十六进制表示,如果放在十六进制点之后,则为4/5的二进制表示形式(即,0.110011001100重复出现五分之四的二进制数-有关原因,请参见下文)。我想你可以从这里拿走!您可能想检查定点算法(尽管请注意,最后将其四舍五入为整数。

至于为什么,乘法比除法快,并且当除数固定时,这是一条更快的路线。

请参阅互逆乘法,该教程提供了有关其工作原理的详细文章,并针对定点进行了说明。它显示了求倒数的算法如何工作,以及如何处理有符号除法和模。

让我们考虑一下为什么0.CCCCCCCC...(十六进制)或0.110011001100...二进制为4/5。将二进制表示除以4(右移2位),然后0.001100110011...通过平凡的检查就可以将原始表示形式添加到get中0.111111111111...,显然等于1,0.9999999...十进制中的相同方式等于1。因此,我们知道x + x/4 = 1,所以5x/4 = 1x=4/5。然后将其表示为CCCCCCCCCCCCD十六进制以进行四舍五入(因为最后一个出现的二进制数字将是1)。


2
@ user2357112随时发布您自己的答案,但我不同意。您可以将乘积视为64.0位乘以0.64位乘以给出128位的定点答案,将丢弃其中的最低64位,然后除以4(我在第一段中指出)。您也许可以提出一个替代的模块化算术答案,该答案同样可以很好地解释位的移动,但是我很确定这可以作为一种解释。
abligh

6
该值实际上是“ CCCCCCCCCCCCCCCCCD”。最后一个D很重要,它可以确保在结果被截断时精确的除法得到正确的答案。
plugwash

4
没关系。我没有看到它们占用了128位乘法结果的高64位。在大多数语言中您都无法做到这一点,因此我最初并没有意识到它正在发生。通过明确提及将128位结果的高64位等同于乘以定点数并四舍五入,将大大改善此答案。(此外,最好解释为什么它必须是4/5而不是1/5,以及为什么我们必须将4/5向上舍入而不是向下
舍入

2
否则,您将必须计算出需要多大的误差才能在四舍五入的边界上向上除以5,然后将其与计算中最坏的情况相比较。大概,海湾合作委员会的开发者这样做了,并得出结论,它将始终给出正确的结果。
plugwash

3
实际上,如果输入值正确取整,则您可能只需要检查5个可能的最高输入值。
plugwash

60

通常,乘法比除法快得多。因此,如果我们可以避免乘以倒数,则可以大大加快除数的速度

皱纹是我们不能精确地表示倒数(除非除以2的幂,但是在那种情况下,我们通常可以将除数转换为位移)。因此,为确保正确答案,我们必须小心,倒数的错误不会导致最终结果的错误。

-3689348814741910323是0xCCCCCCCCCCCCCCCCCD,它的值刚好超过4/5,以0.64定点表示。

当我们将64位整数乘以0.64定点数时,将得到64.64的结果。我们将值截断为64位整数(有效地将其舍入为零),然后执行进一步的移位,将其除以4,然后再次截断。通过查看位级别,很明显,我们可以将这两个截断都视为单个截断。

显然,这至少使我们近似除以5,但是它是否为我们提供了一个正确的答案,正确地将其四舍五入为零?

为了获得准确的答案,误差必须足够小,以免将答案推到舍入边界上。

除以5的确切答案将始终是0、1 / 5、2 / 5、3 / 5或4/5的小数部分。因此,乘和移位后的结果中小于1/5的正误差将永远不会使结果超出舍入边界。

我们常数的误差是(1/5)* 2 -64i的值小于2 64,因此相乘后的误差小于1/5。除以4后,误差小于(1/5)* 2 -2

(1/5)* 2 −2 <1/5,因此答案将始终等于进行精确除法并舍入为零。


不幸的是,这不适用于所有除数。

如果我们尝试将4/7表示为一个0.64的固定点数,并且四舍五入为零,那么最终将得出(6/7)* 2 -64的误差。将i值乘以2 64之后,最终得到的误差就在6/7以下,而除以四之后,我们得到的误差就在1.5 / 7以下,即大于1/7。

因此,要正确实现7分频,我们需要乘以0.65的固定点数。我们可以通过乘以固定点数的低64位,然后加上原始数字(这可能会溢出到进位位),然后进行循环进位来实现。


8
这个答案将模块化乘法逆运算从“看起来比我想花更多时间复杂的数学”转变为有意义的东西。+1(易于理解的版本)。除了使用编译器生成的常量外,我不需要做任何其他事情,因此,我仅浏览了其他解释数学的文章。
彼得·科德斯

2
我在代码中根本看不到与模块化算术有任何关系。Dunno其他评论员从那里得到的信息。
plugwash

3
它的模数为2 ^ n,就像寄存器中的所有整数数学一样。 en.wikipedia.org/wiki/…–
彼得·科德斯

4
@PeterCordes模乘逆用于精确除法,afaik不适用于一般除法
harold

4
@PeterCordes乘以定点倒数?我不知道大家都叫,但我可能会叫的话,这是相当描述
哈罗德·

12

这是一个算法文档的链接,该文档生成我在Visual Studio中看到的值和代码(在大多数情况下),并且我认为在GCC中仍将其用于将变量整数除以常量整数。

http://gmplib.org/~tege/divcnst-pldi94.pdf

在本文中,一个uword有N位,一个udword有2N位,n =分子=被除数,d =分母=除数,ℓ最初设置为ceil(log2(d)),shpre是预移位的(在乘法之前使用) )= e = d中尾随零位的数量,shpost是后移位(在乘法之后使用),prec是precision = N-e = N-shpre。目标是使用预移位,乘法和后移位优化n / d的计算。

向下滚动至图6.2,该图定义了如何生成udword乘数(最大大小为N + 1位),但没有清楚地说明该过程。我将在下面解释。

图4.2和图6.2显示了对于大多数除数,如何将乘数减小到N位或更少。公式4.5解释了如何得出图4.1和4.2中用于处理N + 1位乘法器的公式。

在现代X86和其他处理器的情况下,乘法时间是固定的,因此预移位对这些处理器无济于事,但仍有助于将乘法器从N + 1位减少到N位。我不知道GCC或Visual Studio是否已消除X86目标的预转换。

回到图6.2。仅当分母(除数)> 2 ^(N-1)时(当ℓ== N => mlow = 2 ^(2N)时),mlow和mhigh的分子(股息)才能大于udword。 n / d的最佳替代是比较(如果n> = d,q = 1,否则q = 0),因此不会生成乘数。mlow和mhigh的初始值为N + 1位,两个udword / uword除法可用于产生每个N + 1位值(mlow或mhigh)。以64位模式下的X86为例:

; upper 8 bytes of dividend = 2^(ℓ) = (upper part of 2^(N+ℓ))
; lower 8 bytes of dividend for mlow  = 0
; lower 8 bytes of dividend for mhigh = 2^(N+ℓ-prec) = 2^(ℓ+shpre) = 2^(ℓ+e)
dividend  dq    2 dup(?)        ;16 byte dividend
divisor   dq    1 dup(?)        ; 8 byte divisor

; ...
        mov     rcx,divisor
        mov     rdx,0
        mov     rax,dividend+8     ;upper 8 bytes of dividend
        div     rcx                ;after div, rax == 1
        mov     rax,dividend       ;lower 8 bytes of dividend
        div     rcx
        mov     rdx,1              ;rdx:rax = N+1 bit value = 65 bit value

您可以使用GCC进行测试。您已经了解了j = i / 5的处理方式。看一下j = i / 7的处理方式(应该是N + 1位乘数的情况)。

在当前大多数处理器上,乘法具有固定的时序,因此不需要预移位。对于X86,对于大多数除数,最终结果是两个指令序列,对于像7一样的除数,最终结果是五个指令序列(以模拟pdf文件的公式4.5和图4.2中所示的N + 1位乘法器)。示例X86-64代码:

;       rax = dividend, rbx = 64 bit (or less) multiplier, rcx = post shift count
;       two instruction sequence for most divisors:

        mul     rbx                     ;rdx = upper 64 bits of product
        shr     rdx,cl                  ;rdx = quotient
;
;       five instruction sequence for divisors like 7
;       to emulate 65 bit multiplier (rbx = lower 64 bits of multiplier)

        mul     rbx                     ;rdx = upper 64 bits of product
        sub     rbx,rdx                 ;rbx -= rdx
        shr     rbx,1                   ;rbx >>= 1
        add     rdx,rbx                 ;rdx = upper 64 bits of corrected product
        shr     rdx,cl                  ;rdx = quotient
;       ...

该论文描述了如何在gcc中实现它,因此我认为可以肯定的是仍然使用相同的算法。
彼得·科德斯

1994年的那篇论文描述了如何在gcc中实现它,因此gcc有时间更新其算法了。以防万一其他人没有时间检查该URL中的94的含义。
艾德·格林

0

我将从一个略有不同的角度回答:因为允许这样做。

C和C ++是针对抽象机定义的。编译器在下面的抽象机方面混凝土机械改造这个程序为,如果规则。

  • 只要编译器不更改抽象机指定的可观察行为,就可以进行任何更改。没有合理的期望,编译器将以可能最直接的方式转换代码(即使许多C程序员都假定这样做)。通常这样做是因为与直接方法相比,编译器希望优化性能(如其他答案所述)。
  • 如果在任何情况下,编译器都会将“正确”程序“优化”为具有不同可观察行为的程序,那就是编译器错误。
  • 我们代码中的任何未定义行为(有符号整数溢出是典型示例),并且此合同无效。
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.