Github仓库(含源.v文件和Tb用python文件):Liat190906012/FP16_adder: FP16的加法器的数字电路实现(Verilog)
# FP16的加法器的数字电路实现(Verilog)
## 项目背景
AI推理框架的运算量化已经成为不可避免的趋势,量化牺牲了部分浮点数的精度换来了更低的成本和更高的访存效率。从FP32到FP16、BF16再到FP8和NF4,此类运算单元在DSP和张量、卷积的方面应用极多,而且多种不同精度的MAC的配合也成为流行。
现在对目前的项目做一个简单的记录,本工程的短期目标便是实现FP8的加乘器,并保留一定的精度。在Vibe的帮助下,无论是电路Verilog的搭建还是TB都进行得很快。
目前实现的是FP16的加法器,虽说乘法器还未实现,不过其思路过程基本一样,之后完成TB后再做记录。此加法器是6步流水线的形式,可以保证一定吞吐量(取决于主频),**第0步和第5步的流水更像是做data buffer,并接受输入和向外输出**。
## 核心思路:统一步长的整数幅值计算
本加法器的核心思路是幅值计算,将两个输入数统一缩放写成两个高位自然数,然后计算得到结果后,再缩回正常的级数。先把两个尾数左移相应的位数得到sig_a/b,再进行指数对齐。左移的位数计算公式如下:
```text
value = sig_a/b × 2^(Exp_a/b − 25)
```
其中,指数偏置为15,位数偏置为10,加起来是25,`sig_a`是尾数加上隐藏位后得到的11位小数,就是`1.zzzzzzzzzz`。接下来进行指数对齐。
两个浮点数相加,把所有数统一换算成“以`2⁻²⁴`为最小步长”的整数——即每个数表示成`整数 × 2⁻²⁴`。这样两个数一相加,就是两个普通整数相加,小数点天然对齐。
换算方法就是除以步长`2⁻²⁴`:
```text
value / 2⁻²⁴
= sig_a/b × 2^(Exp_a/b − 25) × 2²⁴
= sig_a/b × 2^(Exp_a/b − 1)
```
而`Exp_a/b`在`[1, 30]`中,所以`Exp_a/b − 1`在`[0, 29]`,最大的位数拓展就是29了。
次正规数的情况就会更加简单,次正规数不存在隐藏位,所以它不需要进行左移。不过为了统一运算,还是需要进行29位的位拓展。
本运算器采用的流水线模式,所以变量的赋值均为非堵塞幅值。
## 接口
```text
输入:a[15:0], b[15:0], sub[0], valid_in[0]
输出:result[15:0], valid_out[0], Overflow[0], Underflow[0]
```
## 第1步:拆包、分类与尾数统一
拆出符号位、指数位、尾数位:`Sign[15]`、`Exp[14:10]`、`Frac[9:0]`。这里是直接设置的reg变量来存储。
```
reg az0,bz0;
reg [4:0] ea0,eb0; reg [9:0] fa0,fb0;
……
sa0<=a[15]; sb0<=b[15];
ea0<=a[14:10]; eb0<=b[14:10]; fa0<=a[9:0]; fb0<=b[9:0];
```
拆分后会对指数位`ea0 [4:0], eb0[4:0]` 判断:输入数中是否存在非正规数?(NaN、Inf、Zero)这些数会统一存进`special_results[15:0]`,不参与之后的运算,并随流水线自然递进直到输出。
```
指数字段 | 类别
00000 | 零或次正规数
00001 ~ 11110 | 正规规格数
11111 | 无穷/NaN
```
```
规格数:指数从 00001 开始,最小规格数的真实指数是 1 − 15 = −14,有效数字是 1.f(有隐藏位 1)。
次正规数:指数是 00000,但真实指数被特意钉死为 −14(而不是按公式 0 − 15 = −15),同时有效数字变成 0.f(去掉隐藏位 1)。
```
次正规数和正规数会单独提出尾数位`[9:0]`,加上隐藏位后得到`[10:0]`(正规数为1,次正规数为0)。然后位拓展:
```verilog
mag_a/b[39:0] = { {29{1'b0}}, Sig_a/b[10:0] } //Sig_a和Sig_b都要进行位拓展然后再进行指数对齐。
```
这里拓展29位是为了容纳FP16的完整指数范围,详细见前文幅值计算。拓展后,进行步长对齐(指数对齐),左移`Exp_a/b − 1`,这样两个数已被处理成40位宽、步长`2⁻²⁴`的整数幅值,可以直接加减了。此步流水完成后将进入下一步。
```
siga = (ea == 0) ? {1'b0, fa} : {1'b1, fa};
sigb = (eb == 0) ? {1'b0, fb} : {1'b1, fb};
shift_a = (ea == 0) ? 0 : ea - 1'b1;
shift_b = (eb == 0) ? 0 : eb - 1'b1;
ma = {29'b0, siga} << shift_a;
mb = {29'b0, sigb} << shift_b;
```
## 第2步:幅值和符号加减
第一步中,我们得到了两个数的幅值和两个数的符号位`Sign_a/b[0]`,再结合输入的操作数`sub[0]`,可以得到最终的加/减指令。
在减法中,本加法器采用的是先比较幅值,然后大减小并确定符号位。幅值经过计算后可以得到幅值结果`mag2[40:0]`,注意到这里多拓展了一位,是因为作加法时可能会产生一位进位。
幅值结果`mag2[40:0]`和符号结果`sign2[0]`会传入下一道流水线。
```
if (sign_a1 == sign_b1) begin
mag2 <= {1'b0, mag_a1} + {1'b0, mag_b1};
sign2 <= sign_a1;
end else if (mag_a1 >= mag_b1) begin
mag2 <= {1'b0, mag_a1} - {1'b0, mag_b1};
sign2 <= sign_a1;
end else begin
mag2 <= {1'b0, mag_b1} - {1'b0, mag_a1};
sign2 <= sign_b1;
end
```
## 第3步:规格化、指数计算、GRS提取
首先是寻找最高有效位,根据这一步可以找到最终的指数值,本工程采取的是前导零计数。最高位的值会被存储进`msb[5:0]`。
```
if (!special2 && (mag2 != 0)) begin
msb = 0;
found = 1'b0;
for (i=40; i>=0; i=i-1) begin
if (!found && mag2[i]) begin
msb = i;
found = 1'b1;
end
end
```
此时对幅值`mag3[40:0]`进行左移,左移一共14位存进`ext[13:0]`,`ext[13:3]`是有效尾数,`ext[2:0]`是舍入判断位。
同时,在得到了`msb[5:0]`后,对指数进入计算,指数公式为:
```text
exp[5:0] = msb − 9
```
`msb`减去步长24再加上指数偏置15得到上述结果。`ext[13:0]`和`exp[5:0]`会进入到下一步的舍入及拼数。
## 第4步:舍入、异常判断、FP16打包
```
ext3: [13] [12..3] [2] [1] [0]
隐藏位 尾数10位 G R S
```
```verilog
round_up = G && (R || S || LSB);
```
```
if (ext3[2] && (ext3[1] || ext3[0] || ext3[3]))
rounded = rounded + 1'b1;
```
保留11 bit尾数,`rounded[11:0]`,GRS即为`ext[13:0]`的末三位。
此公式的核心逻辑便是遵循FP16的舍入规则,就近进位,大于0.5向上进位,小于0.5保留不进位,恰好为0.5时将选择偶数为最终结果(`LSB == 0`)。
接下来是判断舍入是否产生进位,如果是,便右移一位、指数加一;指数是否产生溢出,若溢出,上溢设置为Inf并记录`overflow`,下溢则次正规数,甚者则处理为带符号0(`+0/-0`)并记录`underflow`。
上述完成后,统一打包符号位、指数位、尾数位,流入下一级输出。
## 六级流水线的Dataflow
上述四步是实际处理步骤,寄存器变量的流动dataflow如下。
```text
输入
a[15:0] b[15:0] sub[0:0] valid_in[0:0]
│
▼
┌───────────────────────────────────────────────┐
│ Stage 0:输入锁存 │
│ a0[15:0] b0[15:0] │
│ sub0[0:0] v0[0:0] │
└───────────────────────┬───────────────────────┘
▼
┌───────────────────────────────────────────────┐
│ Stage 1:拆包、分类、尾数统一 │
│ sign_a1/sign_b1 1 bit │
│ ea/eb 5 bit │
│ fa/fb 10 bit │
│ siga/sigb 11 bit │
│ mag_a1/mag_b1 40 bit │
│ special1 1 bit │
│ special_result1 16 bit │
│ invalid1 1 bit │
└───────────────────────┬───────────────────────┘
▼
┌───────────────────────────────────────────────┐
│ Stage 2:符号-幅值加减 │
│ mag2 41 bit │
│ sign2 1 bit │
│ special2 1 bit │
│ special_result2 16 bit │
│ invalid2 1 bit │
└───────────────────────┬───────────────────────┘
▼
┌───────────────────────────────────────────────┐
│ Stage 3:规格化、指数计算、GRS提取 │
│ sign3 1 bit │
│ exp3 6 bit │
│ ext3 14 bit │
│ exact_subnormal3 1 bit │
│ special3 1 bit │
│ special_result3 16 bit │
│ invalid3 1 bit │
└───────────────────────┬───────────────────────┘
▼
┌───────────────────────────────────────────────┐
│ Stage 4:舍入、溢出判断、FP16打包 │
│ rounded 12 bit │
│ result4 16 bit │
│ overflow4 1 bit │
│ underflow4 1 bit │
│ invalid4 1 bit │
│ v4 1 bit │
└───────────────────────┬───────────────────────┘
▼
┌───────────────────────────────────────────────┐
│ Stage 5:最终输出寄存器 │
│ result 16 bit │
│ valid_out 1 bit │
│ overflow 1 bit │
│ underflow 1 bit │
│ invalid 1 bit │
└───────────────────────────────────────────────┘
```
## 关于GRS的个人理解
再说一下GRS,笔者自认为就是多保留三位而已,只是这恰好有个相关理论,笔者也只是简单询问了AI,没有做相关的理论文献查找,可能会有认知偏差(?)。
在和AI的相关讨论中,笔者也问了相关的问题:保留两位是否就可以?AI给出的答案是,在实际硬件的移位器中,保留两位还是三位的硬件资源成本相差不大,而且本身这三位也是只保留一步流水线,不占资源,更不会占推理框架的访存资源,所以这里也选择了这个相对折中的方法。
## GRS的官方解释
GRS的全称是`"Guard保护位""Round舍入位""Sticky粘位"`。
GRS是IEEE 754 舍入机制的经典概念。GRS 位不是 IEEE 754 标准里用"GRS"这个词命名的,但舍入时需要"额外位"(extra bits)来判断丢弃部分与 0.5 的关系这个机制,是标准明确规定的。标准原文讲的是"round to nearest"需要知道被丢掉的尾数是否超过一半,而这在实现上就对应 GRS 三位。
标准文档:IEEE 754-2019(最新修订版,替代 754-2008)
关键章节:4.3 Rounding 和 4.3.1 Round to nearest
https://standards.ieee.org/standard/754-2019.html
Goldberg 论文:
https://docs.oracle.com/cd/E19957-01/806-3568/ncg_goldberg.html