3. RISC-V浮点指令集架构:F扩展详解
好,咱们今天聊聊RISC-V的浮点指令集。说实话,浮点运算单元(FPU)的设计,是很多芯片工程师的“噩梦”,也是“试金石”。我个人觉得,搞懂指令集架构,是设计好FPU的第一步。
RISC-V的浮点扩展,从F(单精度)到D(双精度),再到Q(四精度),是一套非常优雅的递进设计。咱们这节课,就重点啃下F扩展这块硬骨头。
3.1 F扩展:单精度浮点的基础
F扩展,说白了就是给RISC-V内核装上处理32位浮点数的能力。它定义了一套完整的指令集,包括加载/存储、算术运算、比较和类型转换。
为什么先讲F扩展? 因为它是基础。D扩展和Q扩展,很多设计思路都是从F扩展衍生出来的。我在做第一个RISC-V项目时,就是先实现了F扩展,然后才去碰D扩展。嗯,这个顺序很重要。
3.2 浮点加载与存储指令
浮点运算的第一步,是把数据从内存搬到寄存器里。RISC-V的浮点寄存器组叫f0-f31,一共32个。
加载指令就两条:
FLW:加载单精度浮点数(32位)FSW:存储单精度浮点数(32位)
它们的格式和整数加载/存储非常像。比如:
flw f1, 0(x10) # 从内存地址x10+0处加载一个float到f1
fsw f1, 4(x11) # 将f1的值存储到内存地址x11+4处
这里有个坑,我踩过。 浮点加载/存储的地址必须对齐到4字节。如果你给了一个未对齐的地址,硬件会触发异常。我曾经在调试一个DSP算法时,因为结构体成员对齐问题,导致FLW指令频繁报错,查了两天才发现是地址没对齐。所以,设计FPU时,地址对齐检查逻辑一定要做扎实。
3.3 浮点算术指令
这是F扩展的核心。RISC-V提供了完整的单精度浮点算术指令集:
| 指令 | 功能 | 格式 |
|---|---|---|
FADD.S |
浮点加法 | fadd.s fd, fs1, fs2 |
FSUB.S |
浮点减法 | fsub.s fd, fs1, fs2 |
FMUL.S |
浮点乘法 | fmul.s fd, fs1, fs2 |
FDIV.S |
浮点除法 | fdiv.s fd, fs1, fs2 |
FSQRT.S |
浮点平方根 | fsqrt.s fd, fs1 |
FMIN.S / FMAX.S |
最小值/最大值 | fmin.s fd, fs1, fs2 |
FMADD.S / FMSUB.S |
乘加/乘减 | fmadd.s fd, fs1, fs2, fs3 |
你想想看,这些指令里,FDIV.S和FSQRT.S是最难设计的。为什么?因为它们需要迭代算法,硬件开销大,延迟也长。我建议在设计FPU时,优先把加法器和乘法器做扎实,除法和平方根可以用迭代单元或者查找表来实现。
乘加指令(FMADD)是个好东西。 它执行 fd = fs1 * fs2 + fs3,而且只做一次舍入。这在矩阵运算和信号处理中非常有用,精度比先乘后加要高。我在做神经网络加速器时,大量使用了FMADD指令,性能提升很明显。
3.4 浮点比较指令
浮点比较,和整数比较不太一样。因为浮点数有NaN(Not a Number)这种特殊值。RISC-V提供了以下比较指令:
FEQ.S:等于(如果任一操作数是NaN,返回false)FLT.S:小于(如果任一操作数是NaN,返回false)FLE.S:小于等于(如果任一操作数是NaN,返回false)
这些指令的结果是一个布尔值,写入整数寄存器rd。比如:
flt.s x5, f1, f2 # 如果f1 < f2,x5 = 1,否则x5 = 0
NaN的处理,是个容易出错的地方。 我记得有一次,一个同事写的浮点比较代码,没有考虑NaN的情况,结果在数据异常时,程序逻辑完全乱掉了。所以,设计比较器时,一定要先检查操作数是不是NaN。如果是,直接返回false(对于FEQ/FLT/FLE)。
FCLASS指令先检查操作数的类型,然后再做比较。
3.5 浮点转换指令
类型转换,是浮点单元里很常见的操作。F扩展提供了以下转换指令:
| 指令 | 功能 |
|---|---|
FCVT.W.S |
单精度浮点转32位有符号整数 |
FCVT.WU.S |
单精度浮点转32位无符号整数 |
FCVT.S.W |
32位有符号整数转单精度浮点 |
FCVT.S.WU |
32位无符号整数转单精度浮点 |
这些指令的格式是:
fcvt.w.s x5, f1 # 将f1中的float转换为int,存入x5
fcvt.s.w f1, x5 # 将x5中的int转换为float,存入f1
转换时,舍入模式同样重要。 比如,把3.14转换成整数,是截断成3,还是四舍五入成3?这取决于当前的舍入模式。我建议在设计转换单元时,把舍入逻辑做成可配置的,这样软件可以根据需要灵活选择。
3.6 D扩展与Q扩展的差异
好,咱们聊聊D扩展和Q扩展。它们和F扩展有什么不同?说白了,就是精度和位宽的区别。
- F扩展:32位单精度(float)
- D扩展:64位双精度(double)
- Q扩展:128位四精度(quad)
指令格式上,几乎一模一样。 只是操作码和寄存器宽度不同。比如:
- F扩展用
FADD.S,D扩展用FADD.D,Q扩展用FADD.Q - 加载指令:
FLW(32位)、FLD(64位)、FLQ(128位)
硬件设计上的差异,才是关键。
第一,数据通路宽度。D扩展需要64位的加法器、乘法器,Q扩展需要128位的。这直接影响了芯片的面积和功耗。我在做D扩展时,加法器面积比F扩展大了将近4倍。
第二,寄存器文件。F扩展的寄存器是32位宽,D扩展是64位宽。但RISC-V规定,D扩展复用了F扩展的寄存器组(f0-f31),只是每个寄存器变成了64位。这意味着,如果你实现了D扩展,F扩展的指令也能用这些64位寄存器,只是只用了低32位。
第三,延迟和吞吐量。精度越高,计算延迟越长。一个64位的浮点乘法器,延迟通常比32位的高1-2个周期。Q扩展就更夸张了,128位的乘法器,延迟可能达到10个周期以上。
3.7 我的设计经验总结
讲了这么多,我总结几点个人经验:
- 先实现F扩展,再扩展D和Q。 因为F扩展的硬件复杂度最低,适合用来验证你的FPU架构是否正确。
- 注意特殊值的处理。 NaN、无穷大、零、非规格化数,这些都要在指令执行时正确处理。我曾经因为非规格化数处理不当,导致一个科学计算程序结果完全错误。
- 舍入模式一定要实现完整。 很多软件依赖特定的舍入行为,如果只实现了默认的RNE,可能会出问题。
- 性能优化要有的放矢。 加法器和乘法器是热点,优先优化它们的延迟和吞吐量。除法和平方根,可以用迭代单元,面积小,但延迟长。
好了,F扩展的内容就讲到这里。下一节,我们会深入D扩展的设计细节,看看64位浮点运算到底有哪些挑战。记住,搞懂F扩展,D和Q就是水到渠成的事。