加法器:从半加器到流水线加法器
加法器这东西,说简单也简单,说复杂也复杂。我刚开始学Verilog那会儿,觉得加法不就是个"+"号吗?后来做卷积加速器才发现,加法器的设计直接决定了整个加速器的吞吐量。今天咱们就把它彻底讲透。
半加器与全加器:最基础的计算单元
先看最简单的半加器。两个1位二进制数相加,产生和(Sum)与进位(Carry)。
// 半加器
module half_adder (
input a, // 加数
input b, // 加数
output sum, // 和
output carry // 进位
);
assign sum = a ^ b; // 异或:相同为0,不同为1
assign carry = a & b; // 与:两个都是1才进位
endmodule
嗯,这里要注意:半加器不考虑来自低位的进位。实际项目中几乎用不到它,因为多位加法必须考虑进位传递。全加器才是真正有用的。
// 全加器
module full_adder (
input a, // 加数
input b, // 加数
input cin, // 来自低位的进位
output sum, // 和
output cout // 向高位的进位
);
assign sum = a ^ b ^ cin; // 三个输入异或
assign cout = (a & b) | (a & cin) | (b & cin); // 至少两个为1
endmodule
全加器的真值表其实很简单,我建议你手推一遍:
| a | b | cin | sum | cout |
|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 |
| 0 | 0 | 1 | 1 | 0 |
| 0 | 1 | 0 | 1 | 0 |
| 0 | 1 | 1 | 0 | 1 |
| 1 | 0 | 0 | 1 | 0 |
| 1 | 0 | 1 | 0 | 1 |
| 1 | 1 | 0 | 0 | 1 |
| 1 | 1 | 1 | 1 | 1 |
个人经验:我在做卷积加速器时,乘法器里的部分积累加就是靠全加器阵列完成的。当时为了省面积,把全加器用LUT实现了,结果时序跑不过。后来改成专用加法器单元,频率直接提升了30%。
行波进位加法器:最直观的多位加法
把多个全加器串联起来,就构成了行波进位加法器(Ripple Carry Adder)。每个全加器的进位输出接到下一个全加器的进位输入。
// 4位行波进位加法器
module ripple_carry_adder_4bit (
input [3:0] a,
input [3:0] b,
input cin,
output [3:0] sum,
output cout
);
wire c1, c2, c3; // 内部进位
full_adder fa0 (.a(a[0]), .b(b[0]), .cin(cin), .sum(sum[0]), .cout(c1));
full_adder fa1 (.a(a[1]), .b(b[1]), .cin(c1), .sum(sum[1]), .cout(c2));
full_adder fa2 (.a(a[2]), .b(b[2]), .cin(c2), .sum(sum[2]), .cout(c3));
full_adder fa3 (.a(a[3]), .b(b[3]), .cin(c3), .sum(sum[3]), .cout(cout));
endmodule
这种加法器有个致命问题:进位像多米诺骨牌一样逐级传递。4位加法器需要4级门延迟,32位就需要32级。在卷积加速器里,如果数据位宽是32位,那延迟就太长了。
避坑指南:我曾经在一个图像处理项目里用了行波进位加法器做像素累加,结果时序收敛不了。后来换成超前进位加法器,问题才解决。记住:位宽超过8位就别用行波进位了。
超前进位加法器:速度与面积的权衡
超前进位加法器(Carry Look-ahead Adder)的核心思想是:提前计算出进位,而不是等前一级算完。它用两个关键信号:
- 生成信号 G = a & b:只要a和b都是1,就一定产生进位
- 传播信号 P = a ^ b:如果a和b不同,进位取决于cin
有了G和P,进位公式就变成了:
c1 = G0 | (P0 & cin)
c2 = G1 | (P1 & G0) | (P1 & P0 & cin)
c3 = G2 | (P2 & G1) | (P2 & P1 & G0) | (P2 & P1 & P0 & cin)
// 以此类推...
你看,每个进位都可以用组合逻辑直接算出来,不需要等待。代价是逻辑门数量呈指数增长。4位超前进位加法器还行,32位的话,那个公式会写到你想哭。
实际工程做法:通常采用"分层超前进位"——先做4位一组,组内超前进位,组间再超前进位。这样速度和面积都比较均衡。我在卷积加速器里就是这么干的。
流水线加法器:为高速而生
如果加法器要跑在500MHz以上,组合逻辑延迟肯定扛不住。这时候就需要流水线了。
// 4级流水线加法器
module pipelined_adder (
input clk,
input rst_n,
input [31:0] a,
input [31:0] b,
input cin,
output reg [31:0] sum,
output reg cout
);
// 第1级:低8位加法
reg [7:0] sum_low;
reg carry_mid1;
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
sum_low <= 0;
carry_mid1 <= 0;
end else begin
{carry_mid1, sum_low} <= a[7:0] + b[7:0] + cin;
end
end
// 第2级:次低8位加法
reg [7:0] sum_mid_low;
reg carry_mid2;
reg [7:0] a_mid_low, b_mid_low; // 输入打拍
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
sum_mid_low <= 0;
carry_mid2 <= 0;
a_mid_low <= 0;
b_mid_low <= 0;
end else begin
a_mid_low <= a[15:8];
b_mid_low <= b[15:8];
{carry_mid2, sum_mid_low} <= a_mid_low + b_mid_low + carry_mid1;
end
end
// 第3级、第4级类似,省略...
// 最后一级输出最终结果
endmodule
流水线加法器的好处是:虽然单个加法需要4个时钟周期,但每周期都能启动一个新的加法。吞吐量是原来的4倍。在卷积加速器里,我们通常用这种设计来做乘累加运算。
我的习惯:做流水线设计时,我会在每级之间加valid信号做握手。这样即使某级延迟变化,也不会丢数据。另外,复位逻辑一定要写全,否则仿真时会出现X态,排查起来很痛苦。
加法器在卷积加速器中的典型应用
卷积运算的核心就是乘累加(MAC)。一个3x3的卷积核,需要做9次乘法和8次加法。如果直接用"+"号,综合工具会帮你选最优结构。但如果你想手动优化,可以这样:
// 卷积核的加法树(3x3)
module adder_tree (
input [7:0] p0, p1, p2, p3, p4, p5, p6, p7, p8, // 9个乘法结果
output [11:0] result
);
wire [8:0] sum1, sum2, sum3, sum4;
wire [9:0] sum5, sum6;
wire [10:0] sum7;
// 第1级:两两相加
assign sum1 = p0 + p1;
assign sum2 = p2 + p3;
assign sum3 = p4 + p5;
assign sum4 = p6 + p7;
// 第2级:继续合并
assign sum5 = sum1 + sum2;
assign sum6 = sum3 + sum4;
// 第3级:加上最后一个数
assign sum7 = sum5 + sum6;
// 第4级:加上p8
assign result = sum7 + p8;
endmodule
这种加法树结构,延迟是log2(N)级,比串行加法快得多。我在做YOLO的卷积层加速时,就是用这种树形结构,配合流水线,实现了每时钟周期输出一个卷积结果。
关键总结:
- 小位宽(≤8位)用行波进位,简单省面积
- 中位宽(8-32位)用超前进位,平衡速度和面积
- 高位宽(>32位)或高频场景,必须上流水线
- 卷积加速器里,加法树+流水线是标配
好了,加法器这块就聊到这儿。记住:没有最好的加法器,只有最适合你项目的加法器。下次遇到时序问题,先别急着调流水线,看看是不是加法器选型出了问题。
可可精品资料库