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

qiu*_*bit 206 c assembly gcc x86-64 integer-division

我一直在阅读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;
}
Run Code Online (Sandbox Code Playgroud)

然后生成汇编语言代码:

gcc -S division.c -O0 -masm=intel
Run Code Online (Sandbox Code Playgroud)

但是看生成的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
Run Code Online (Sandbox Code Playgroud)

这里发生了什么?为什么海湾合作委员会根本不使用div?它如何产生这个神奇的数字以及为什么一切都有效?

Sne*_*tel 157

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

如果您提前知道除数,则可以通过将其替换为具有相同效果的一组其他运算(乘法,加法和移位)来避免除法.即使需要进行多次操作,它通常仍然比整数除法本身快得多.

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

因此,如果在编译时不知道除数,则只倾向于看到dividiv在输出中.

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

  • @Sneftel:这可能只是因为那些主动向编译器开发者抱怨他们的代码运行速度超过预期的应用程序开发人员的数量相对较少. (8认同)
  • @PeterCordes是的,我认为GCC(以及许多其他编译器)已经忘记了"在禁用优化时适用哪种优化"的良好理由.花了一天的时间来追踪一个模糊的代码错误,我现在对此感到有些恼火. (6认同)
  • 我不确定在速度比较中将FP和整数运算混为一谈是否合理,@ fuz.也许Sneftel应该说*division*是你可以在现代处理器上执行的最慢*整数*操作?此外,评论中还提供了一些关于这种"魔术"的进一步解释的链接.你认为他们适合收集你的能见度答案吗?[1](http://www.flounder.com/multiplicative_inverse.htm),[2](http://ridiculousfish.com/blog/posts/labor-of-division-episode-i.html),[3] ](http://blog.sigfpe.com/2010/05/optimising-pointer-subtraction-with-2.html) (5认同)
  • 真正的答案是gcc -O0 [仍然通过内部表示将代码转换为将C转换为机器代码的一部分](http://stackoverflow.com/a/33284629/224132).只是在"-O0"(但不是`-Os`)中默认启用模块化乘法反转.其他编译器(如clang)将在-O0`中使用DIV作为非幂2的常量.相关:我想我在[我的Collat​​z-conjecture手写的asm答案]中包含了一个关于此的段落(http://stackoverflow.com/a/40355466/224132) (5认同)
  • *因为操作顺序在功能上是相同的...*这始终是一个要求,即使在`-O3`。编译器必须编写能够为所有可能的输入值提供正确结果的代码。这仅适用于带有 `-ffast-math` 的浮点数,并且 AFAIK 没有“危险的”整数优化。(启用优化后,编译器可能能够证明有关可能的值范围的某些内容,例如,它可以使用仅适用于非负有符号整数的内容。) (2认同)

abl*_*igh 109

除以5与乘以1/5相同,再乘以4/5并向右移2位相同.有关的值是CCCCCCCCCCCCCCCD十六进制,如果放在一个十六进制点后面是4/5的二进制表示(即四分之五的二进制0.110011001100重复出现 - 见下面的原因).我想你可以从这里拿走它!您可能想要检查定点算术(尽管注意它在最后舍入为整数.

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

请参阅Reciprocal Multiplication,这是一个关于它如何工作的详细文章的教程,用定点来解释.它显示了查找倒数的算法如何工作,以及如何处理有符号的除法和模数.

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

  • 该值实际上是"CCCCCCCCCCCCCCCD"最后一个D很重要,它确保当结果被截断时,确切的除法会出现正确的答案. (5认同)
  • 没关系.我没有看到他们正在采用128位乘法结果的高64位; 这不是你在大多数语言中都可以做的事情,所以我最初没有意识到它正在发生.通过明确提及如何将128位结果的高64位相当于乘以定点数并向下舍入,可以大大改善这个答案.(另外,最好解释为什么它必须是4/5而不是1/5,为什么我们必须将4/5向上舍入而不是向下.) (3认同)
  • @ user2357112 随时发布您自己的答案,但我不同意。您可以将乘法视为 64.0 位乘以 0.64 位的乘法,给出 128 位定点答案,其中最低 64 位被丢弃,然后除以 4(正如我在第一段中指出的那样)。您很可能会想出一个替代的模块化算术答案,它同样可以很好地解释位移动,但我很确定这可以作为一种解释。 (2认同)
  • 假设你需要弄清楚需要多大的误差才能在一个四舍五入的边界上将一个除法率提高5,然后将其与你的计算中的最坏情况误差进行比较.据推测,gcc开发人员已经这样做,并得出结论,它总会得到正确的结果. (2认同)
  • 实际上,你可能只需要检查5个最高可能的输入值,如果那些正确的其他一切也应该. (2认同)

plu*_*ash 56

通常,乘法比除法快得多.因此,如果我们可以通过乘以倒数来逃避,我们可以通过常数显着加快除法

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

-3689348814741910323是0xCCCCCCCCCCCCCCCD,它是刚好超过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的值小于2 64,因此乘法后的误差小于1/5.除以4后,误差小于(1/5)*2 -2.

(1/5)*2 -2 <1/5所以答案总是等于做一个精确的除法并向零舍入.


不幸的是,这对所有除数都不起作用.

如果我们试图将4/7表示为0.64定点数,并且从零开始舍入,则最终得到(6/7)*2 -64的误差.乘以一个略低于2 64的i值后,我们最终得到一个不到6/7的误差,除以4后,我们最终得到的误差略低于1.5/7,大于1/7.

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

  • 这个答案变成了模数乘法反转,从"看起来比我想要花费时间更复杂的数学"变成了有意义的东西.+1易于理解的版本.除了使用编译器生成的常量之外,我从来不需要做任何其他事情,因此我只浏览了解释数学的其他文章. (7认同)
  • @PeterCordes模块乘法逆是用于精确划分,afaik它们对一般除法没有用 (4认同)
  • @PeterCordes乘以定点倒数?我不知道每个人都称它为什么,但我可能会称之为,它具有相当的描述性 (4认同)
  • 它是模2 ^ n,就像寄存器中的所有整数数学一样.https://en.wikipedia.org/wiki/Modular_multiplicative_inverse#Applications (3认同)
  • 我根本没有看到与代码中的模运算有关的任何事情.Dunno,其他一些评论者从那里得到了. (2认同)
  • 在某些除数的情况下,例如j = i / 7,需要一个65位的乘法器。处理这种情况的代码要复杂一些。 (2认同)

rcg*_*ldr 12

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

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

在文章中,uword有N位,udword有2N位,n = numerator = dividend,d = denominator = divisor,l最初设置为ceil(log2(d)),shpre是pre-shift(在乘法之前使用) )= 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)(当ℓ== 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
Run Code Online (Sandbox Code Playgroud)

您可以使用GCC进行测试.你已经看到了如何处理j = i/5.看看如何处理j = i/7(应该是N + 1位乘法器的情况).


归档时间:

查看次数:

14983 次

最近记录:

6 年 前