我一直在读关于div和mul装配操作的文章,我决定用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?它是如何产生这个幻数的?为什么所有的东西都能工作?
- GCC按常量优化划分,尝试按2、3、4、5、6、7、8进行划分,您很可能会看到每种情况下的代码都非常不同。
- 尝试从用户处读取值,以查看一些实际的除法指令。
- 嗯,奇怪的是,我关闭了-O0的优化,它仍然优化?
- 在这里寻找更详细的解释。
- 注:幻数-3689348814741910323转换为CCCCCCCCCCCCCCCD作为uint64_t或约(2^64)×4/5。
- @丘比特:编译器不会仅仅因为优化被禁用而产生低效的代码。例如,将执行一个不涉及代码重新排序或变量消除的简单"优化"。基本上,单个源语句将独立地转换为该操作最有效的代码。编译器优化考虑了周围的代码,而不仅仅是单个语句。
- 阅读这篇很棒的文章:分工
- 迈克尔·沃兹和杰斯特已经给你链接了两篇很棒的文章。这是另一篇很棒的文章。太棒了!
- 有些编译器实际上会反常地生成效率低下的代码,因为优化被禁用。特别是,他们这样做是为了使调试变得容易,比如在单独的代码行上设置断点的能力。事实上,GCC非常不寻常,因为它没有真正的"无优化"模式,因为它的许多优化都是结构性打开的。这是一个例子,您可以通过GCC看到这一点。另一方面,clang和msvc将在-O0上发出div指令。(cc@克利福德)
- 这里的关键是,这只是实现单个C操作符的几种可能方法之一,这些方法的输入与C抽象机相同。它对调试根本没有影响,因为它没有跨多个语句或类似的任何语句进行优化。有些架构没有硬件划分指令,所以我想知道gcc -O0是否启用了这种技巧(对于所有架构),以便能够在这些目标上正常地编译常量划分。
- btw,使用-Os(针对小代码优化)将使gcc使用div而不是模块化乘法逆:godbolt.org/g/fpb74p。clang仍然使用乘法逆,即使需要很多指令。不过,对于像13这样的小常量,代码大小几乎没有增加。(请参阅gcc和clang中的/13和/12345,作为接受args并返回值的函数,因此它们不会像您的main()示例那样优化除法。)
- 这里我真正不明白的是编译器为什么要生成代码来做(优化)除法。这些值是常量,所以可以在编译期间计算结果,不是吗?要查看实际的通用除法指令,我建议让程序读取i和j的值。
- @在常量上操作jamesqf是这样一种事情,如果您使用-O0编译,gcc会出于某种原因假定您需要它。不过,要做到这一点,几乎没有什么方法。
- GCC应提供-O-1和-O-2选项,以故意生成低效代码;—)
- @我不知道这个精确的问题是否在其他地方重复,但它是作为stackoverflow.com/question s/40354978答案的一部分隐式回答的。它也直接在stackoverflow.com/a/12909900/616460上回答(我认为,这个答案比这里的答案更有趣,因为它描述了如何找到神奇的数字)。在谷歌上也很容易找到(最后一个答案是"gcc整数除法汇编程序"的第一个搜索结果)。
- 另请参见stackoverflow.com/questions/3850665/&hellip;
- @当然,我得到了无优化的东西,但是为什么它要做以移位和C替换DIV的优化呢?几乎没有方法,事实上:—)
- @但是,这个特定的优化不会影响我所知道的任何调试器。当然,您会期望"没有优化"来避免移动代码行或交错它们,但这并不能做到这一点。
- 然后你意识到人类用查找表来优化他们的划分…
- 用5除一个数,不用除法运算符,用3除一个整数的最快方法是什么?C++快速分割/mod 10 ^ x,如何让GCC编译器将变量除法转换为MUL(如果更快)
- 对于I/7,代码有点复杂:mov r8,i_mov rax,2635249153387078803_mul r8_sub r8,rdx_shr r8,1_add rdx,r8_shr rdx,2_mov rax,rdx。
- "我在这里真正不明白的是,编译器为什么要生成代码来做(优化的)划分。"O0的aiui gcc将在语句内优化,而不是在语句之间优化。如果我们把部门改为9/5,它就会得到优化。如果我们改变除法,使两个输入都是变量,它会生成一个除法指令。
整数除法是在现代处理器上可以执行的最慢的算术运算之一,它的延迟可达几十个周期,吞吐量很差。(有关x86,请参阅Agner Fog的说明表和Microarch指南)。
如果你提前知道除数,你可以用一组其他的操作(乘法、加法和移位)代替它来避免除数。即使需要几个操作,它仍然比整数除法本身快得多。
用这种方式实现c /运算符,而不是使用涉及div的多指令序列,这只是gcc按常量进行除法的默认方法。它不需要跨操作进行优化,也不需要更改任何内容,即使用于调试。(对小代码使用-Os确实使gcc使用div。)使用乘法逆函数而不是除法,就像使用lea而不是mul和add一样。
因此,只有在编译时除数未知的情况下,才会在输出中看到div或idiv。
有关编译器如何生成这些序列的信息,以及让您自己生成它们的代码(几乎可以肯定是不必要的,除非您使用的是死脑筋的编译器),请参见libdivide。
- 实际上不是。如果我记得正确的话,现代英特尔处理器上最慢的算术运算是fbstp和fbld,如果我记得正确的话。
- @因此引信是"其中之一"。在任何情况下,我都不确定这对OP的问题有什么影响…你只是学究吗?
- Huch?当我第一次读到你的答案时,我确信它是"整数除法是最慢的运算…"
- 我不确定在速度比较中把fp和integer操作放在一起是否公平,@fuz。也许sneftel应该说除法是在现代处理器上可以执行的最慢的整数运算?此外,评论中还提供了一些链接来进一步解释这种"魔力"。你认为在你的答案中收集它们是否合适?1, 2, 3
- 因为操作顺序在功能上是相同的…这始终是一项要求,即使在-O3中也是如此。编译器必须生成对所有可能的输入值给出正确结果的代码。这只会改变使用-ffast-math的浮点,而afaik没有"危险"的整数优化。(启用优化后,编译器可能能够证明一些关于可能的值范围的内容,例如,这些值允许它使用只适用于非负有符号整数的内容。)
- 真正的答案是GCC-O0仍然通过内部表示来转换代码,作为将C转换为机器代码的一部分。模块乘法逆在默认情况下甚至在-O0时也能启用(但在-Os时不能启用)。其他编译器(如clang)将在-O0上对非2次幂常量使用DIV。相关:我想我在我的collatz推测中包括了一段关于这个的手写ASM答案。
- @彼得卡德我关于"功能相同"的观点是对分组指令本身进行了修改。没有重新排序、提升或任何相关的操作;只是一组与DIV相同的指令。
- 对,是的,我在回答OP关于这个问题的评论时就知道了。这只是实现C /运算符的另一种方法,不需要在C语句之间进行优化,也不需要做任何会影响调试的事情。但是请注意,C标准和GCC文档都不能保证有一种模式可以以任何简单的方式将C语句映射到目标ASM。
- 我做了一个编辑,删除了"功能相同"的措辞,我认为不同的人可能会解释不同的方式。这是你的答案,所以请回顾我的编辑。(我把第一段改为"整数运算",因为中断和主内存的往返更慢)。
- 另外,您确定使用IDIV进行有符号常量除法吗?我不认为gcc -O2或-O3会这样做,除非有不存在反数的除数。GCC6.2 -O0使用IDIV,即使它需要大量指令:godbolt.org/g/gmwfg8。我想你应该说你得到了非常数除数的div和idiv。
- @彼得命令不,那是我的错。我模糊地记得有一些数字给乘法逆法带来了问题,但我错了。
- @PeterOrders和Yeah,我认为GCC(以及许多其他编译器)忘记了为"禁用优化时应用什么样的优化"提出一个很好的理由。在花了一天的大部分时间来追踪一个模糊的代码生成错误之后,我现在对此有点恼火。
- @sneftel:这可能是因为主动向编译器开发人员抱怨代码运行速度比预期快的应用程序开发人员的数量相对较少。
- @当您不明确地告诉sneftel msvc优化代码时,它会更加谨慎地进行一些微优化;对于至少某些版本,这似乎是其中之一。
除以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 = 1,x=4/5。然后用十六进制表示为CCCCCCCCCCCCD,用于四舍五入(超出最后一个数字的二进制数字为1)。
- @用户2357112可以发布自己的答案,但我不同意。你可以把乘法看成是64.0位乘以0.64位的乘法,给出128位定点应答,其中最低位的64位被丢弃,然后除以4(如我在第一段中指出的)。你很可能会想出一个替代的模块化算术答案,它同样能很好地解释位的移动,但我相信这是一个很好的解释。
- 该值实际上是"ccccccccccccccccc d",最后一个d很重要,它确保当结果被截断时,精确的除法得到正确的答案。
- 不要介意。我没有看到他们在取128位乘法结果的64位高位;在大多数语言中,这不是你能做的,所以我最初没有意识到这是在发生。如果明确地提到128位结果的上64位等于乘以定点数并四舍五入,这个答案会得到很大的改进。(另外,最好解释一下为什么它必须是4/5而不是1/5,以及为什么我们必须向上取整而不是向下取整4/5。)
- @Plugwash感谢Fixed-我打字很懒惰,但现在已经完成了四舍五入。
- @Plugwash我仍然不完全确定如何确保正确的截断。你手头正好有推荐信吗?
- 如果乘以的数字略小于4/5,则在截断任何可被5整除的数字的最终答案后,都会得到错误的结果。
- 如果误差略大于4/5,那么情况会变得更糟,你必须计算出最坏情况下的误差,然后检查误差是否足够大,从而导致不正确的舍入。
- @Plugwash:即在被乘数中,误差小于2^-63,所以即使乘以2^64,如果右移2,它也会丢失?
- afaict你必须计算出一个误差需要有多大,才能在一个舍入边界上向上抛出5的除法,然后将其与你计算中的最坏情况下的误差进行比较。假设GCC开发人员已经这样做了,并得出结论,它将始终给出正确的结果。
- 实际上,如果正确地进行四舍五入,您可能只需要检查5个可能的最高输入值,其他所有值也应该检查。
一般来说,乘法比除法快得多。因此,如果我们可以不用乘以倒数,我们可以显著加快除以常数的速度。
一个折痕是我们不能精确地表示倒数(除非除法是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位相乘,然后添加原始数(这可能会溢出到进位中),然后执行一个旋转进位来实现这一点。
- 这个答案把模块化的乘法逆从"看起来比我想花时间做的更复杂的数学"变成了有意义的东西。+1为便于理解的版本。除了使用编译器生成的常量之外,我从来就不需要做任何事情,所以我只略读了其他解释数学的文章。
- 我认为代码中的模块化算术与此无关。不知道其他评论者从哪里得到的。
- 它是模2^n,就像寄存器中所有的整数数学一样。en.wikipedia.org/wiki/&hellip;
- 由于乘法的输出是输入大小的两倍,因此序列中的任何操作都不能换行。所以模行为是不相关的。
- 如果他采用多学科中较低的64位而不是较高的64位,我们会讨论模块化算术,但他没有,所以我们没有。
- 也许我用错了术语,但我认为这是该技术的正确名称。进一步的谷歌搜索显示,对该技术的其他讨论通常称之为"乘法逆"。然而,Granlund&Montgomery关于该技术并在GCC中实现的论文确实指出"可以找到dodd模2^n的乘法逆dinv"。我认为"模块化"之所以出现是因为它只适用于从0到2^n-1的输入,而不适用于任何数学整数。我同意使用它时没有模。
- @PeterOrders模乘反相用于精确除法,afaik它们对一般除法不有用。
- @哈罗德:那么,用一个"神奇"的数字乘以结果的高半来做固定宽度的整数除法,有没有一个特定的名称?它只是"乘法逆"吗?我希望有更具体的东西,这不适用于类似的浮点优化。
- @彼得命令乘定点倒数?我不知道每个人都叫它什么,但我可能会叫它,它很有描述性。
- 对于一些除数,如j=i/7,需要一个65位的乘数。处理这种情况的代码要复杂一些。
这里是一个算法文档的链接,它生成我在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
; ... |
- 本文描述了在GCC中实现它,所以我认为仍然使用相同的算法是一个安全的假设。
- 1994年的那篇文章描述了它在GCC中的实现,所以GCC有时间更新它的算法。以防其他人没有时间查看该URL中94的含义。