我一直在阅读有关div
和mul
装配操作的内容,我决定通过在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
这里发生了什么?为什么海湾合作委员会根本不使用div?它如何产生这个神奇的数字以及为什么一切都有效?
答案 0 :(得分:150)
整数除法是您可以在现代处理器上执行的最慢的算术运算之一,延迟可达数十个周期且吞吐量不佳。 (对于x86,请参阅Agner Fog's instruction tables and microarch guide)。
如果您提前知道除数,则可以通过将其替换为具有相同效果的一组其他运算(乘法,加法和移位)来避免除法。即使需要进行多次操作,它通常仍然比整数除法本身快得多。
以这种方式实现C /
运算符而不是涉及div
的多指令序列只是GCC默认的常量除法。它不需要跨操作进行优化,即使是调试也不会改变任何内容。 (使用-Os
获取较小的代码大小确实可以让GCC使用div
。)使用乘法逆而不是除法就像使用lea
而不是mul
和{{ 1}}
因此,如果在编译时未知除数,则只会在输出中看到add
或div
。
有关编译器如何生成这些序列的信息,以及允许您自己生成它们的代码(几乎肯定是不必要的,除非您使用脑死亡编译器),请参阅libdivide。
答案 1 :(得分:103)
除以5与乘以1/5相同,再乘以4/5并向右移2位相同。有关的值是十六进制的CCCCCCCCCCCCD
,如果放在十六进制点之后,则是4/5的二进制表示(即四分之四的二进制重复0.110011001100
- 见下面的原因)。我想你可以从这里拿走它!您可能想要查看fixed point arithmetic(但请注意它在最后四舍五入为整数。
至于为什么,乘法比除法快,当除数固定时,这是一条更快的路线。
请参阅Reciprocal Multiplication, a tutorial以获取有关其工作原理的详细说明,并根据定点进行解释。它显示了查找倒数的算法如何工作,以及如何处理有符号的除法和模数。
让我们考虑一下为什么0.CCCCCCCC...
(十六进制)或0.110011001100...
二进制为4/5。将二进制表示除以4(右移2位),我们将得到0.001100110011...
,通过简单的检查可以添加原始的0.111111111111...
,显然等于1,同样的方式十进制中的0.9999999...
等于一。因此,我们知道x + x/4 = 1
,5x/4 = 1
,x=4/5
。然后将其表示为十六进制的CCCCCCCCCCCCD
用于舍入(因为超出最后一个的二进制数字将是1
)。
答案 2 :(得分:53)
通常,乘法比除法快得多。因此,如果我们可以通过乘以倒数来逃避,那么我们可以通过常数
显着加快除法皱纹是我们不能准确地表示倒数(除非除法是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 &lt; 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位来实现,然后加上原始数字(这可能会溢出到进位),然后通过进位进行旋转。
答案 3 :(得分:10)
这是一个算法文档的链接,该算法生成我在Visual Studio中看到的值和代码(在大多数情况下),并且我假设仍然在GCC中用于将变量整数除以常数整数。
http://gmplib.org/~tege/divcnst-pldi94.pdf
在文章中,uword有N位,udword有2N位,n = numerator = dividend,d = denominator = divisor,ll最初设置为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。只有当分母(除数)> 1时,mlow和mhigh的分子(被除数)才能大于udword。 2 ^(N-1)(当ℓ== N =&gt; mlow = 2 ^(2N))时,在这种情况下,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的五个指令序列(为了模拟N + 1位乘法器,如公式4.5和pdf文件的图4.2所示)。示例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
; ...
答案 4 :(得分:0)
我将从一个稍有不同的角度回答:因为允许这样做。
C和C ++是针对抽象机定义的。编译器遵循as-if规则将程序从抽象机转换为具体机。