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时,地址对齐检查逻辑一定要做扎实。

警告: 浮点加载/存储指令的地址必须4字节对齐。未对齐访问会触发异常,这是RISC-V规范明确要求的。

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.SFSQRT.S是最难设计的。为什么?因为它们需要迭代算法,硬件开销大,延迟也长。我建议在设计FPU时,优先把加法器和乘法器做扎实,除法和平方根可以用迭代单元或者查找表来实现。

乘加指令(FMADD)是个好东西。 它执行 fd = fs1 * fs2 + fs3,而且只做一次舍入。这在矩阵运算和信号处理中非常有用,精度比先乘后加要高。我在做神经网络加速器时,大量使用了FMADD指令,性能提升很明显。

重点: 浮点算术指令的结果,会受到舍入模式(Rounding Mode)的影响。RISC-V支持四种舍入模式:RNE(就近舍入)、RTZ(向零舍入)、RDN(向下舍入)、RUP(向上舍入)。设计FPU时,必须实现这四种模式。

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)。

小技巧: 如果你需要“无序比较”(即NaN也算作满足条件),可以用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个周期以上。

设计建议: 如果你的目标应用是AI推理或图形处理,F扩展就足够了。如果是科学计算或金融分析,D扩展是必须的。Q扩展目前用得很少,主要用于一些高精度物理模拟。

3.7 我的设计经验总结

讲了这么多,我总结几点个人经验:

  1. 先实现F扩展,再扩展D和Q。 因为F扩展的硬件复杂度最低,适合用来验证你的FPU架构是否正确。
  2. 注意特殊值的处理。 NaN、无穷大、零、非规格化数,这些都要在指令执行时正确处理。我曾经因为非规格化数处理不当,导致一个科学计算程序结果完全错误。
  3. 舍入模式一定要实现完整。 很多软件依赖特定的舍入行为,如果只实现了默认的RNE,可能会出问题。
  4. 性能优化要有的放矢。 加法器和乘法器是热点,优先优化它们的延迟和吞吐量。除法和平方根,可以用迭代单元,面积小,但延迟长。

好了,F扩展的内容就讲到这里。下一节,我们会深入D扩展的设计细节,看看64位浮点运算到底有哪些挑战。记住,搞懂F扩展,D和Q就是水到渠成的事。