如果你在某颗SoC里调过浮点加法单元,大概率见过这种时序报告:整条路径算到尾数加法器只要1.4ns,后面跟着的前导零计数器(LZC)又吃掉0.9ns,最后再过一个桶形移位器,时钟频率死活压不回目标。这时候第一反应是加流水级,把浮点加拆成三段甚至四段,但段数越多,旁路和转发逻辑越贵。真正懂行的人会在加法器旁边放一个前导0预测器(Leading-Zero Anticipator,LZA),让它和加法器并行把规格化要用的移位量提前算出来。LZA不是不做前导零检测,而是把检测从“结果产生之后”挪到“结果产生之前”,用A、B这两个输入直接猜结果的前导0个数,猜错了再用修正电路兜底。这篇就把LZA的原理、误差来源、RTL实现和实际排坑经验一次讲透。
1. 把“数完0再移位”从关键路径上拿掉——浮点加法的规格化困境
1.1 一次浮点加法到底发生了什么
先看标准浮点加法流程,以FP32为例:指数对齐、尾数对阶、尾数加减、规格化、舍入、异常处理。其中指数对齐需要把较小指数的尾数右移,然后两尾数相加/减。最关键的是后面的规格化步骤:尾数结果可能变成 0.001xxx… 这样的形式,必须左移若干位,把最高有效位恢复到整数位,同时指数相应减小。
这条“左移若干位”的操作,依赖前导0数量。传统做法是等加法器输出sum之后,用一个前导零计数器(Leading Zero Counter,LZC)从最高位开始扫描,数出连续0的个数,再用这个数值驱动桶形移位器。
问题就出在这个串行依赖上。关键路径是:
指数对齐 -> 尾数加法器 -> 前导0扫描 -> 桶形移位 -> 舍入每个箭头都是一整级电路延迟。尾数加法器本身已经有进位链要等,后面再接一个逐位扫描的LZC,整个路径就很难收时序。
1.2 传统LZC的延迟构成
传统LZC有两种常见实现:逐位扫描和树形扫描。逐位扫描最直观,但延迟和位宽成正比,24位尾数就要24级门。树形扫描用二叉树归并,可以把延迟压到约O(log n),但树形结构本身也有5到6级门延迟,而且它必须等加法器的进位链完全稳定后才能开始。
我整理过一个FPGA上的延迟估算,以24位尾数为例:
| 部件 | 大致门延迟 | 说明 |
|---|---|---|
| 尾数加法器进位链 | 8~12 | 取决于进位结构 |
| LZC树形扫描 | 5~8 | 等sum稳定后开始 |
| 桶形移位器 | 3~4 | 取决于实现 |
| 规格化总附加延迟 | 8~12 | 几乎和加法器本身一样长 |
所以传统规格化路径等于“加法器+LZC+移位器”三段串起来。加法器省不掉的,桶形移位也省不掉的,唯一能动的就是LZC这一段。LZA的思路就是把这5~8级门延迟从关键路径里抽出去,让它和加法器并行跑。
1.3 LZA的定位:不取消规格化,只是把它提前
LZA不是不数前导0,而是在加法器还没算出sum的时候,基于A和B提前预测出前导0数量。它和加法器共享输入,独立计算,输出一个预测移位量。等到加法器sum出来时,桶形移位器已经知道该移多少了。
这样摆出来的关键路径变成:
指数对齐 -> 尾数加法器 / LZA(并行) -> 桶形移位 -> 舍入LZA和加法器同时开始,谁晚等谁。LZA的优势在于它不依赖进位链稳定,通常比加法器早一点或同时出结果,于是最长的路径就是“指数对齐 -> 加法器 -> 移位器”。5~8级门延迟就这样被隐藏了。
2. 三位状态机思维:G/P/Z如何让LZA并行起来
2.1 为什么要用输入而不是结果做判断
LZA的核心洞察是:前导0出现在哪里,其实由A和B每一位的“和”状态决定,而这个状态在加法之前就可以分类。对每对输入位(A[i], B[i]),我们只关心它在加法中的行为,不关心具体数值。
这里用三个记号:
- G(generate):A[i]=1且B[i]=1,这一位一定会产生进位。
- P(propagate):A[i]≠B[i],这一位会传播低位的进位。
- Z(zero):A[i]=0且B[i]=0,这一位吸收进位,加法结果为0或由进位决定的1。
用一张表看得很清楚:
| 输入组合 | 记号 | 加法行为 | 减法中的行为(A-B) |
|---|---|---|---|
| 1,1 | G | 产生进位 | 传播借位 |
| 0,1 / 1,0 | P | 传播进位 | 生成或吸收借位,看方向 |
| 0,0 | Z | 吸收进位 | 传播借位 |
P这个状态在加法里很特殊:它本身不产生进位,但会把从低位传来的进位原样传到高位。一串P位就像一排多米诺骨牌,只要最下面有一个进位,它能一路推上去,直到碰到Z或者G才停下。G会锁住进位并继续产生新进位,Z则直接切断进位链。
2.2 前导1就出现在进位链的“断点”附近
现在把A、B变成一串G/P/Z符号。举例,A=00100(4),B=00110(6),做加法得10,也就是01010。从最高位开始标符号:
- bit4:0+0 -> Z
- bit3:0+0 -> Z
- bit2:1+0 -> P
- bit1:0+1 -> P
- bit0:0+0 -> Z
符号串是 Z Z P P Z。P位会传播进位,而实际SUM是 0 1 0 1 0,最高有效位在bit3,恰好是最高非Z位。再看一个例子,A=00100,B=00100,和是8,即01000。符号串是 Z Z G Z Z。G位产生进位,结果最高位跑到了bit3,也就是最高非Z位上方的那个Z位。
这就是LZA要抓的规律:结果的最高有效位不出现在“最高非Z位”本身,就出现在它高一位。对于加法,这个观察基本成立。因为浮点尾数对阶后都在[1,2)区间(含隐藏位),两数相加不会超过4,所以进位链最多跨过最高非Z位后再多推一位。
2.3 减法是另一个世界:借位链的端点更隐蔽
减法方向就复杂得多。考虑A=10000(16),B=01111(15),差是1。A、B的最高非Z位是bit4,但结果的有效位是bit0,两者差了整整3位。这种情况在IEEE浮点数里叫灾难性消除,是LZA最容易翻车的地方。
减法的本质是A + (~B) + 1。把B取反后,原来的G变成Z,原来的Z变成G,P保持不变,再加1会产生一个最低位的进位。这时候要找的是借位链的断点,不是进位链的断点。业界处理方式基本都建立在模式匹配上:在G/P/Z符号串里搜索形如GZ、ZG、PG、PZ这样的边界模式,每个边界模式都对应一个可能的最高有效位位置。
不同论文对模式集合的写法不完全一样。Hokenek和Montoye那篇经典文章用双扫描法处理加减法,Schmookler和Nowka后来做过一版统一算法,把加法和减法的模式统一成一张表。我们在工程实现时,不需要背公式,但心里要清楚:加法靠最高非Z位加修正就够了,减法必须靠完整的模式集合,缺一个模式就可能在灾难性消除场景下输错预测。
2.4 为什么模式匹配比扫描快
LZA的延迟不依赖进位链传播,所有bit的G/P/Z分类是纯组合逻辑,一个周期内全部算完。模式检测和优先编码也都是树形结构,延迟大约只有LZC的一半到三分之二。更关键的是,它不需要等加法器的sum稳定,可以和加法器同一拍启动,所以整个规格化路径的串行延迟就变成了“max(加法器,LZA) + 移位器”,而不是“加法器 + LZC + 移位器”。
3. 一字误差:LZA的“差不多”为什么恰好够用
3.1 一个误差实例
先看A=00100(4),B=00100(4),SUM=01000(8)。A和B在bit2都是1,所以G位在这里,最高非Z位就是bit2。如果直接用最高非Z位预测前导0数量,会得到前导0=2(因为有3个高位0,按27位尾数算的话是另外一回事),但实际SUM=01000的最高有效位在bit3,前导0数量等于2加上了吗?这里我们用小位宽感受概念:预测位置是bit2,实际首位在bit3,相差1位。
这类误差是LZA的固有属性。它不精确预测,而是允许预测结果和真实前导0数量差1位。业界把这个叫one-bit error,几乎所有LZA设计都会面对。
3.2 误差到底从哪里来
误差来自进位链的传播长度。最高非Z位可能是G,G位加法结果为0并产生进位,这个进位会让高一位的Z变成1。如果高一位还是P,进位还会继续往上推。幸运的是,浮点尾数加法中两个有效数都在[1,2),和小于4,所以进位链最多推过最高非Z位后的一位就会停下来。这就是为什么误差上限是1,而不是3或4。
减法方向不一样。灾难性消除时,两个接近的数值相减,结果的有效位可能比A、B的最高位低很多,这时候靠“最高非Z位+1”的预测会错得离谱,必须回到3.3里说的模式匹配,从借位链端点去找真实位置。
3.3 修正电路的设计:后修正与双候选
两种主流修正方案,一种是后修正,一种是双候选。
后修正的做法是:先用预测移位量左移sum,检测移位结果最高位。如果最高位不是1,说明预测少了1位,再左移1位,指数同步减1。伪代码:
shifted = sum << pred_shift; if (~shifted[N-1]) begin shifted = shifted << 1; pred_shift = pred_shift + 1; end // 指数更新 exp_result = exp_base - pred_shift;后修正的好处是电路简单,只需额外一个1位移位器和1个2:1选择器。坏处是它要求预测误差严格≤1,否则修正不回来。双候选的做法是并行准备两个桶形移位器,一个按shift预测移,一个按shift+1移,最后根据sum的结果选。面积翻倍但可以省掉修正的那一拍,适合对频率极敏感的流水线。
| 方案 | 面积开销 | 时序改善 | 适用场景 |
|---|---|---|---|
| 后修正 | 低 | 中等 | 一般FPU设计 |
| 双候选 | 高 | 更高 | 高频ASIC流水线 |
3.4 为什么只修1位就够
因为LZA的设计目标就是保证预测误差≤1。加法方向靠“最高非Z位+1”这个事实保证,减法方向靠完整的模式表保证。换句话说,真正的LZA不是在“猜”,而是在所有可能的首位候选点里挑一个,并确保真实首位就在这个候选点或它的高一位。
修正电路因此不需要做循环,也不需要做“直到最高位为1”的迭代,一个1位桶形移位器加一个指数减法器就解决问题。这也是LZA和“随便估一个前导0数再慢慢修”的本职区别。
4. 落到RTL:教学版LZA的结构与代码
4.1 先定义位宽和接口
在FP32里,尾数有效位24位(含隐藏位),为了处理对阶误差、保护位、舍入位,实际尾数运算通常做到27位或28位。这里我用N=27举例,最高位保留给可能产生的进位。
接口包括:
- A、B:27位操作数
- sub:加减选择
- pred_shift:预测的前导0数量
- 三个派生信号g/p/z,方便外部调试
4.2 第一步:并行产生G/P/Z
module lza_teach #( parameter int N = 27 )( input logic [N-1:0] a, input logic [N-1:0] b, input logic sub, output logic [$clog2(N)-1:0] pred_shift, output logic [N-1:0] g, output logic [N-1:0] p, output logic [N-1:0] z ); assign g = a & b; assign p = a ^ b; assign z = ~(a | b);这一步没有任何数据依赖,每位的g/p/z同时算出来,延迟就是一两级门。这是LZA能并行起来的第一块基石。
4.3 第二步:加法方向的预测
加法方向可以用最高非Z位做预测。找最高非Z位的逻辑非常简单,一个从高到低的循环加优先编码器:
always_comb begin pred_shift = '0; if (!sub) begin for (int i = N-1; i >= 0; i--) begin if (a[i] | b[i]) begin pred_shift = N - 1 - i; break; end end end这段代码在加法方向配合后修正电路是功能正确的,因为真实首位只可能在最高非Z位或它的高一位。减法方向就不行了,注释里特意留了扩展位,真要做减法必须往下走。
4.4 减法方向的模式匹配思路
减法方向不能再用最高非Z位。业界做法是在g/p/z串上找模式端点,再把这些端点位编码成移位量。我在这里不展开完整的模式表,因为不同论文覆盖的模式集合有细微差别,直接抄容易在边界case上出问题。但思路可以写出来:把减法变成 A + (~B) + 1,然后对 A 和 ~B 重新生成 g/p/z,这时候原先的“最高非Z位”思维要丢到一边,改而寻找类似“G后面跟一串P再碰到Z”这样的断点结构。
实际实现最稳妥的方式,是先用SystemVerilog把每个端点模式写成独立的连续赋值,然后用一个优先编码器选最高位。比用一个又长又不直观的for循环要容易查错。
4.5 后修正电路和指数联动
预测移位量出来后,外面配的修正电路长这样:
// pipeline 外部:假设 sum 已经由加法器算出 logic [N-1:0] shifted; logic corr; shifted = sum << pred_shift; if (~shifted[N-1]) begin shifted = shifted << 1; corr = 1'b1; end else begin corr = 1'b0; end // 指数更新:以对阶后的大指数为基准 exp_result = exp_base - pred_shift - corr;这里有个细节:修正位的指数补偿必须和pred_shift同步。如果pred_shift是组合逻辑直接给指数加法器的,corr也必须是同一拍组合产生,否则流水线上指数和尾数就错拍了。输出里那个g/z信号就是为了让验证环境能直接监视模式是否命中的。
4.6 建议的测试向量
LZA的验证不能只靠随机数。我通常会把测试向量分成三类:常规加法、边界进位、灾难性消除。
常规加法:A=1.5×2^k,B=0.75×2^k,检查预测移位量是否在真实前导0周围。 边界进位:A、B的有效位全为1,比如A=1.111...1,B=1.000...1,和会溢出到最高位,这时候最高非Z位预测会偏1位,验证修正电路是否接管。 灾难性消除:A=1.000...0,B=0.111...1111,差非常小,前导0数量很大,减法方向的模式匹配必须在这种向量下仍然命中。
我习惯在随机仿真里加一个断言,检查预测误差是否≤1:
assert(final_shift - true_shift inside {0, 1})一旦预测误差超过1,立刻报错,完全不等到修正电路阶段。
5. 放进流水线与实测对比:LZA到底省了什么
5.1 三级流水线的经典划分
浮点加法单元做成三级流水时,常见划分是:
- EX1:指数比较、尾数对阶右移
- EX2:尾数加法器 + LZA并行
- EX3:桶形移位、规格化、舍入
LZA放在EX2,和加法器同拍启动。加法器的sum在EX2末稳定,LZA的pred_shift也在EX2末稳定。EX3只需要把sum和pred_shift送进桶形移位器,不存在额外的“等LZC扫描”阶段。
5.2 延迟对比
我用过一个27位浮点加法单元做对比,传统方案“加法器进位链+LZC+移位器”的规格化路径大约占了整个周期的60%以上。换成LZA+后修正后,EX3里只有桶形移位器和舍入,整个规格化路径的附加延迟几乎减半。
| 方案 | 规格化路径总延迟 | 典型频率提升 |
|---|---|---|
| 加法器+LZC+移位器 | 15~20级门 | 基准 |
| 加法器+LZA+后修正 | 9~12级门 | 约20%~30% |
| 加法器+双候选LZA | 8~10级门 | 约25%~35% |
这个数据取决于具体位宽和工艺,但趋势很稳定:LZA省掉的是串行LZC那一段,而后修正电路只加回一小段。
5.3 面积和功耗代价
LZA不是一个免费的魔法。G/P/Z生成很便宜,但模式匹配和优先编码器要消耗组合逻辑。在FPGA上,一个27位LZA大约多消耗100~200个LUT,相对整个浮点加法单元大概10%~20%的面积开销。
功耗方面,LZA和加法器并行跑意味着每一拍都要翻转一堆中间信号,动态功耗会有轻微上升。省下的是因关键路径太长而不得不多打一级流水导致的大量寄存器开销。在高性能场景,这笔账通常是划算的。
5.4 与CLZ指令和FMA的关系
CPU指令集里的CLZ、LZCNT这类指令也是数前导0,但它们是等结果出来再扫描,本质是LZC。LZA是预测器,不依赖结果。两者可以在一个芯片里共用优先编码器之类的部件,但设计目标完全不同。
FMA(乘加)单元里也有类似问题:乘法结果和累加器要做一次大位宽加法,规格化同样需要前导0信息。FMA的累加加法器往往比普通浮点加更宽,LZA同样合适,只是G/P/Z的位宽要扩大到FMA中间结果的位宽,模式表也要相应调整。
6. 我踩过的坑与最后的排查建议
6.1 坑一:对阶后的补位补错
LZA的输入是对阶后的尾数,不是原始尾数。对阶时较小指数的尾数要右移,移出来的高位补什么取决于符号。加法补0,减法先取反再补1。我曾在减法场景直接把B取反后右移补0,结果G/P/Z串在高位多出一串错误G,LZA预测的前导0少了好几位。后来把所有减法都在进入LZA之前统一转成“补码加法视角”,补位规则固定为补1,才把这个坑填平。
6.2 坑二:修正电路忘记同步调指数
有一版设计验证时发现LZA预测数值总比真实值大1,查了很久,最后发现修正电路检测到最高位是0后只把尾数左移了1位,忘记对指数做-1修正。浮点结果直接翻倍,综合器也不会报错。这种bug最适合用断言抓,直接断言指数变化量等于左移量。
6.3 坑三:只靠随机向量验证
随机向量很多年都测不出问题,因为大部分随机数相加的有效位位置都差不多,灾难性消除被覆盖的概率太低。后来加入了定向向量,专门构造A和B的高位全相同、低位全相反的case,LZA的减法模式缺陷立刻暴露。现在我的验证脚本里保留了一组极端消除向量,每次跑回归都带上。
6.4 一个实用的debug流程
如果预测误差超过1,我的排查顺序是这样的:先看G/P/Z三串是否正确,再看模式匹配有没有覆盖到真实首位附近的端点,然后确认优先编码器的优先级方向有没有写反,最后检查修正电路的指数补偿。这三个信号是LZA的全部逻辑,拆开看比看综合网表快得多。
另外一个经验是:在LZA模块里把g、p、z全部引到调试接口,仿真时波形里直接看这些信号和sum的对照关系,比猜内部逻辑高效很多。我自己后来做任何一个带LZA的浮点单元,都会保留这套调试引脚,省下的调试时间绝对值得那点管脚开销。