关于C#:为什么GCC在实现整数除法时使用乘以奇数的乘法?

Why does GCC use multiplication by a strange number in implementing integer division?

我一直在读关于divmul装配操作的文章,我决定用C语言编写一个简单的程序,以看到它们的实际应用:

文件分割

1
2
3
4
5
6
7
8
9
10
11
#include <stdlib.h>
#include <stdio.h>

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

然后生成汇编语言代码:

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

但是查看生成的division.s文件,它不包含任何DIV操作!相反,它做了一些黑色魔术与位移动和魔术数字。下面是计算i/5的代码片段:

1
2
3
4
5
6
7
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?它是如何产生这个幻数的?为什么所有的东西都能工作?


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

如果你提前知道除数,你可以用一组其他的操作(乘法、加法和移位)代替它来避免除数。即使需要几个操作,它仍然比整数除法本身快得多。

用这种方式实现c /运算符,而不是使用涉及div的多指令序列,这只是gcc按常量进行除法的默认方法。它不需要跨操作进行优化,也不需要更改任何内容,即使用于调试。(对小代码使用-Os确实使gcc使用div。)使用乘法逆函数而不是除法,就像使用lea而不是muladd一样。

因此,只有在编译时除数未知的情况下,才会在输出中看到dividiv

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


除以5等于乘以1/5,同样等于乘以4/5和右移2位。有关的值是十六进制的CCCCCCCCCCCCD,如果放在十六进制点之后,它是4/5的二进制表示(即,五分之四的二进制是0.110011001100循环出现的-请参阅下面的原因)。我想你可以从这里拿走!您可能需要检查定点算术(尽管注意它在末尾被四舍五入为整数)。

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

有关如何工作的详细说明,请参阅倒数乘法,这是一个教程,以固定点的形式进行解释。它说明了求倒数的算法是如何工作的,以及如何处理有符号除法和模。

让我们考虑一下为什么EDOCX1(hex)或0.110011001100...二进制是4/5。将二进制表示除以4(右移2位),我们得到0.001100110011...,通过简单的检查,可以把原来的加起来得到0.111111111111...,显然等于1,同样的,0.9999999...在十进制中等于1。因此,我们知道x + x/4 = 1,所以5x/4 = 1x=4/5。然后用十六进制表示为CCCCCCCCCCCCD,用于四舍五入(超出最后一个数字的二进制数字为1)。


一般来说,乘法比除法快得多。因此,如果我们可以不用乘以倒数,我们可以显著加快除以常数的速度。

一个折痕是我们不能精确地表示倒数(除非除法是2的幂,但在这种情况下,我们通常可以将除法转换为位移位)。因此,为了确保正确的答案,我们必须小心,我们的相互关系中的错误不会导致最终结果中的错误。

-3689348814741910323是0xCCCCCCCCCCCCCD,其值略大于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-64。i的值小于264,因此乘以后的错误小于1/5。除以4后,误差小于(1/5)*2&减去;2。

(1/5)*2&minus;2<1/5,因此答案始终等于执行精确的除法并四舍五入为零。

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

如果我们试图将4/7表示为一个0.64的定点数,从零开始取整,最终会得到一个误差(6/7)*2-64。乘以一个略低于264的i值后,我们会得到一个略低于6/7的错误,除以4后,我们会得到一个略低于1.5/7的错误,该错误大于1/7。

所以要正确地实现除数7,我们需要乘以一个0.65的定点数。我们可以通过将定点数的低64位相乘,然后添加原始数(这可能会溢出到进位中),然后执行一个旋转进位来实现这一点。


这里是一个算法文档的链接,它生成我在Visual Studio中看到的值和代码(在大多数情况下),并且我假设在gcc中仍然使用它来将变量整数除以常量整数。

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

在本文中,uword有n个位,udword有2n个位,n=分子=被除数,d=分母=除数,?最初设置为CEIL(log2(d)),shpre为预移位(在乘法前使用)=e=d中尾随零位的个数,shpost为后移位(在乘法后使用),prec为精度=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)(何时)时,mlow和mhigh的分子(股息)才能大于udword。==n=>mlow=2^(2n)),在这种情况下,对n/d的优化替换是比较(如果n>=d,q=1,否则q=0),因此不会生成乘数。mlow和mhigh的初始值将为n+1位,并且可以使用两个udword/uword除法生成每个n+1位值(mlow或mhigh)。以64位模式下的x86为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
; 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(以便模拟n+1位乘法器,如PDF文件的方程式4.5和图4.2所示)。示例x86-64代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
;       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
;       ...