计算机组成原理 —— 四则运算
在人工计算加减乘除时,我们会更偏向于计算加法与乘法,最起码是我这样的,因为直观上,我认为减法比加法复杂,除法比乘法复杂。
在《密码工程》课程中实现\(RSA\)时,蒙哥马利优化的核心就在于利用数学,将%运算利用乘法与<</>>操作来相同的效果:取余操作需要使用除法操作,而除法会占用很多的CPU时钟周期,但是如若除数是\(2^i\),我们可以用更快的移位操作来完成。
在这篇文章中,我想从计算机组成原理的角度剖析一下为什么除法会更慢以及计算机的%操作底层的汇编指令是怎么样的。
加减法
计算机只会做一件事:布尔逻辑。要实现加减法,需要从小到大拼接逻辑门:
\(Full\ Adder\)
- 全加器(\(Full\ Adder\)):这是最基本的计算单元。它接收三个输入:位\(A\)、位\(B\)和来自低位的进位\(C_i\),输出当前位的和\(S\)以及向高位的进位\(C_{i+1}\)。

\(RCA\rightarrow CLA\)
-
从串行到并行(\(CLA\)):如果把多个\(Full\ Adder\)像链条一样首尾相连,就组成了行波进位加法器(\(RCA\))。但它存在一个缺点就是高位的计算必须等待低位的进位符号,延迟为\(O(N)\)。为了提速,硬件架构师设计了超前进位加法器(\(CLA\)),利用逻辑代数展开式,提前把所有进位(\(C_i\))并行计算出来,用增加电路面积的代价换取\(O(1)\)的速度。
全加器的电路逻辑如下:\[\begin{cases}s=a\oplus b\oplus c_{i}\\c_{i+1}=ab+ac_i+bc_i=ab+c_i(a\oplus b)\end{cases} \]观察上式\(s\)和\(c\),将共有部分分别定义:
\[\begin{cases}P=a\oplus b\\G=ab\end{cases} \]对于\(N\)比特\(CLA\)加法器,进位与和的逻辑计算可写成:
\[\begin{cases}s_i=P_i\oplus c_{i-1},i=1,\cdots,N\\c_i=G_i+c_{i-1}P_i,i=1,\cdots,N\\c_0=c_{in}\\c_{out}=c_N\end{cases} \]其中:
\[\begin{cases}P_i=a_i\oplus b_i,i=0,1,\cdots,N-1\\G_i=a_ib_i,i=0,1,\cdots,N-1\end{cases} \]其门电路图如下,本质为一个半加器:

以\(4\)比特\(CLA\)加法器为例,其进位链与和公式分别计算如下:
\[\begin{cases}c_1=G_0+c_0P_0\\c_2=G_1+c_1P_1=G_1+(G_0+c_0P_0)P_1=G_1+G_0P_1+c_0P_1P_0\\c_3=G_2+c_2P_2=G_2+(G_1+G_0P_1+c_0P_1P_0)P_2=G_2+G_1P_2+G_0P_2P_1+c_0P_2P_1P_0\\c_4=G_3+c_3P_3=G_3+(G_2+G_1P_2+G_0P_2P_1+c_0P_2P_1P_0)P_3=G_3+G_2P_3+G_1P_3P_2+G_0P_3P_2P_1+c_oP_3P_2P_1P_0\end{cases} \]\[\begin{cases}s_0=P_o\oplus c_0\\s_1=P_1\oplus c_1\\s_2=P_2\oplus c_2\\s_3=P_3\oplus c_3\end{cases} \]\(4\)比特超前进位加法器的结构图大致如下:

-
由于物理实现的方式不同,\(CLA\)又可以分为递归超前进位加法器(\(RCLA\))、分类超前进位加法器(\(BCLA\))、混合型超前进位加法器。
-
对于位宽更大的\(CLA\),如\(16\)、\(32\)、\(64\)比特\(CLA\)等可以并行生成所有的\(PG\)和进位\(c\),但是造成电路极大地扇入和扇出;另外可以根据\(4\)比特\(CLA\)级联而成,如\(16\)比特\(RCLA\)如下图:

-
为什么\(CLA\)相比于\(RCA\)在速度上更快?
从输入所有的\(a_i\)、\(b_i\)和\(c_0\),\(CLA\)的输出进位\(c_4\)只需要\(3\)级门电路延迟。其中\(P_i\)与\(G_i\)可以并行生成,而\(c_i\)的生成仅需要\(P\)与\(G\)的参与,因此\(c_1,c_2,c_3,c_4\)可以并行同时生成,再加上最终的\(s_i=P_i\oplus c_i\),一共\(4\)级门电路延迟,相比于\(4\)比特的\(RCA\)的关键路径的\(9\)级门电路而言,\(CLA\)完成了速度上的提升。同样是\(32\)比特加法器,理想的\(CLA\)(全部展开所有的进位逻辑)关键路径延迟理论上还是只需要\(4\)级门电路,而\(RCA\)的关键路径延迟为\(65\)个门电路延迟,这回应了我们前面提到的\(O(1)\)与\(O(N)\)的延迟。如果我们采用的是上述级联的\(RCLA\),那么关键路径延迟为\(3+7×2+1=18\)级门电路延迟,也达到了缩短关键路径长度的目的。 -
但是我们也可以很清晰地看出电路面积增大了,将\(PG\ generator\)的电路进行整合之后的\(4-bit\ CLA\)如下图所示:
![[LCA逻辑图.png|675]] -
减法怎么做?在计算机的算术逻辑单元(\(ALU\))中,不存在专门的减法器。在模\(2^n\)的系统下,计算\(A-B\)等价于计算\(A+(-B)=A+(\sim B)+1\),其靠一个控制信号(通常记为
SUB), B的每一位在进入加法器之前,都先经过一个异或门(\(XOR\))。异或门的一个输入端接入接\(B_i\),另一个输入端接控制信号SUB;控制信号SUB直接连到最低位全加器的进位\(c_0\)。 -
需要注意的是,有符号整数以补码的形式存储在计算机中。但我们所说的存储与计算并不是指的同一件事,我们也可以利用同一套加法器完成减去一个负数的操作。
补码(
有点记不清补码的意义了,这里补充回顾一下)计算机只有加法器,没有减法器。所以\(A-B\)要变成\(A+(-B)\)来计算。如果我们采用原码来表示负数,即
+1=0000 0001、-1=1000 0001,那么直接按照原码相加的话会有0000 0001 + 1000 0001 = 1000 0010 = -2这样的错误现象。与此同时,+0 = 0000 0000和-0 = 1000 0000都是0的表示,这不仅浪费了一个编码,而且让计算机判断“是否为0”变得复杂。为了解决“负数加法”和"
0有两个编码的问题,设计了补码。补码的核心思想就是模运算,假设我们有3位二进制(范围0~7),这就相当于是一个模8的环,现在数值为2,如果想让数值变为5,有两种操作:
+3(2+3=5)-5(2-5=-3 --> mod8 --> 5)
在uint8中,一个负数的补码=负数+\(2^8\),举个例子,求1的补码,-1+256=255(1111 1111),所以1111 1111就是-1的补码。此时我们就有了1+(-1)=0000 0001 + 1111 1111 = 1 0000 0000,由于溢出,这个结果就是0000 0000 = 0。而我们在第一次学习计算机导论等内容的时候都会遇到一种求补码的方式,整数的补码就是原码,负数的补码是原码取反加
1,例如int8下的-5就是这样子存储的,先求+5的原码0000 0101,取反得到1111 1010,最后加1得到1111 1011,这就是-5的补码。
乘法
基础硬件乘法器
普通的二进制乘法就像列竖式计算,本质是“移位并累加”。但在现代体系架构中,为了追求极速,乘法器深度融合了高级算法。
- 先来看一下最基本的乘法电路实现。对于现实而言,我们可以通过下面的过程完成二进制下的\(13×11\):
1 1 0 1 (被乘数 M: 13)
x 1 0 1 1 (乘数 Q: 11)
---------
1 1 0 1 <- 乘数第0位是1,照抄 M
1 1 0 1 0 <- 乘数第1位是1,照抄 M 并左移1位
0 0 0 0 0 0 <- 乘数第2位是0,全是 0(左移2位)
1 1 0 1 0 0 0 <- 乘数第3位是1,照抄 M 并左移3位
-------------
1 0 0 0 1 1 1 1 (结果: 128+15 = 143)
-
我们习惯于把乘数不断向左移位,最后一次性把所有行都加起来。但是如果计算机也这么计算,会遇到两个物理问题:
- 加法器太宽:\(4\)位乘\(4\)位,结果是\(8\)位。如果像人类一样最后一起加,需要一个机器庞大且复杂的\(8\)位甚至更宽的多操作数加法器。
- 寄存器浪费:需要很多寄存器来临时保存每一行左移后的结果。
-
为了解决上面的两个物理问题,硬件工程师的思路就是被乘数不左移,而是把“累加结果”向右移。我们需要\(3\)个核心部件:
- \(M\)寄存器(\(Multiplicand\)):存放被乘数
1101,它的位置永远不动; - \(Q\)寄存器(\(Multiplier\)):存放乘数
1011。随着计算,它会向右移,最低位用来控制要不要加。 - \(A\)寄存器(\(Accumulator\)):累加器,初始为
0000。用来和\(Q\)拼在一起存放最终的\(8\)位结果。
- \(M\)寄存器(\(Multiplicand\)):存放被乘数
-
接下来按照硬件的时钟周期,用“判断→累加→联合右移”的逻辑,走完\(13×11\)这个\(4\)bit的乘法过程:
初始状态:\(M=\)1101;\(C=\)0;\(A=\)0000;\(Q=\)1011- 周期\(1\):处理\(Q\)的低位
观察\(Q\)的最低位101[1]
① 最低位为\(1\):将\(M\)加到\(A\)中。\(A=A+M\rightarrow\)0000 + 1101 = 1101。此时\([C,A,Q]=\)[0, 1101, 1011]
② 联合右移:把\([C,A,Q]\)整体向右移动\(1\)位。最右边的\(1\)被丢弃,\(C\)补\(0\)。移动后的结果:\(A=\)0110;\(Q=\)1101 - 周期\(2\):处理新的最低位
观察\(Q\)的最低位110[1]
① 最低位为\(1\):将\(M\)加到\(A\)中。\(A=A+M\rightarrow\)0110 + 1101 = 10011。产生进位\(1\)。此时\([C,A,Q]=\)[1, 0011, 1110]
② 联合右移:整体右移,\(C\)的\(1\)移入\(A\)的最高位,\(A\)的最低位\(1\)移入\(Q\)的最高位。移动后的结果:\(A=\)1001;\(Q=\)1110 - 周期\(3\):处理新的最低位
观察\(Q\)的最低位111[0]
① 最低位为\(0\):不执行加法。此时\([C,A,Q]=\)[0, 1001, 1110]
② 联合右移:整体右移。移动后结果:\(A=\)0100;\(Q=\)1111 - 周期\(4\):处理新的最低位
观察\(Q\)的最低位111[1]
① 最低位为\(1\):将\(M\)加到\(A\)中。\(A=A+M\rightarrow\)0100 + 1101 = 10001。产生进位\(1\)。此时\([C,A,Q]=\)[1, 0001, 1111]
② 联合右移:移动后结果:\(A=\)1000;\(Q=\)1111
循环\(4\)次结束,将\(A\)和\(Q\)拼接后作为最终乘法的结果\(A || Q=\)1000 1111。

通过将\(A\)与\(Q\)整体右移,硬件完成了空间的复用:\(A\)寄存器腾出的空间刚好用来存储高位结果,而\(Q\)寄存器消耗掉的控制位刚好为低位结果腾出了空间。
- 周期\(1\):处理\(Q\)的低位
-
上述我们演示了两个正数的乘法如何通过计算机硬件逻辑实现,但是这个机制在遇到负数时就会崩溃。在现代计算机体系中,负数是通过补码表示的,对于一个\(4\)bit系统:无符号数
1101代表\(13\),补码表示的1101代表\(-3\),如果将补码强行塞入我们的“基础移位乘法器”里,会遭遇两种截然不同的崩溃:- 当乘数\(Q\)为负数时,权重会出现问题。
例如我们要算\(5×(-3)\)。被乘数\(M=5\),0101;乘数\(Q=-3\),1101
基础硬件乘法器的逻辑是:只要\(Q\)的最低位是\(1\),就加上\(M\),所以在处理\(Q\)(1101)时,硬件会一次在第\(0\)位、第\(2\)位、第\(3\)位把\(M\)加到累加器\(A\)里。
崩溃的原因在于,在无符号数学中,1101的每一位权重都是正的:\(13=(+2^3)+(+2^2)+0+(+2^0)\),但在补码的数学体系中,最高位(符号位)的权重是负的\(-3=(-2^3)+(+2^2)+0+(+2^0)\)。基础硬件看到\(Q\)的最高位是\(1\)的时候,依旧把它当作\(+2^3\)执行一次加法,最高位本该执行“减去\(M\)”,硬件却执行了“加上\(M\)”。
这个时候的结果被错算成了0100 0001,此时的结果表示是\(65\)。基础硬件乘法器没办法区分乘数\(Q\)是负数,做的还是两个正数的乘法。 - 当被乘数\(M\)为负数时,符号扩展会失效:
例如我们要计算\((-3)×3\)。被乘数\(M=-3\),1101;乘数\(Q=3\),0011
乘数是正的,权重没问题,按道理来讲该加的时候加,该移的时候移,此时的崩溃原因在于逻辑右移操作,逻辑右移时,最左边空出来的位置永远补\(0\)。
① 周期\(1\):\(Q\)的最低位是\(1\),把\(M\)(1101)加到\(A\)(0000)中。此时\(A=\)1101,也就是\(-3\)。
② 执行右移:\([A,Q]=\)[1101, 0011],移动之后的结果:\(A=\)0110,十进制下为\(+6\)。
这就触及了计算机对于有符号的移位操作时,必须使用算术右移操作,即右移时左侧必须补充原来的符号位,以此来保持负数右移后仍然是负数。
- 当乘数\(Q\)为负数时,权重会出现问题。
-
至此,基础硬件乘法器是三个主要的寄存器通过“判断→累加→移位”来完成的,对于两个正数的操作是显而易见正确的。但如果想要支持补码乘法,需要增加补丁逻辑:
- 为解决\(M\)的符号,总是采用算数右移
- 为解决\(Q\)的符号,如果是负数,在最后一步(最高位)时,必须把\(ALU\)的操作从
Add强制切换为Sub。
\(Booth\ Algorithm\)
为了避免上述复杂繁琐的补丁逻辑,\(Andrew\ Booth\)在\(1951\)年提出的\(Booth\)算法不仅完美修复了补码乘法的“硬件崩溃”,还带来了性能的提升:跳过连续的\(1\),大幅减少加法次数。
- 在上面我们提到,当乘数\(Q\)是负数的补码时,硬件算错的根本原因是最高位的权重是负的,而基础乘法器把它当成正数处理。
- 对于一个\(n\)位的补码乘数\(Q=q_{n-1}q_{n-2}\cdots q_1q_0\),它的真实十进制值是:\[V(Q)=-q_{n-1}2^{n-1}+\sum_{i=0}^{n-2}q_i2^i \]\(Booth\)的天才之处在于进行一个代数变换,我们知道,一个数可以拆成两个相邻幂的差:\(2^i=2^{i+1}-2^i\),我们把\(Q\)的每一项按照这个规律展开:\[\begin{cases}q_02^0=q_02^1-q_02^0\\q_12^1=q_12^2-q_12^1\\\cdots\\q_{n-2}2^{n-2}=q_{n-2}2^{n-1}-q_{n-2}2^{n-2}\end{cases} \]把这些展开式代入原来的\(V(Q)\),然后按照\(2^i\)合并同类项,并且为了格式统一,在最低位虚构一个\(q_{-1}=0\):\[V(Q)=(q_{n-2}-q_{n-1})2^{n-1}+\cdots+(q_0-q_1)2^1+(q_{-1}-q_0)2^0=\sum_{i=0}^{n-1}(q_{i-1}-q_i)2^i \]
- 既然\(V(Q)=\sum_{i=0}^{n-1}(q_{i-1}-q_i)2^i\),那么在硬件上,每次只需要观察相邻的两位\((q_i,q_{i-1})\)。因为\(q_i\)只能是\(0\)或\(1\),所以\((q_{i-1}-q_i)\)的结果只有\(3\)种可能:\(0\)、\(+1\)、\(-1\)。
这就导出了\(Booth\)乘法器的控制规则表:为了存放虚拟的\(q_{-1}\),硬件会在\(Q\)寄存器的右侧增加一个触发器(通常称为\(Q_{-1}\)寄存器),初始值为\(0\)。并且,\(Booth\)算法强制要求所有移位操作必须是算术右移,以此来完美保留负数的符号位。
| 当前位\(q_i\) | 附加位\(q_{i-1}\) | \(q_{i-1}-q_i\) | 硬件操作 |
|---|---|---|---|
| 0 | 0 | 0 | 仅右移 |
| 0 | 1 | +1 | 加\(M\),然后右移 |
| 1 | 0 | -1 | 减\(M\),然后右移 |
| 1 | 1 | 0 | 仅右移 |
- 验证一下\(Booth\)算法如何防止补码乘法引起的崩溃,回到\(5×(-3)\)的案例:
\(M=5\),0101;\(Q=-3\),1101
初始\(A=\)0000,\(Q_{-1}=\)0- \(period1\):观察\(q_0,q_{-1}=\)
1, 0。减去\(M\)。(等价于\(-1 \cdot 2^0 = -1\))。算术右移。 - \(period2\):观察\(q_1,q_0=\)
0, 1。加上\(M\)。(等价于\(+1\cdot 2^1=+2\))。算术右移。 - \(period3\):观察\(q_2,q_1=\)
1, 0。减去\(M\),(等价于\(-1\cdot 2^2=-4\))。算术右移。 - \(period4\):观察\(q_3,q_2=\)
1, 1。不操作。(等价于\(0\cdot 2^3=0\))。算术右移。 - 最终的结果为\(-1 + 2 - 4 + 0 = -3\),即最终的结果为\((-1 + 2 - 4)×5=-15\)
在这个过程中,\(Booth\)算法不需要判断这个数是不是负数,只需要机械地比对相邻两位。由于负数补码的最高位为\(1\),它在最后一次跳变检验时,天然地把最高位地负权重加到了结果里。
- \(period1\):观察\(q_0,q_{-1}=\)
- 限制的算术右移兼容了\(M\)是负数的情况,比对\(Q\)的相邻两位兼容了\(Q\)是负数的情况,现在来看我们不过是完成了补丁逻辑的一个平替。对于普通乘法器,如果我也限制算术右移,并且针对于\(Q\)是否是补码而对最后一步进行判定选择,达到的效果是一样的。那为什么现代计算机体系要认可采用\(Booth\)算法?答案就是\(Booth\)算法不仅能完成补丁逻辑的任务,而且还能带来性能上的优化:化连加为一减一加。
假设\(Q=124\),01111100,\(Booth\)算法在处理的过程中处理的位串为[0] [1] [1] [1] [1] [1] [0] [0] | [0],最后补充的为\(q_{-1}=0\)。按照\(Booth\)规则从右向左扫描\(8\)次,下列是\(-3\times 124\)的执行流程,完成初始化之后\(M=\)[11111101],\([A,Q,q_{-1}]=\)[00000000 01111100 0]:
| \((p_i,p_{i-1})\) | \(p_{i-1}-p_i\) | 操作 | \(M\) | \([A,Q,q_{-1}]\) |
|---|---|---|---|---|
| \((0,0)\) | \(0\) | 算术右移\(1\)位 | 11111101 |
[00000000 00111110 0] |
| \((0,0)\) | \(0\) | 算数右移\(1\)位 | 11111101 |
[00000000 00011111 0] |
| \((1,0)\) | \(-1\) | \(-M\),算数右移\(1\)位 | 11111101 |
[00000001 10001111 1] |
| \((1,1)\) | \(0\) | 算数右移\(1\)位 | 11111101 |
[00000000 11000111 1] |
| \((1,1)\) | \(0\) | 算数右移\(1\)位 | 11111101 |
[00000000 01100011 1] |
| \((1,1)\) | \(0\) | 算数右移\(1\)位 | 11111101 |
[00000000 00110001 1] |
| \((1,1)\) | \(0\) | 算术右移\(1\)位 | 11111101 |
[00000000 00011000 1] |
| \((0,1)\) | \(1\) | \(+M\),算数右移\(1\)位 | 11111101 |
[11111110 10001100 0] |
最终的结果计算结果为11111110 10001100 0,即-372的补码。原来我们需要在遇到\(1\)时执行\(5\)次完整的“读取→加法→写入”耗时操作。但在\(Booth\)算法中,连续的\(1\)内部(即\((1,1)\)对)产生的结果全是\(0\)。它只关心跳变点:在第\(2\)位发生\((1,0)\)跳变,执行减法(等价于\(-2^2\));在第\(7\)位发生\((1,0)\)跳变,执行加法(等价于\(+2^7\))。加法器只启动了\(2\)次,结果正确。
- 对于\(32\)位甚至\(64\)位的乘法器而言,这种“跨越连续\(1\)”的特性,能够极大降低电路翻转率,从而降低功耗并提高平均运算速度。
\(Radix-4\ Booth\ Algorithm\)
基础\(Booth\)算法(\(Radix-2\))虽然解决了补码乘法问题并优化了连续位串,但其存在一个缺点:最坏情况下的性能退化。当遇到像01010101这样的\(0\)、\(1\)交替出现的乘数时,基础\(Booth\)算法需要在每个周期都执行加减法,完全没有跳过任何操作,迭代次数依然是\(N\)次。
为此,硬件工程师引入了\(Radix-4\ Booth\)算法(也称\(Modified\ Booth\ Algorithm\)),它的优化核心是:无论乘数是什么,直接在物理结构上把部分积(加法次数)强制砍半。
- 要减少加法次数,最直接的思路就是每次处理乘数的\(2\)个比特,也就是把二进制(基\(2\))当作四进制(基\(4\))来看待。每次处理\(2\)位,原本\(32\)的乘法自然就只需要\(16\)次迭代。
- 但在处理当前这\(2\)位\((q_{2i+1},q_{2i})\)时,我们不能孤立地看它们。就像基础\(Booth\)算法需要借用\(q_{i-1}\)来判断跳变一样,\(Radix-4\)也需要借用前一组的最高位\(q_{2i-1}\),作为本组的重叠判断位。因此,扫描窗口从基础算法的\(2\)位,扩展成了\(3\)位滑动窗口,且窗口每次滑动\(2\)位,保留\(1\)位重叠。
- 对基础\(Booth\)公式的代数变形,推导出\(Radix-4\)的底层逻辑:\[V(Q)=\sum_{i=0}^nq_i2^{i}=\sum_{i=0}^n(q_{i-1}-q_i)2^i \]我们将这个求和公式中相邻的两项(偶数项\(2i\)和奇数项\(2i+1\))强行合并成一组,并提取公因式\(4^i\)(即\(2^{2i}\)):\[V(Q)=\sum_{i=0}^{{2\over n}-1}(q_{2i-1}-q_{2i})2^{2i}+(q_{2i}-q_{2i+1})2^{2i+1}=\sum_{i=0}^{{n\over2} - 1}(q_{2i-1}+q_{2i}-2q_{2i+1})\cdot 4^i \]括号中的系数项\((q_{2i-1}+q_{2i}-2q_{2i+1})\)。因为\(q\)只能是\(0\)或\(1\),这个多项式的结果一定在\(\{-2,-1,0,1,2\}\)。
- 根据上述这个集合,直接得到现代\(CPU\)乘法器内部的\(3-bit\)编码控制表:
| 奇数位\(q_{2i+1}\) | 偶数位\(q_{2i}\) | 重叠位\(q_{2i-1}\) | 编码计算\(q_{2i-1}+q_{2i}-2q_{2i+1}\) | 硬件执行的操作 |
|---|---|---|---|---|
| \(0\) | \(0\) | \(0\) | \(0\) | 算术右移\(2\)位 |
| \(0\) | \(0\) | \(1\) | \(+1\) | +\(M\),算术右移\(2\)位 |
| \(0\) | \(1\) | \(0\) | \(+1\) | +\(M\),算术右移\(2\)位 |
| \(0\) | \(1\) | \(1\) | \(+2\) | +\(2M\),算术右移\(2\)位 |
| \(1\) | \(0\) | \(0\) | \(-2\) | -\(2M\),算数右移\(2\)位 |
| \(1\) | \(0\) | \(1\) | \(-1\) | -\(M\),算术右移\(2\)位 |
| \(1\) | \(1\) | \(0\) | \(-1\) | -\(M\),算术右移\(2\)位 |
| \(1\) | \(1\) | \(1\) | \(0\) | 仅算术右移\(2\)位 |
-
一个新的问题是,加减\(M\)用普通的加法器就可以完成,但表里遇到了\(±2M\)的计算,这会不会引入额外的乘法开销?
在二进制中,乘以\(2\)等价于向左逻辑移\(1\)位:计算\(+2M\),硬件不需要任何计算,只需在把\(M\)送入加法器之前,通过连线在物理上将其左移\(1\)位(即错开一根数据线)。这不仅是“免费的”,而且耗时为零;计算\(-2M\),将\(M\)左移\(1\)位,然后将每一位取反(“非门”),并在加法器的进位输入端强制输入\(1\),这个过程可能听起来有点复杂了,其实就是\(M\)左移\(1\)位(算术移位)先得到\(2M\),然后再计算其相反数,按位取反再加\(1\)。有符号数的算数左移可能发生溢出,如果\(M\)在能表示的最小的负数范围内,而\(2M\)超出了这个范围,就会发生溢出。例如\(-64=\)
1100 0000,算术左移\(1\)位为1000 0000\(=-128\);\(-65=\)1011 1111,算术左移\(1\)位为0111 1110\(=126\)。检测溢出:溢出 = (原最高位) XOR (新最高位),如果结果为\(1\),说明符号翻转,结果无效。\(ALU\)算完后,将\([C,A,Q]\)整体算术右移\(2\)位,准备读取下一个\(3-bit\)窗口。
-
\(Radix-4\ Booth\)算法通过增加一点点组合逻辑(\(3-bit\)译码器和物理左移连线),稳定地将\(N\)位的乘法运算周期压缩到了\({N\over2}\)次。
\(Wallace\ Tree\ Multiplier\)
经过\(Booth\)算法,我们完成了负数(补码)乘法器的实现,并且在乘数\(Q\)出现连续的\(1\)时,可以起到优化的作用,为了保证\(Booth\)算法在最糟糕的情况下不会退化,\(Radix-4\ Booth\)算法强制对半压缩了迭代\(Q\)的次数。
但是,我们可以看到我们当前的乘法器工作流程就是,\({N\over2}\)次“判断→加法→移位”。假设我们是在进行\(32\)位乘法,但是把这\(16\)个数加完也需要数个时钟周期。为了在一个时钟周期(甚至半个周期)内把这\(16\)个部分积“相加”得到正确的最终结果,硬件工程师抛弃了传统的串行加法思维,发明了保留进位加法器(\(CSA\)) 和华莱士树(\(Wallace\ Tree\))。
- \(CSA\):\(3\rightarrow2\)
在传统的行波进位加法器中,全加器(\(Full\ Adder\))的逻辑是:计算\(A+B+C_{in}\),输出本位和\(Sum\),并产生一个进位\(C_{out}\)传给高一位。这个“传给高一位”是串行的,因为高位必须等待低位的进位符号,形成了前后依赖的串行。为此,我们前期进行逻辑分离,利用\(PG\ generator\)以扩大电路面积的代价换取了\(4-bit\)的\(CLA\)的并行,将\(O(N)\)的关键路径延迟降低为了\(O(1)\)的关键路径延迟,对于两个操作数的加法而言,这无疑是非常高效的了。
但在乘法计算的过程中,我们希望的是找到多个操作数的加法来减少加法指令的数量。能否找到一种一次性计算的方式?\(CSA\)在物理电路上就是一个全加器,但它拒绝传播进位:- 当我们有\(3\)个独立的二进制数(部分积)\(X\)、\(Y\)、\(Z\)需要相加时,\(CSA\)把它们同一个\(bit\)位置的三个输入放进全加器。它输出一个\(Sum\)向量(留在当前位);输出一个\(Carry\)向量(直接在布线上向左偏移\(1\)位)。在时间上,它只消耗了\(1\)个逻辑门的延迟,就把 \(3\)个数压缩成了\(2\)个数。这就是著名的\(3-to-2\)压缩器,在这个过程中,没有任何进位在位与位之间横向传播,所有位都是在并行计算。
- 现在,我们手里有\(Radix-4\ Booth\)生成的\(16\)个部分积,按原来的思路,我们采用\(16\)次\(O(1)\)的\(CLA\)进行相加。但现在我们可以用\(CSA\)将它们加在一起。\(Wallace\ Tree\)的本质就是把无数个\(CSA\)像倒长的树根一样层层堆叠,每一层都尽可能地把\(n\)个数按照“每\(3\)个一组”放进\(CSA\)中(\(3\)叉树),变成\(2\)个数。针对\(16\)个部分积,它的压缩过程如下:
① 初始状态:\(16\)个数
② \(Layer\ 1\):把\(16\)个数分成\(5\)组(每组\(3\)个),剩\(1\)个。\(5\)个\(CSA\)并行工作,把\(5×3=15\)个数压缩成了\(5×2=10\)个数。加上未参与\(CSA\)的\(1\)个数,现在还剩\(11\)个数。
③ \(Layer\ 2\):把\(11\)个数分成\(3\)组,剩\(2\)个。\(3\)个\(CSA\)并行工作,\(3×3=9\rightarrow 3×2=6\)。加上未参与\(CSA\)的\(2\)个数,现在还剩\(8\)个数。
④ \(Layer\ 3\):\(8\rightarrow 6\)
⑤ \(Layer\ 4\):\(6\rightarrow 4\)
⑥ \(Layer\ 5\):\(4\rightarrow 3\)
⑦ \(Layer\ 6\):\(3\rightarrow 2\)
经过\(6\)层\(CSA\)的门延迟,\(16\)个数被无延迟传播地压缩成了\(2\)个数。这种树状结构地时间复杂度是\(O(log_{3\over2}N)\),呈指数级下降。 - \(Wallace\ Tree\)再怎么进行压缩,最后也一定会剩下\(2\)个数,到现在我们不得不面对“进位传播”问题了。此时,硬件工程师会在整个乘法器的最后防止一个快速的进位传播加法器(\(CPA\))。通常是一个\(64\)位的\(CLA\)或者更高级的前缀加法器(\(Kogge-Stone\ Adder\))。这个最终的加法器负责把\(Wallace\ Tree\)输出的两个数\(Sum\)和\(Carry\)相加,算出最终乘法的结果。
除法
OK,写到这里的时候感觉这里的内容已经很多了,这么多加法器、乘法器算法该怎么梳理清楚?首先,
① 半加器→全加器(\([A,B,C_{in}]\rightarrow [S,C_{out}]\));
② 全加器级联形成行波进位加法器(\(RCA\)),由于高位的计算需要低位的进位,形成了串行,两个\(n\)位的数字相加,时间复杂度为\(O(N)\);
③ 利用\(S\)与\(C_{out}\)的布尔逻辑抽离嵌套,采用\(P_i=a_i\oplus b_i;G_i=a_ib_i\),将计算逻辑修改为:
我们可以并行计算得到\(c_i\),进而求解\(s_i=P_i\oplus c_i\),利用这种并行的加法器,我们可以高效地完成两个数的加法,即\(A+B\)。
④ 乘法器就是将其进行了二进制下的“判断→加法→移位”操作,实际上类似于十进制下的乘法,只不过我们平时并未注意,而最基础的乘法器需要采用算术移位以及对乘数的最高位进行判定来兼容补码乘法。而\(Booth\)算法可以通过\(2^{i}=2^{i+1}-2^i\)进行转换,转换后已经将乘数补码的最高位进行了权重判定,并且可以化连加为一加一减。操作判别式为\(p_{i-1}-p_i\)。
⑤ 为了避免在特殊情况下退化,\(Radix-4\ Booth\)强制进行转化为基\(4\)的乘法,滑动窗口的大小由\(2\)为增加至\(3\),其中的\(±2M\)由一次算数左移来完成,操作判别式为\(p_{2i-1}+p_{2i}-2p_{2i+1}\)。
⑥ 在乘法器中我们引入了多个部分积求和的问题,即多操作数加法,此时\(CSA\)的效率是要比\(CLA\)要高的,因为前者可以完成\(3-to-2\)的加法效果,但是最终我们会化简为两个操作数的加法,此时还是需要回头调用\(CLA\)完成最后的加法的。
恢复余数法(\(Restoring\ Division\))
整理完上述内容,我们来看一下计算机是怎么来完成除法操作的。我们人类在计算除法的时候会有一个操作,那就是判断当前的要执行加法的这个操作数是否够减,例如\(6x\)大于被除数此时的余数,\(5x\)小于此时的余数,我们就在这一位的商设定为\(5\),二进制下只有\(0\)和\(1\)。因此我们需要判定的只有此时够不够减去\(1\)倍的除数即可。
在硬件层面,基础二进制除法和乘法是极度对称的“镜像操作”:
- 乘法:判断乘数的最低位→将被乘数加到\(A\)→寄存器整体右移
- 除法:寄存器整体左移→尝试从\(A\)中减去除数→根据是否够减,生成商的一位,放在\(Q\)的最低位。
在这个过程中,依旧复用三个核心寄存器:\(M\)(存放除数)、\(A\)(存放余数)、\(Q\)(存放被除数,且随着移位,空出来的空间刚好用来存储商)
计算机最早期的除法实现就类似于我们现实中的列竖式,但计算机没办法提前判断是否够减,因此它必须尝试减一下,判断一下结果是不是负数。如果是负数,说明减多了,必须把除数加回来(恢复余数)。
- 整体左移:将
[A,Q]寄存器联合左移\(1\)位; - 尝试减法:执行\(A=A-M\);
- 判断结果:
- 如果\(A≥0\)(最高符号位为\(0\)):说明够减。将\(Q\)的最低位设为\(1\);
- 如果\(A<0\)(最高符号位为\(1\)):说明不够减。将\(Q\)的最低位设为\(0\),并且执行\(A=A+M\)恢复原来的余数。
- 循环执行上述步骤,知道所有位处理完毕。最终\(Q\)中是商,\(A\)中是余数。
举个例子:\(13÷3\)
被除数与除数均采用\(4-bit\)。初始化:被除数装入\(Q=\)1101、除数装入\(M=\)0011、余数累加器\(A\)初始化为0000。
| \(peroid\) | 当前状态[A Q] |
1. 联合左移 | 2.减去除数\(A=A-M\) | 3.判断与恢复(确定商) | 周期结束状态[A, Q] |
|---|---|---|---|---|---|
| \(1\) | [0000 1101] |
[0001 101_] |
\(A=0001 - 0011 = 1110\) | 不够减,商置\(0\)。执行加法恢复\(A=\)0001 |
[0001 1010] |
| \(2\) | [0001 1010] |
[0011 010_] |
\(A=0011-0011=0000\) | 够减,商置\(1\)。 | [0000 0101] |
| \(3\) | [0000 0101] |
[0000 101_] |
\(A=0000-0011=1101\) | 不够减,商置\(0\)。执行加法恢复\(A=\)0000 |
[0000 1010] |
| \(4\) | [0000 1010] |
[0001 010_] |
\(A=0001-0011=1110\) | 不够减,商置\(0\),执行加法恢复\(A=\)0001 |
[0001 0100] |
最终结果:寄存器\(Q=\)0100(商为\(4\));寄存器\(A=\)0001(余数为\(1\))。 |
加减交替法(\(Non-Restoring\ Division\))
\(Restoring\ Division\)的缺陷在哪里?如果在\(peroid1\)发现不够减,执行一次\(A=A+M\)恢复余数,白白浪费了一个时钟周期的加法操作,于是硬件工程师系统通过找到一种不需要恢复的实现方式:
- 按照\(Restoring\ Division\),我们需要先恢复余数\(A_{recover}=A-M+M\);
- 进入下一个\(peroid\)时,我们会将\(A_{recover}\)左移\(1\)位(相当于乘\(2\)),然后再减去\(M\);
- 把这两步合并成一个代数式:下一个\(peroid\)的余数\(A_{next}=2\times A_{recover} - M=2\times (A-M+M)-M=2A+M\)。
上面这个等式是惊艳的,它意味着,如果当前减\(M\)发现减多了变成负数,根本不需要在这个周期把它加回来。可以直接带着这个错误的负数进入到下一个\(peroid\),把它左移(乘\(2\)),然后在这个周期(\(peroid_{next}\))执行加法(\(+M\)),得到的结果和“先恢复、再左移、再减去”完全一摸一样。
\(Non-Restoring\ Division\)的核心规则就是不再“恢复”,而是根据上一轮余数的正负来决定下一轮是加还是减:
- 整体左移;
- 观察当前\(A\)的符号:
- 如果\(A\ge0\):执行\(A=A-M\);
- 如果\(A<0\):执行\(A=A+M\);
- 判断新计算出的\(A\)的符号:
- 如果\(A>0\):本位商上\(1\);
- 如果\(A<0\):本位商上\(0\)
如果最后一步算完余数依旧是负数,只需要再最终结束时执行一次\(A=A+M\)将真实余数修正即可,过程中绝不浪费周期。
加减交替法(\(Non-Restoring\ Division\))在体系结构史上具有里程碑意义。因为它打破了“商只能是老老实实的正数猜测”的束缚。
- 当你减去 \(M\) 时,相当于你猜当前的商位是 \(+1\)。
- 当你加上 \(M\) 时,相当于你承认上一步猜大了,你在这一步猜当前的商位是 \(-1\) 来补偿。
这套把商看作可以有正有负(\(\{1, -1\}\))的理念,直接启发了后来的计算机科学家。他们沿着这个思路继续拓展:如果每次不只看 1 位,而是看几位,并且允许商有 \(\{-2, -1, 0, 1, 2\}\) 更多种选择,是不是可以提前查表,甚至算错也没关系,后面补回来就行?
这就是现代 SRT 除法算法(Sweeney-Robertson-Tocher)的直接理论前身。 而现代CPU和GPU处理除法,形成了壁垒分明的两大门派:
- “空间换时间”的查找表猜测派:\(SRT\)算法(Intel/AMD整数和部分浮点除法主要采用)。
- “乘法代替除法”的暴力迭代派:\(Newton-Raphson\)(牛顿-拉弗森)算法(现代GPU和部分高频CPU浮点除法主力)
\(SRT\)
\(SRT\)算法得名于它的三位发明者\(Sweeney\)、\(Robertson\)、\(Tocher\)。如果你在纸上进行长除法(\(Restoring\ Division\)),你会发现最耗时的就是“猜商”。\(SRT\)的优化思想就是:不要每次都只猜\(1\)位,要一次猜\(2\)位、\(4\)位甚至\(8\)位。
- 冗余商与查找表:
在普通的二进制除法中,商的每一位都只能是0或1。
\(SRT\)算法引入了冗余数字系统(\(Redundant\ Number\ System\))。例如,在\(Radix-4\)(基\(4\),即每次产生\(2\)次商)的\(SRT\)算法中,商的候选数字集合不再是\(\{0,1,2,3\}\),而是变成了\(\{-2,-1,0,1,2\}\)。- 为何要引入负数商?
因为引入冗余后,我们在“猜商”时就不需要极其精确地比较部分余数和除数的大小了。硬件只需要截取部分余数的高几位,去差一个预先写死在硅片里的\(Lookup\ Table\)(查找表,\(PLA\))。
如果猜大了,无需恢复,下一个周期的商可以是负数(比如\(-1\)或\(-2\)),把它“补回来”。
如果猜小了,下一个周期再用正数加回来。
- 为何要引入负数商?
- 硬件实现
在硬件中,\(SRT\)算法每个周期做两件事情:- 用当前余数的高位和除数高位去查表,得到当前周期的商\(q_i\)。
- 计算新的余数:\(R_{i+1}=4\times R_i-q_i\times Divisor\)。(这里的乘\(4\)就是左移\(2\)位,减去\(q_i\times D\)则是通过\(CSA\)加法器完成)
查表操作计算机是怎么实现的?
- 由于有了负数商做缓冲(猜错了下个周期可以减回来),硬件只需要:
- 截取部分余数的高\(7\)位;
- 截取除数的高\(5\)位。
- 硬件利用这\(7\)位和\(5\)位的短数字,去查一张硬件连线固化的二维表格。这个表格的输入是(余数高位,除数高位),输出就是对应的商\(q_i\)。
举个例子:计算\(-124÷4\),在\(Radix-4\)中,由于每次处理\(2-bit\),商的权是\(4^i\)。我们需要用到\(3\)个周期的商\(q_2,q_1,q_0\)来组合最终的结果:
为了便于理解,我们将硬件中“余数乘\(4\)”的动作,等价替换为“把除数按权重放大”:
- \(peroid1\):等价除数\(=5\times16=80\);
- \(peroid2\):等价除数\(=5\times4=20\);
- \(peroid3\):等价除数\(=5\times1=5\)
接下来,进行查表挖成除法,
- 假设硬件查找表每次都给出了完美的\(q_i\)。
| \(peroid\) | 当前余数\(R\) | 查表给出的\(q_i\) | 本\(peroid\)操作:减去\(q_i\times\)等效除数 | \(peroid\)结束的新余数 |
|---|---|---|---|---|
| \(1(\times16)\) | \(-124\) | \(-1\) | \(-124-(-1\times80)\) | \(-44\) |
| \(2(\times4)\) | \(-44\) | \(-2\) | \(-44-(-2\times20)\) | \(-4\) |
| \(3(\times1)\) | \(-4\) | \(0\) | \(-4-(0\times5)\) | \(-4\) |
- 最终商的计算\(Q=(-1)\times16+(-2)\times4+(0)\times1=-16-8=-24\)。
- 假设查找表猜错了
假设第一个周期,硬件查找表发生了偏差,给出了一个比较夸张的商\(-2\)。在传统的\(Restoring\ Division\)中,必须回滚来恢复余数。而\(SRT\)算法会按照下面的执行流程来处理:
| \(peroid\) | 当前余数\(R\) | 查表给出的\(q_i\) | 本\(peroid\)操作:减去\(q_i\times\)等效除数 | 周期结束的新余数 | 状态分析 |
|---|---|---|---|---|---|
| \(1(\times16)\) | \(-124\) | \(-2\) | \(-124-(-2\times80)\) | \(36\) | 余数从负数变成了正数 |
| \(2(\times4)\) | \(36\) | \(2\) | \(36-(2\times20)\) | \(-4\) | 既然上一步减多了,这一步查表就会给出一个正数\(2\),把多减的部分加回来 |
| \(3(\times1)\) | \(-4\) | \(0\) | \(-4-(0\times5)\) | \(-4\) |
- 最终商的计算\(Q=(-2)\times16+(2)\times4+(0)\times1=-32+8=-24\)
- 无论第一步的商是\(-1\)还是\(-2\),最终拼出来的十进制商都是精确的\(-24\),这就是引入\(\{-2,-1,0,1,2\}\)冗余数字系统的根本意义——重叠的容错空间。硬件在查表时,不需要对比所有位,只需要抽取高位进行比对,给个大概的商,只要误差没有击穿冗余容限,下个周期就能用反向符号的商给拉回来。
小插曲:
1994 年的那个冬天,\(Intel\) 因为这个 \(Bug\) 付出了 \(4.75\) 亿美元的召回成本,史称 \(Pentium\ FDIV\ Bug\)。
在 \(Pentium\) 处理器的 \(FPU\)(浮点运算单元)中,这张二维查找表是通过 \(PLA\)(可编程逻辑阵列) 在硅片上硬连线实现的。
整个表理论上包含 \(1066\) 个有效表项。但在 \(1991\) 年设计这块电路时,一位 \(Intel\) 工程师为了优化硬件面积,编写了一个自动化脚本来生成这 \(1066\) 个表项的逻辑连线。
由于脚本存在一个小缺陷,在下载这些逻辑方程到 \(PLA\) 时,漏写了其中的\(5\)个表项! 这\(5\)个漏掉的表项,其正确的商应该输出 +2。但在硅片上,因为那里没有任何连线,所以只要硬件查到了这 5 个格子,输出就默认变成了 0。
你可能会想:只错了\(5\)个格子,而且\(SRT\)算法不是号称有“冗余修复”能力,猜错了下个周期还能救回来吗?
这里就是 \(SRT\) 算法的盲区:它只能修复“小幅猜错”,修复不了“重大漏判”。
- 如果你该猜
+2,你猜成了+1,下个周期余数偏大,下个周期还会猜正数,慢慢能补回来。 - 但如果你该猜
+2,因为 \(Bug\) 你直接输出了0!这意味着你少减了极其巨大的数值。在这个周期结束时,部分余数变得如此之大,以至于远远超出了 \(SRT\) 算法设计时的容错边界(超出了下一轮查表的支持范围)。
这就导致下一轮查表得到的结果继续失真。最终,经过多轮循环累积,算出的浮点商在第 \(9\) 位或第 \(10\) 位有效数字之后,出现了肉眼可见的偏差。
\(Newton-Raphson\ Division\)
现代高性能处理器(特别是\(GPU\)和支持\(AVX\)向量扩展的\(CPU\))在处理高精度浮点除法时,已经彻底抛弃了传统的“猜商-相减”逻辑,转而采用纯数学分析维度的:牛顿-拉弗森迭代法(\(Newton-Raphson\ Division\))。
在硬件架构中,除法器之所以慢,是因为每一次做减法之前,都必须等待上一次减法的结果(强串行依赖)。牛顿迭代法的优化思想就是:完全废除除法电路,利用已经高度并行化、极速的华莱士树乘法器,通过逼近算法直接“乘”出除法的结果。
\(Division\rightarrow Find\ The\ Root\)
计算 \(A \div B\),在数学上等价于 \(A \times (1/B)\)。硬件如果能极速求出除数的倒数,除法就变成了一次简单的乘法。
求倒数 \(x = 1/B\),可以通过构造一个函数,使其根刚好为 \(1/B\)。
我们构造函数:
显然,当 \(f(x) = 0\) 时,\(x\) 的真实值就是我们要找的 \(1/B\)。
根据微积分中的牛顿-拉弗森迭代公式(利用切线不断逼近零点):
我们对 \(f(x)\) 求导,得到 \(f'(x) = -1/x^2\)。将其代入迭代公式:
观察最终的迭代式,里面没有一个除号!所有的操作仅包含:
- 一次乘法:\(B \times x_n\)
- 一次减法(对二进制极度友好):\(2 - \text{乘积}\)
- 一次乘法:\(x_n \times \text{差值}\)
这完美契合了现代微架构中的 \(FMA\)(\(Fused\ Multiply-Add\),融合乘加) 单元。
二次收敛
为什么硬件工程师敢用迭代法?因为牛顿迭代具有二次收敛(\(Quadratic\ Convergence\))特性。
我们可以做一个误差分析。假设第 \(n\) 次迭代的相对误差为 \(\epsilon_n\):
我们来看下一次迭代的误差 \(\epsilon_{n+1}\):
代入迭代公式 \(x_{n+1} = x_n(2 - B x_n)\):
误差在每一次迭代后,直接被平方!
在二进制浮点数中,误差平方意味着有效精度(正确的比特位数)在每一次迭代后直接翻倍。
双精度除法
在真实的 CPU/GPU 硅片上,求解 IEEE 754 双精度(64-bit,其中尾数占 53 bit)浮点除法的完整物理管线如下:
-
Step 1:种子生成 (\(Seed\ Generation\))
硬件绝对不会从 \(0\) 或者 \(1\) 开始盲目迭代。\(FPU\)(浮点运算单元)内部固化了一块极小的 \(ROM\) 查找表。硬件提取除数 \(B\) 的前几位输入 \(ROM\),直接查出一个精度大约为 \(8- bit\) 的粗糙倒数近似值作为初始种子 \(x_0\)。这一步耗时几乎为 \(0\)。 -
Step 2:迭代 (\(Newton-Raphson\ Iterations\))
将 \(x_0\) 送入 \(FMA\) 乘加树阵列:- 第 \(1\) 次迭代:利用 \(8-bit\) 精度算出 \(x_1\),精度瞬间翻倍至 \(16-bit\)。
- 第 \(2\) 次迭代:算出 \(x_2\),精度翻倍至 \(32-bit\)(此时已经满足单精度浮点 \(float\) 的要求)。
- 第 \(3\) 次迭代:算出 \(x_3\),精度翻倍至 \(64-bit\)。
仅仅经过 \(3\) 次时钟周期的 \(FMA\) 流水线流转,一个高达 \(64\) 位的精确倒数就已经计算完毕。
-
Step 3:相乘 (\(Final\ Multiplication\))
拿到精确的 \(1/B\) 后,将其与被除数 \(A\) 送入乘法器,执行 \(A \times x_3\),得出最终的商。
跨域映射:硬件除法与密码学大数模逆的同源性
牛顿迭代不仅在浮点硬件中大放异彩,在密码学的高性能大数运算中同样是核心基石。
在 \(RSA\) 或者是国密 \(SM2\) 的底层实现中,我们经常需要处理数千位的大数除法或取模(如 \(A \bmod M\))。传统的长除法在几千位的数字面前会慢到系统瘫痪。
为此,密码学中大量采用了 \(Barrett\) 约减算法。\(Barrett\) 算法的核心思想与硬件浮点除法如出一辙:预先计算并保存模数的倒数(\(\mu = \lfloor 2^{2k} / M \rfloor\)),从而把运行时的除法取模运算,全部替换为移位和乘法。
而在硬件加速卡(如 \(FPGA\) 上的加密芯片)中计算 \(Barrett\) 约减的那个极其庞大的倒数 \(\mu\) 时,采用的正是基于有限域或 \(p-adic\) 整数扩展的牛顿-拉弗森迭代倒数算法。
乘法\(VS\)除法
现在回到我们这篇笔记整理之初所探究的问题,为什么计算机中除法普遍比乘法慢?
其根本原因在于除法存在极强的数据前后依赖性(\(Data\ Dependency\)),导致除法无法像乘法那样在物理空间商彻底地展开与高度并行化。
无论是最基础地恢复余数法、加减交替法,还是现代地\(SRT\)查表法和牛顿-拉弗森迭代法,除法地物理管线始终无法摆脱串行循环的硬件设计。我们可以从体系结构的微观视角和汇编指令的宏观视角来分析。
乘法的空间特权\(vs.\)除法的串行循环
- 乘法为什么快?
- 乘法的本质是多操作数加法。得益于\(Booth\)算法和\(Wallace\ Tree\)的结合,乘法器在接收到操作数的第一个时钟周期内,就能无条件、并行地生成所有的部分积。
我们前期介绍到\(Radix-4\ Booth\)算法的时候并没有以并行的角度来展开讲解,但是在介绍\(Wallace\ Tree\)的时候,我们得到了,“如果我们能够得到所有的部分积,那么我们可以利用\(CSA\)很快地得到部分积的求和”。
这里说的并行得到所有的部分积是怎么回事?因为我们的乘法的选择仅依赖于第一时间载入的乘数\(Q\),根据\(q_{2i-1}+q_{2i}-2q_{2i+1}\)选择执行\(\{-2M,-M,0+M,+2M\}\),然后执行右移。我们可以扩大电路面积,一次性计算出所有的\(q_{2i-1}+q_{2i}-2q_{2i+1}\),然后并行地选取要执行的操作,而所谓的移位操作的我们可以将整个线路错开固定位数的线路完成。举个形象的例子,就是,我们在计算\(179\times123\)的时候,我们可以计算出\(179\times1\)、\(179\times2\)、\(179\times3\),这就是部分积,得到了之后利用\(CSA\)一次性求和。
- 由于所有的部分积已经同时存在,\(Wallace\ Tree\)就可以利用\(CSA\)将\(O(N)\)个部分积在\(O(log\ N)\)的极短门电路延迟内完成求和。乘法是一个前馈(\(Free-forward\))的纯组合逻辑电路,只要电路面积够大,它就能在一个或少数几个时钟周期内将结果算出。
- 除法为什么慢?
- 除法的本质是在“试探与更新”。在计算第\(i\)位商时,硬件必须知道第\(i-1\)位商导致的部分余数是正还是负。
- 对于\(SRT\)算法:即便\(Radix-4\)能够一次猜出\(2\)位商,一个\(64\)位的除法依然需要串行迭代\(32\)次。你无法提前算好所有的部分余数,因为下一次的查表输入(部分余数的高位)严格依赖于上一步加法器输出的结果。这种串行反馈回路(\(Feedback\ Loop\))使得触发器永远无法展开成一颗全并行的树。
- 对于\(Newton-Raphson\)算法:虽然废弃了除法器,改用\(FMA\)(融合乘加)单元,但其核心公式\(x_{n+1}=x_n\times(2-B\times x_n)\)依然是强依赖的。第\(3\)次迭代的乘法,必须等第\(2\)次迭代彻底算完才能开始。虽然单次乘法极快,但连续\(3\)到\(4\)次串行\(FMA\)的延迟累加,使得其实际耗时一般是单一乘法指令的\(3\)到\(5\)倍以上。
汇编底层的%操作与微代码
在\(\times86\)架构的汇编指令集底层,取模运算(%)并没有独立的基础运算硬件,它和除法完全共享同一条数据通路和指令微代码。
DIV与IDIV指令的物理行为:
- 当使用\(C\)语言或\(Rust\)编写\(A\%B\)时,如果\(B\)不是编译期已知的常量,编译器会生成
DIV(无符号)或IDIV(有符号)指令。 - 在\(\times86\)体系中,除法指令的设计时商和余数同时生成的:
- 输入:被除数存放在
RDA:PAX(\(64\)位模式下的\(128\)位寄存器组合),除数在通用寄存器或内存中。 - 执行:\(CPU\)内部的微代码调度器(\(Microcode\ Sequencer\))会暂停当前执行流,将数据送入除法器硬件(\(SRT\)或迭代单元)循环执行数十个周期。
- 输出:最终的商强制存入
RAX,最终的余数强制存入RDX。
- 输入:被除数存放在
- 这意味着,即使你仅仅是需要
A%B的余数,完全不关心商,\(CPU\)底层也是需要完成一次完整的除法的。这种流水线阻塞(\(Pipeline\ Stall\))对乱序执行(\(Out-of-Order\ Execution\))引擎是巨大的伤害。 - 由于
%的开销极其昂贵(通常需要\(10-40\)个时钟周期,而乘法仅需\(3-4\)个周期,加法仅需\(1\)个周期),如果除数\(B\)是\(2^k\),编译器会将A % B优化为A & (B-1)。这只需要\(1\)个周期的按位与(\(AND\))操作。这是所有模运算中最快的一条捷径。
思想迁移
-
Montgomery 乘法 (Montgomery Reduction)
蒙哥马利算法的本质,就是为了彻底消灭运行时的任意除数取模(% M)。它利用同构映射,将数字转换到蒙哥马利域内,使得所有的除法操作全都变成了除以 \(R\)。而密码学工程师会巧妙地将 \(R\) 设置为 \(2^k\)(通常是机器字长的整数倍)。此时,极其昂贵的除法/取模,在硬件底层被瞬间降维成了零开销的寄存器算术右移(Shift) 和截断(逻辑 AND)。 -
Barrett 约减 (Barrett Reduction)
当无法使用蒙哥马利域时,Barrett 算法采用了类似硬件牛顿迭代除法的“乘法替代”思想。它在系统初始化时,极其昂贵地计算一次 \(\mu = \lfloor 2^{2k} / M \rfloor\) 并缓存。在后续高频的加密/签名取模时,通过 \(A - \lfloor (A \cdot \mu) / 2^{2k} \rfloor \cdot M\) 将模运算全部转换为两次乘法和两次移位,完美避开了 ALU 中的串行除法器。
\(My\ own\ thinking\)
- 这篇笔记写了很久,其中在慢慢整理的过程中也思考了很多有趣的事情。这里记录一下其中一个。
- 我们知道在乘法器执行的过程中,不论是基础的乘法器还是\(Booth\)算法,其本质还是在遍历乘数\(Q\),而被乘数\(M\)在寄存器内保持不变,但是乘法具有对称性。当我们遇到乘法的时候,我们是不是可以调整一下两者的载入顺序,让位数更短的数字做为乘数,从而进一步提升乘法执行的速度?
- 例如,我们要计算
0x01101101与0x00000011的乘法,以0x0000011作为乘数,岂不是需要执行的操作次数就减少了? - 后续查了一些资料,大致整理如下:
动态交换操作数以让有效位数较短的数字作为乘数,在纯软件算法或早期的串行微代码硬件中是使用过的,但在现代高性能处理器的纯这逻辑硬件架构中,它不仅不可行,反而会拖慢系统整体速度。- 软件实现
如果在使用\(C\)、\(Python\)、\(Rust\)等语言手写密码学的大数运算库,这种优化思路是可行且有效的:
在软件代码或早期的单片机(\(MCU\))硬件中,乘法本质上是一个while (Q != 0)的循环。每次循环执行一次判断、加法和移位。
如果要计算0x01101101\(\times\)0x00000011:- 如果
0x01101101作为乘数\(Q\),循环需要执行\(25\)次; - 如果
0x00000011作为乘数\(Q\),循环执行\(5\)次。
在这种时序逻辑下,操作数交换可以将执行周期从\(25\)减少到\(5\),性能提升极其显著。
- 如果
- 现代计算机硬件
现代\(CPU\)内部的\(Booth-Wallace\)乘法器,其内部就像我们上面说的那样,它不再是循环,而是完全展开的纯组合逻辑电路。- 空间展开到了的“时间恒定”:
在\(32\)位乘法器中,硬件工程师在硅片上焊死了\(16\)个\(Radix-4\ Booth\)编码器和\(6\)层华莱士树。
当0x00000011进入这\(16\)个编码器时,即使它的高\(27\)位全是\(0\),高位的\(Booth\)编码器依然会正常工作,输出\(0\)的控制信号;高位的多路选择器(\(MUX\))依然会选出0作为部分积;华莱士树种的\(CSA\)加法器依然会执行0+0+0的物理电平翻转。
在组合逻辑电路种,电子穿过硅片走完整个物理路径的时间是衡定的。无论是算0xFFFFFFFF\(\times\)0xFFFFFFFF还是1\(\times\)1,其中的电路路径是不变的,因为流水线的设计,现代\(CPU\)的乘法通常固定为\(3\)到\(4\)个时钟周期,不会因为输入的数字小就提前结束。 - 动态交换会带来“关键路径惩罚”
如果要在硬件层面实现我想的,或者说前人考虑过的交换想法,\(CPU\)需要在乘法器前面前置一套前置电路:- 两个前导\(0\)检测器(\(Leading\ Zero\ Detector,LZD\)),分别测算两个操作数的真实长度。
- 一个比较器(\(Comparator\)),判断谁更短;
- 两个\(64-bit\)多路选择器(\(MUX\)),根据比较结果交叉路由,把短的送入\(Q\)寄存器,长的送入\(M\)寄存器。
这套前置电路本身需要消耗大约\(1\)到\(2\)个时钟周期的门延迟。由于乘法器的主题是固定周期的,这意味着为了优化某种特殊情况,将所有乘法指令的执行时间都增加了\(1\)到\(2\)个时钟周期,这种想法对于一般情况反而会增加硬件的执行时间。
- 空间展开到了的“时间恒定”:
- 现代工业生产的处理方式:
现代硬件的确也会关注“操作数很小”的情况,但是它们的优化维度不是“交换操作数”,而是以下两种物理手段:- 功耗门控:当硬件检测到高位全是\(0\)时,它虽然不能减少时钟周期,但可以通过逻辑与门(\(AND\ gate\))直接把高位电路的输入强行锁死在
0。这样高位的晶体管就不会发生无意义电平翻转,从而极大地节省了动态功耗。(手机\(ARM\)芯片)。 - 早期终止:在一些非乱序执行或较简单的微架构(如\(ARM\ Cortex-M\)系列)的早期设计中,乘法器的确会根据前导零的数量,在\(1\)周期、\(2\)周期或\(3\)周期后提前给出\(Ready\)信号。但即便如此,它也是通过“看一眼现成的\(Q\)有多长,长久多算两步,短就直接输出”来实现的,而绝对不会花费额外的门延迟来交换\(M\)与\(Q\)的。
- 功耗门控:当硬件检测到高位全是\(0\)时,它虽然不能减少时钟周期,但可以通过逻辑与门(\(AND\ gate\))直接把高位电路的输入强行锁死在
- 软件实现

计算机组成原理——四则运算(半加器、全加器、超前进位加法器、保留进位加法器、乘法器、Booth算法、除法器)
浙公网安备 33010602011771号