4. 性能评估基础与基准测试

执行单元是处理器的“心脏”。ALU、乘法器、除法器这些部件,决定了你的CPU能不能在每一个时钟周期都干出活来。我见过不少同学,RTL写得很漂亮,一上FPGA跑分就拉胯,问题十有八九出在执行单元的延迟没处理好。

这一章,咱们就聊聊怎么把E203的执行单元调顺溜。说白了,就是让流水线不因为等计算结果而空转。

4.1 执行单元的延迟模型

先看一张图,理解一下E203执行单元在流水线中的位置。

E203 执行单元流水线结构 取指 IF 译码 ID 执行 EX 访存 MEM 写回 WB ALU (1周期) 乘法器 (3周期) 除法器 (多周期) 数据前推路径 (Forwarding) 💡 关键点:ALU 1周期完成,乘法器3周期,除法器需要多个周期。 延迟隐藏的核心:不让流水线因为等待这些结果而停顿。

E203的执行阶段(EX)是单周期设计的,但乘法器和除法器不是。这就带来了一个矛盾:流水线希望每个阶段都只花1个周期,可乘法器要3个周期,除法器更久。怎么办?

核心思路:让乘法器和除法器“自成一派”,在EX阶段内部做多周期处理,同时通过数据前推(Forwarding)把结果提前送给后续指令,避免流水线停顿。

4.2 ALU的延迟隐藏

ALU在E203里是1周期完成的,按理说没什么延迟问题。但我在项目中遇到过一种情况:ALU指令后面紧跟着一条使用其结果的指令,如果写回阶段(WB)和读寄存器阶段(ID)没对齐,就会多等一个周期。

E203的做法很直接——结果写回和结果前推同时进行。ALU计算完,结果直接送到EX阶段的输入多路选择器,同时写入寄存器堆。这样,下一条指令在ID阶段就能拿到值,不用等WB完成。

实战技巧:检查你的ALU结果通路。如果发现ALU输出先去了WB寄存器,再从WB寄存器绕回EX,那就多了一个周期的气泡。E203的ALU结果是从EX直接前推到EX的,没有中间寄存器。

4.3 乘法器的3周期延迟处理

乘法器在E203里用了3个周期。为什么是3?因为E203的乘法器是逐位 Booth 编码 + Wallace 树,3周期刚好平衡面积和速度。

延迟隐藏的策略是这样的:

  • 第1周期:乘法器开始计算,流水线继续向前走,不等待。
  • 第2周期:乘法器还在算,此时如果有其他不依赖乘法结果的指令,可以正常执行。
  • 第3周期:乘法器出结果,结果通过前推通路直接送到EX阶段的输入。

你想想看,如果乘法器结果要等写回寄存器堆才能用,那后续指令至少等3个周期。有了前推,只需要等1个周期(如果乘法结果被下一条指令使用)。

场景 无前推(停顿周期) 有前推(停顿周期)
MUL rd, rs1, rs2 → ADD rd, rs1, rs2 3 1
MUL rd, rs1, rs2 → 无关指令 0 0
MUL rd, rs1, rs2 → LW rd, 0(rs1) 3 2

注意:乘法器结果前推有一个陷阱——如果乘法结果被用于地址计算(比如基址寻址),前推路径可能来不及。E203的做法是:地址计算在ID阶段就开始了,所以乘法结果不能用于地址前推。我曾经在这个坑里折腾了两天,最后发现是地址生成逻辑没等乘法结果。

4.4 除法器的多周期挑战

除法器是最难搞的。E203用的是恢复余数除法,周期数等于被除数的位宽(32位除法需要32个周期)。

延迟隐藏的思路变了——不再试图隐藏,而是让流水线“绕行”。

具体做法:

  1. 当检测到除法指令时,EX阶段进入“忙等待”状态。
  2. 除法器开始逐位计算,每个周期产生1位商。
  3. 流水线中后续的指令如果依赖除法结果,必须等待。
  4. 如果不依赖,可以继续执行(E203支持乱序完成吗?不支持,但可以插入气泡让后续无关指令通过)。

嗯,这里要注意:E203是顺序发射、顺序完成的。除法器占用EX阶段时,后续指令不能进入EX。但ID阶段可以继续译码,只要不发射到EX就行。

优化点:我曾经在项目里把除法器改成非恢复余数算法,周期数从32降到16。代价是面积增加了约15%。对于实时性要求高的场景,这个trade-off是值得的。

4.5 数据前推的实现细节

数据前推是延迟隐藏的“杀手锏”。E203的前推逻辑集中在EX阶段:

// 伪代码:E203 数据前推选择逻辑
// 来自 ALU 结果
if (ex_alu_valid && ex_alu_rd == id_rs1_addr) then
    forward_rs1 = ex_alu_result
// 来自乘法器结果
else if (ex_mul_valid && ex_mul_rd == id_rs1_addr) then
    forward_rs1 = ex_mul_result
// 来自除法器结果
else if (ex_div_valid && ex_div_rd == id_rs1_addr) then
    forward_rs1 = ex_div_result
// 来自写回阶段
else if (wb_valid && wb_rd == id_rs1_addr) then
    forward_rs1 = wb_result
else
    forward_rs1 = regfile[rs1_addr]

这段逻辑看起来简单,但优先级很重要。我习惯把EX阶段的结果放在最高优先级,因为它是“最新鲜”的。写回阶段的结果虽然也有效,但可能已经被EX阶段的结果覆盖了。

调试技巧:如果你发现数据前推没生效,先检查比较器。E203的寄存器地址比较是5位宽(x0-x31),但x0是硬连线0,比较时要排除。我曾经漏掉了x0的排除逻辑,导致所有读x0的指令都前推了错误的值。

4.6 性能调优实战

咱们看一个具体的调优案例。假设有一段代码:

mul t0, a0, a1    # t0 = a0 * a1, 3周期
add t1, t0, a2    # t1 = t0 + a2, 依赖t0
div t2, t1, a3    # t2 = t1 / a3, 依赖t1, 32周期
sw  t2, 0(sp)     # 写回内存, 依赖t2

没有前推的情况下,这段代码需要:3(mul)+ 1(add等待)+ 32(div)+ 1(sw等待)= 37个周期。

有了前推:3(mul,add在第4周期拿到结果)+ 32(div,sw在第36周期拿到结果)= 35个周期。

只省了2个周期?对,因为除法器是瓶颈。真正的大头在除法器。如果能把除法器从32周期优化到16周期,总周期数就能降到19。

调优原则:先优化最慢的部件。在E203里,除法器是执行单元的“长尾”。ALU和乘法器的延迟隐藏做得再好,也抵不过除法器拖后腿。我建议你在做性能分析时,先看除法指令的占比,如果超过5%,就该考虑优化除法器了。

4.7 避坑指南

最后,分享几个我踩过的坑:

  • 前推路径的时序:数据前推增加了组合逻辑的深度。E203的EX阶段本来时序就紧,加上前推多路选择器,容易导致setup违例。我习惯在关键路径上插入一级流水寄存器,代价是多一个周期的延迟,但时序能收敛。
  • 乘法器与除法器的互斥:E203的乘法器和除法器共享部分硬件资源。如果同时发射乘法和除法指令,会出现资源冲突。E203的做法是:检测到冲突时,后一条指令 stall 一个周期。
  • 写后读(RAW)冒险:前推能解决大部分RAW冒险,但有一种情况例外——加载指令(LW)后面紧跟使用加载结果的指令。加载结果来自MEM阶段,前推到EX阶段需要跨两个阶段,E203在这里插入了一个气泡。

嗯,执行单元的优化,说白了就是跟延迟做斗争。ALU快,就让它直接前推;乘法器慢,就让它边算边推;除法器更慢,就让它独占流水线。理解了这些,你就能把E203的执行单元调得服服帖帖。