最近刚通关图灵完备,作为一个没有学过计算机专业课的人来说,玩的过程中也算是对逻辑电路有了一点点了解。
不知道是不是我的 mac 配置不够的问题,我已经在搭原件的时候遇到
两四次死机的情况了。本来还想尝试加一下 risc-v 指令集呢,还是先搁置吧。
基础实现
加法器的输入是两个八位数字 $x$,$y$ 以及进位 $c$,计算 $x+y+c$ 以及输出进位。
一个最基础,同时也是最直观的实现,就是模拟手工计算的过程。这里我们定义 $x_i$,$y_i$ 分别是输入的第 $i$ 位,$s_i$ 以及 $c_i$ 则是第 $i$ 位的输出值和进位。其流程如下:
- 计算第 0 位的输出以及进位,其中 $s_0 = x_0 \oplus y_0 \oplus c $, $c_0 = (x_0 \land y_0) \lor (x_0 \land c) \lor (y_0 \land c)$。为了方便起见,我们把传入的进位定义为 $c_{-1}$。
- 把第 0 位得到的进位和第一位计算得到 $s_1$ 和 $c_1$。
- 以此类推,得到所有八位输出以及最终进位 $c_7$。
其实这里就是把 8 个全加器连接起来。
改写一下
第一个方法很简单,很直白,除了慢了点,没啥缺点。这种实现也有个名字,叫行波进位。容易看出,每一位的计算需要 7 个原件,延迟主要来自 $c_i$ 的计算,抛开能预计算得到的部分,延迟是4。
这里原件是指 xor,nor 之类的,延迟也是游戏里的定义,不玩游戏的话不用关心。
不过这其实并不是最简形式,首先我们给预计算部分进行一下定义(至于为什么叫 $p$ 和 $g$ 后面就会知道了):
$$ \begin{aligned} g_i &= x_i \land y_i \\ p_i &= x_i \oplus y_i \end{aligned} $$注意力惊人的人可以发现,$c_i$ 也可以改写为:
$$ c_i = (x_i \land y_i) \lor \left(c_{i-1} \land (x_i \oplus y_i)\right) $$诶,这时候第 $i$ 位的计算就可以写成如下形式,延迟也降低了1。
$$ \begin{aligned} s_i &= p_i \oplus c_{i-1} \\ c_i &= p_i \lor (g_i \land c_{i-1}) \end{aligned} $$快一点
虽然但是,目前大部分计算仍然是串行执行的,每一位的计算都依赖于前一位的结果。想要让速度快起来,就必须找到一种方式能够进行并行计算。
先来看 $c_i$ 的计算,我们来展开看看:
$$ \tag{1} \begin{aligned} c_i &= g_i \lor (p_i \cdot c_{i-1}) \\ &= g_i \lor (p_i \cdot (g_{i-1} \lor (p_{i-1} \cdot c_{i-2}))) \\ &= g_i \lor (p_i \cdot (g_{i-1} \lor (p_{i-1} \cdot (g_{i-2} \lor (p_{i-2} \cdot c_{i-3}))))) \\ &= g_i \lor (p_i \cdot g_{i-1}) \lor (p_i \cdot p_{i-1} \cdot (g_{i-2} \lor (p_{i-2} \cdot c_{i-3}))) \\ &= g_i \lor (p_i \cdot g_{i-1}) \lor (p_i \cdot p_{i-1} \cdot g_{i-2}) \lor (p_{i} \cdot p_{i-1} \cdot p_{i-2} \cdot c_{i-3}) \\ &= \cdots \end{aligned} $$括号太多了,就不继续展开了,总之最后这个式子一定能展开到 $c_{-1}$。这里很多部分都是能够并行计算的(例如四个 and/or 可以在 2 延迟内完成),因此总延迟也就降低了。
好处说完了就有坏处,为了提高并行度势必要引入更多的原件,当然这也是不可避免的 trade-off。
这个方法也有个名字,叫超前进位加法器 (Carry-Lookahead Adder)。
分而治之
我们在上面展开三位的时候,就已经十分复杂了,实际上总电路规模会随着位数以二次的速率上升,这对于 8 位计算来说是不可接受的,所以就要祭出分治的思想。
分块超前进位就是将输入拆成多个块,分别计算超前进位,最后再拼接起来。例如我们可以将八位的输入拆成两个四位的块,下图展示了其中一块的输入与输出:
gi,gi+1,gi+2,gi+3 pi,pi+1,pi+2,pi+3
| |
v v
q[i,i+3] <--- +--------------------------------+ <--- ci-1
p[i,i+3] <--- | 4bit 超前进位链块 |
+--------------------------------+
|
v
ci,ci+1,ci+2
基于上一节里的展开,如果我们分别定义 $g_{[i, j]}$ 和 $p_{[i, j]}$ 为:
$$ \tag{2} \begin{aligned} g_{[i, j]} &= g_j \lor (p_j \cdot g_{j-1}) \lor \cdots \lor (p_j \cdot p_{j-1} \cdots g_{i}) \\ p_{[i, j]} &= p_j \cdot p_{j-1} \cdots p_{i} \end{aligned} $$进位输出就可以表示为:
$$ c_i = g_{[i, j]} \lor (p_{[i, j]} \cdot c_{j-1}) $$由于 $p_{[i, j]}$ 和 $g_{[i, j]}$ 的计算同样不依赖前导位,因此可以先并行计算出每个块必要的数据,然后从 $c_{-1}$ 开始,得到每一块的输出,以及需要传递给下一位的进位。
到这里就可以解释 $p$ 和 $g$ 分别是什么了:
- $p$ (propagate):进位传播,即当前位是否能将来自上一位的进位传递下去。
- $p_{[i, j]}$ 的理解也很直接,只有从 $i$ 到 $j$ 的每一位都能传播时,这个块才能传播传入的进位。
- $g$ (generate):进位生成,即当前位是否生成了到下一位的进位。
- $g_{[i, j]}$ 同理,当前位产生了进位只有两种情况,自身有进位或者前一位有进位且自己能传播它,以此类推。
下文里我们将这两个值统称为块信号。
还不够?
分块超前进位的串行度由块数决定,有没有办法再快一点呢?
有的,朋友有的,重新回到公式(1),注意到这一行:
$$ c_{i} = {\color{#9a4638} g_i \lor (p_i \cdot g_{i-1})} \lor ({\color{#9a4638} p_i \cdot p_{i-1}} \cdot ({\color{#4a7c7b} g_{i-2}} \lor ({\color{#4a7c7b} p_{i-2}} \cdot c_{i-3}))) $$有感觉吗?我们把公式(2)代入:
$$ \begin{aligned} c_{i} &= g_{[i, i-2]} \lor (p_{[i, i-2]} \cdot c_{i-3}) \\ &= g_{[i, i-1]} \lor (p_{[i, i-1]} \cdot (g_{[i-2, i-2]} \lor (p_{[i-2, i-2]} \cdot c_{i-3}))) \\ &= g_{[i, i-1]} \lor (p_{[i, i-1]} \cdot g_{[i-2, i-2]}) \lor (p_{[i, i-1]} \cdot p_{[i-2, i-2]} \cdot c_{i-3}) \\ \end{aligned} $$不失一般性,对于 $i < j < k$,我们可以从中归纳出如下公式:
$$ \begin{aligned} g_{[i, k]} &= g_{[j, k]} \lor (p_{[j, k]} \cdot g_{[i, j-1]}) \\ p_{[i, k]} &= p_{[i, j-1]} \cdot p_{[j, k]} \\ c_{i} &= g_{[0, i]} \lor (p_{[0, i]} \cdot c_{-1}) \\ s_{i} &= p_{i} \oplus c_{i} \end{aligned} $$也就是说,相邻的块信号是可以合并的。
为了便于描述,这里定义 $Q_{[i, j]} = (g_{[i, j]}, p_{[i, j]})$,以及对应的计算 $\cup$
$$ Q_{[i, j]} = Q_{[i, j-1]} \cup Q_{[j, k]} $$这时候,问题就变成了
- 已知所有长度为 1 的块信号,$Q_{[i, i]} (i = 0\dots 7)$,如何计算 $Q_{[0, i]} (i = 0\dots 7)$
这个问题没有唯一解,所有基于此产生的加法器,可以统称为 并行前缀加法器(Parallel-Prefix Adder)。
一种常见的结构叫做 Kogge-Stone 结构,总共需要三层计算:
- 第0层是所有 $Q_{[i, i]}$。
- 第一层将相邻的信号合并,得到 $Q_{[i, i+1]}$。
- 第二层中,对上一层得到的信号进一步进行跨度为2的合并,得到 $Q_{i, i+3]}$。
- 最后一层中,再次对上一层的信号进行跨度为4的合并,最终得到所有 $Q_{[0, i]}$。
可以对照下面的表格来品一下上面的合并逻辑。
╔══════════╦═══════╦═══════╦═══════╦═══════╦═══════╦════════╦════════╦════════╗
║ Lvl/Bit ║ 0 ║ 1 ║ 2 ║ 3 ║ 4 ║ 5 ║ 6 ║ 7 ║
╠══════════╬═══════╬═══════╬═══════╬═══════╬═══════╬════════╬════════╬════════╣
║ L0 ║ [0] ║ [1] ║ [2] ║ [3] ║ [4] ║ [5] ║ [6] ║ [7] ║
╠══════════╬═══════╬═══════╬═══════╬═══════╬═══════╬════════╬════════╬════════╣
║ L1 ║ [0] ║ [1,0] ║ [2,1] ║ [3,2] ║ [4,3] ║ [5,4] ║ [6,5] ║ [7,6] ║
╠══════════╬═══════╬═══════╬═══════╬═══════╬═══════╬════════╬════════╬════════╣
║ L2 ║ [0] ║ [1,0] ║ [2,0] ║ [3,0] ║ [4,1] ║ [5,2] ║ [6,3] ║ [7,4] ║
╠══════════╬═══════╬═══════╬═══════╬═══════╬═══════╬════════╬════════╬════════╣
║ L3 ║ [0] ║ [1,0] ║ [2,0] ║ [3,0] ║ [4,0] ║ [5,0] ║ [6,0] ║ [7,0] ║
╚══════════╩═══════╩═══════╩═══════╩═══════╩═══════╩════════╩════════╩════════╝
感谢 d 老师生成的表格🥹。
除了这种结构外,还有 BK,LF 等结构,就不在此展开了。
结语
到此,我们就成功地设计出了一个高速的加法器。作为一个从没有接触过这方面的小白来说,最终能在游戏里搭建并跑通这个电路还是很有成就感的。
当然,我实际的心路历程是和本文相反的,最初是在 b 站的评论区里发现了超前进位、并行前缀等术语,然而我一点也看不懂,就直接扔给了 d 老师,让 d 老师帮我生成具体的执行步骤,我只管搭建。虽然运行通过了,但是我一点也不理解我在做什么,什么是 $p$,什么是 $g$,为啥要这么合并,为啥要合并三层。
后来在知乎找到了一系列的文章(请看参考),写的非常好,娓娓道来,总算是弄明白了我在做什么。
虽然搞明白这个好像并没有什么卵用,就权当浪费了几天来自娱自乐了(当你看到这里的时候,你也浪费了宝贵的 6 分钟)。