背景

卷积是深度学习和图像处理最基本的运算之一。当我们在 CPU 上用 5×5 kernel 对 32×32 的矩阵进行卷积运算的时候,需要大量的 cycle 去完成卷积运算,造成了严重的性能浪费。

因此我们使用基于 RISC-V 的 RoCC(Rocket Custom Coprocessor)接口设计了一个专门用于计算卷积的加速器——把算力密集的任务从 CPU 卸载到专用硬件上。CPU 只需要发出指令,RoCC 即可进行运算,其中不需要 CPU 进行任何参与。

Phase 0:方案规划 & 协议设计

首先我们需要设计此项目的架构以及目标。

协议设计:RoCC 指令编码

RoCC 协议:CPU 通过 custom instruction 把操作数(rs1, rs2)发送给加速器,等待加速器计算结束后通过resp.data 再把结果发送回来。

1
2
3
4
5
6
7
8
9
10
Rocket Core (CPU)                      Accelerator
| |
| custom inst (rs1=addr, rs2=data) | ← RoCC interface: control
| -----------------------------------> |
| |
| DMA read / write | ← TileLink bus: data
| <==================================> |
| |
| resp.data = status / result | ← RoCC interface: status
| <----------------------------------- |

因此我们加速器使用三步走的流程:setting address → trigger → polling

CPU 先告诉输入矩阵、kernel、输出各在内存的什么位置,然后发一个 START,最后 polling status register 观察加速器是否完成运算。

Rocket Core 通过 RoCC 接口和加速器进行通信。每条 custom instruction 带有 funct7rs1rs2rd 四个字段。

funct7 Instruction rs1 Description
0 SET_ADDR_IN base addr Base address of input matrix
1 SET_ADDR_KER base addr Base address of kernel
2 SET_ADDR_OUT base addr Base address of output matrix
3 START_ACCEL Non-blocking start
4 POLL_STATUS Read status register to rd

设计包含四位状态寄存器用于进行polling。

Bit Name Meaning
0 busy Accelerator is computing
1 done Computation complete
2 overflow Accumulator overflow
3 addr_err Address check failed

设计决策

  1. 为什么地址分三条独立指令?

    一条 RoCC 指令只带两个操作数(rs1rs2),不够一次传三个base address。替代方案是传结构体指针,但那会让加速器为了读配置额外发起一次 DMA,导致复杂度和延迟增加。设置三条独立的指令,一次只传一个地址,硬件接口最干净。

  2. 为什么non-blocking 启动 + polling,而不是阻塞等结果?

    non-blocking 允许 CPU 发出 START_ACCEL 后立刻去进行其他工作。选polling不选中断,是因为 5×5 卷积的延迟很短而且可预期——中断控制器和上下文切换的开销反而更大。

  3. 为什么 funct7 连续编码而不是跳跃分配?

    一个 funct7 <= 4 判断就能覆盖全部合法指令,解码器最小。跳跃编码会增加combinational logic。

关键设计取舍

下面几个问题需要在项目开始前定下来。

  1. kernel 大小是固定还是可配置的?

    如果支持可变kernel导致control logic复杂度显著增加。最终选取固定的 5×5 硬件datapath。对于小于 5×5 的kernel由软件预先zero-padding补齐。

  2. 边界像素怎么处理?

    zero-padding是最简单的办法——滑动窗口 FSM 不需要任何边界处理特殊逻辑。

  3. 为什么选fixed-point不用floating-point?

    • 为什么不用floating-point? IEEE 754 multiplier面积大、延迟高。Q8.8 fixed-point multiplier就是一个 16-bit × 16-bit 整数乘法,一个周期出结果,硬件零开销。

    • 为什么不用纯整数? kernel权重基本上都是小数(比如 0.125、-0.5),没有办法用纯整数表示。这里使用Q8.8定点数:8 位整数部分(范围 ±128)与8 位小数部分(精度 1/256)进行组合形成一个16bits数据。

    • 为什么accumulator是 32 位? 5×5 卷积要累加 25 个 Q8.8 定点数的乘积。每个 16-bit × 16-bit 产生 32 位数据,32 位accumulator刚好能做到不溢出。

  4. 为什么有地址对齐约束?

    TileLink DMA 在地址按字对齐时进行搬运最高效。如果地址没对齐的话,一次传输会被拆分成多次传输,同时硬件需要对其进行字节移位和拼接的处理。因此只需要在加速器运行中对每条 SET_ADDR_* 检查地址即可。

Parameter Value
Input Matrix 32×32
Max Kernel 5×5
Data Format Q8.8 Fixed Point(16-bit)
Accumulator 32-bit
Output Same size, zero-padding
Performance Target <2500 cycles, ≥40× speedup

Phase 1:架构总览

问题:为什么通过 CPU 直接进行卷积运算会这么慢?

5×5 kernel 在 32×32 的矩阵上滑动运算的时候,总共进行 1024 次输出。对于每次输出要进行 25 次乘法和 24 次加法——1024个输出一共约 25,000 次乘加。在 CPU 上,真正的限制是”滑动”本身。Kernel每次进行滑动,CPU 需要计算地址偏移、更新循环变量、从内存加载像素和权重、再写回结果,因此绝大多数指令花在了循环控制和地址搬运。

因此我们可以进行粗略估计:在一颗简单的顺序 RISC-V 核上,每个输出像素大约花 100~150 cycles(两次 load,一次 store,约 50 条算数指令,外加循环分支)。1024 个像素加起来,10 万到 15 万 cycles。而我们的加速器干同样的活只要 2428 cycles——大约 50 倍的加速。

把计算卸载进加速器中

当我们使用加速器去计算卷积的时候,CPU只需要发指令即可。加速器自行完成剩下其他步骤:从内存中取数据、计算卷积、写回数据。CPU只需要发出:SET, START以及POLL指令。

加速器内部看上去是什么样的?

最简单的方法是将DMA load部分,MAC计算部分与DMA store部分进行串行连接。这种连接非常简单,但是依旧会产生大量的资源浪费。当DMA进行数据传输的时候,MAC部分需要暂停运算等待数据传输结束,反之亦然。因此此链接方法在同一个周期内只有一个硬件进行工作。为了提高加速器的工作效率,我们将三个硬件工作进行重叠,使其能够流水线工作。最终达到接近 50 倍的加速。

Module Map

Module Role Phase
ConvControl RoCC decode + 5-state master FSM + status register 2
ConvDMA 7-state DMA engine over TileLink 3, 4
LineBuffer 5×32×16 sliding window with zero-padding 5
InputQueue + StoreQueue Elastic buffers with backpressure 7
ConvEngine ShiftWindow + KernelROM + ConvUnit, 6-stage MAC pipeline 6

The following phases walk through each module in detail.


Phase 2:ConvControl-指令解码与控制FSM

之前我们已经设计了整个加速器的架构,由 Phase 1 可知,最顶层是 RoCC control。其中 CPU 发出 funct7,ConvControl 负责进行解码以及执行,最终通过 rd 进行回复。

Interface

ConvControl 通过五个信号和一个有效就绪握手与 CPU 通信:当 CPU 接收到 instrCmd.validfunct7rs1rd 信号,并返回 instrReady 信号。当 validready 在同一周期内都为高电平时,即可执行指令。

指令解码

在解码过程中使用五个比较器——funct7 === 0.Ufunct7 === 4.U,不需要优先级编码器,也不需要查找表。Phase 0 的连续编码在这里得到了回报:funct7 本身就是指令编号。

  • SET (0–2): 在 sBusy 状态下阻塞。在计算过程中更改地址会破坏运算。
  • START (3): 仅在 sIdle 或 sDone 状态下接受。不能启动已在运行的加速器。
  • POLL (4): 始终接受。这是一个纯读取操作——不会对任何操作进行干扰。

SET_ADDR:存储三个基地址

根据 funct7 的值,rs1 被写入 addrInaddrKeraddrOut 中。如果加速器处于 sError 状态,任何 SET 都会将其清除并回到 sIdle

START_ACCEL:地址检查与触发

  1. 地址非零检查

    如果任意一个基地址(addrInaddrKeraddrOut)为零,状态跳转到 sError。

  2. 地址对齐检查

    输入和输出矩阵要求 8 字节对齐(addrIn(2,0) === 0.U)。kernel 要求 2 字节对齐(addrKer(0) === 0.U)。DMA 总线宽度为 64 位,每次传输搬 4 个像素。kernel 只有 25 个系数,2 字节对齐足够。如果数据未对齐,硬件需要额外进行字节移位和拼接。

所有检查通过 → sIdle → sBusy,计算开始。任意一项失败 → sError,status register 中 addr_err 位置位。

FSM:四状态

1
2
3
4
5
6
7
8
9
10
11
12
         START pass                done
┌─── sIdle ────────────────► sBusy ─────────────► sDone
│ ▲ ▲ │
│ │ │ START │ SET
│ │ └────────────────────┘
│ │
│ │ SET
│ │
│ START fail
│ │
│ ▼
└──► sError
  • sIdle: 复位后的默认状态。等待 START。
  • sBusy: 只接受 POLL。计数到零后拉高 done,进入 sDone。
  • sDone: 保持 done = 1。CPU 可以重新 START(→ sBusy)或 SET 重配地址(→ sIdle)。
  • sError: 保持 addrErr = 1。只有 SET 能拉回 sIdle——没有直达 sBusy 的路径。

卷积引擎需要频繁地从 SRAM 读取像素和权重,再将结果写回。ConvDMA 负责管理这些数据传输。Phase 3 从严格串行的 DMA 开始——同一时刻只有一个请求在飞。这样 FSM 足够简单,初始测试也能逐周期精确验证。验证通过后,我们再添加流水线,让多个请求可以不等前一个响应就发出。

DMA 接口定义

三个纯 Bundle 连接 DMA 与 L1 data cache。仅信号声明,不含逻辑——valid/ready 握手由 Decoupled 提供。

  • SimpleMemReq — DMA 发给 L1 的请求。携带 64-bit 地址、64-bit 写数据、字节掩码、读写标志和 4-bit tag。串行 DMA 中 tag 恒为 0,预留 4-bit 给流水线版本做乱序响应匹配。
  • SimpleMemResp — L1 的回复。返回 64-bit 读数据,并回传请求的 tag。
  • SimpleMemIO — 将 req(DMA→L1)和 resp(L1→DMA,Flipped)捆为一个端口。
1
2
3
4
5
6
  ConvDMA                  SimpleMemIO            L1 data cache
┌─────────┐ ┌─────────────┐ ┌──────────┐
│ │── req ──────►│ req (output) │────────►│ │
│ FSM │ │ │ │ L1 D$ │
│ │◄─ resp ──────│ resp (input) │◄────────│ │
└─────────┘ └─────────────┘ └──────────┘

串行 FSM

ConvDMA 有两条数据通路:

  • Load path(sIdle → sIssue → sWaitResp → sUnpack → 循环):从内存读 64-bit word,拆解为 4 个 16-bit 元素,推入 elemQueue 供计算单元消费。
  • Store path(sIdle → sGather → sIssue → 循环):从计算单元收集 4 个 16-bit 元素,拼成 64-bit word,写回内存。

两条路径共享 sIssue,通过 opReg 分叉。严格遵守”发射一个请求 → 等响应 → 拆/拼数据 → 再发射下一个”的串行铁律——任意时刻只有 1 个请求在飞。

1
2
3
4
5
6
7
8
9
Load path:   sIdle → sIssue → sWaitResp → sUnpack(×4) ─┐
▲ │
└────────────────────────────────────────┘

Store path: sIdle → sGather(×4) → sIssue ─┐
▲ │
└─────────────────────────────┘

Error path: sIdle ──► sError (SET addr ⇒ sIdle)

瓶颈:6 cycles/word

以 load path 为例,读一个 word 走 sIssue → sWaitResp → sUnpack×4 → 回到 sIssue,恰好 6 拍。sUnpack 占 4 拍,内存在这 4 拍完全空闲。瓶颈不在内存延迟,而在 FSM 不让发射和拆包重叠。

1
2
3
4
5
6
7
8
cyc |     state |     mem.req    |   mem.resp   | loadStream
0 | sIssue | fire(rd) | |
1 | sWaitResp | | fire |
2 | sUnpack | |
3 | sUnpack | | deq elem[0]
4 | sUnpack | | deq elem[1]
5 | sUnpack | | deq elem[2]
6 | sIssue | fire(rd) | | deq elem[3]

256 word × 6 + 1 cycle overhead = 1537 cycles

流水线化 DMA

这一步解决串行 FSM 的瓶颈。核心思路:将发射(issue)和拆包(unpack)解耦为两个并发硬件进程,在时间上重叠。

1
2
3
4
5
6
7
8
9
10
11
Serial:
Issue: [sIssue] [sIssue] [sIssue]
Response: [sWait] [sWait] [sWait]
Unpack: [sUnpack×4] [sUnpack×4] [sUnpack×4]
↑── 6 cycles/word ──↑

Pipelined:
Issue: [sIssue][sIssue][sIssue][sIssue][sIssue]...
Response: [1 cycle] [1 cycle] [1 cycle] [1 cycle] [1 cycle] ← enqueue FIFO
Unpack: [sUnpack×4][sUnpack×4][sUnpack×4]... ← dequeue FIFO
↑── 4 cycles/word (unpack is the bottleneck) ──↑

响应 FIFO 吸收速率差:issue 引擎以 1 word/cycle 发射(直到 inflight 上限),响应自动落入 FIFO,unpack 引擎以 4 cycles/word 从 FIFO 取出拆解。两者互不阻塞。1537 cycles → ~1033 cycles,提升约 33%。逐步实现细节见后续文章。

串行到流水线的四项改动

1. Register → Queue

串行 DMA 用单个 respWord 寄存器锁存一次响应——因为最多只有 1 个 in-flight 请求。流水线版本替换为响应 FIFO 缓冲多个响应。数据自动落入 FIFO,FSM 只在需要时从 FIFO 取数。这拆掉了响应收集与 FSM 状态机之间的耦合。

2. sWaitResp 退化

sWaitResp 不再等 mem.resp.valid,也不再锁存响应数据。唯一剩下的工作就是等 FIFO 有数据可取。

3. inflightCount — 信用制流控

issue 引擎发射速率(1 word/cycle)快于 unpack 引擎消费速率(1 element/cycle → 0.25 word/cycle)。不加限流 FIFO 会溢出。inflightCount 作为信用账户:issue 每发一个请求消耗 1 点信用,unpack 每拆完一个 word 归还 1 点信用。inflightMax = 4 将窗口限制为 4 个 in-flight word,恰好匹配 unpack 引擎消化速度。

4. sLoadActive — 并发 FSM(核心改动)

将串行的三个状态 sIssuesWaitRespsUnpack 合并为一个 sLoadActive 状态。在此状态内,两个独立的 when 块并发运行:issue 引擎发射请求直至 inflightMax,unpack 引擎从 FIFO 排空数据。两者可在同一拍内同时推进——issue 与 unpack 真正重叠。


Phase 4:计算数据通路 — LineBuffer & ConvEngine

DMA 每个周期送入一个像素,顺序为行优先——第 0 行从左到右,然后第 1 行、第 2 行。但 5×5 卷积在输出像素 (r, c) 处需要 25 个像素,排列为以 (r+2, c+2) 为中心的 5×5 邻域。单个像素毫无用处——计算数据通路必须在 MAC 单元启动前拼出完整的 5×5 窗口。

这是一个数据重组问题,自然拆成两个维度:LineBuffer 负责垂直方向(行),ShiftWindow 负责水平方向(列)。LineBuffer 从 DMA 流中收集 5 行 × 32 列像素,然后每周期输出同一列上 5 个垂直相邻像素——从行优先到列优先,做了 90° 旋转。ShiftWindow 缓冲来自 LineBuffer 的连续 5 列,每周期右移,输出完整的 5×5 窗口给 MAC 单元。

为什么不直接从 SRAM 读取? 每个输出像素需要来自 5 个不同行的值。直接读取需要每周期 5 次独立读取,指向 5 个不同地址——五个内存端口。LineBuffer 用一个写端口(DMA 每周期写入一个像素)和一个 5 路读端口(5 行 × 同一列)替代了它。160 条目的寄存器堆远便宜过一个 5 端口 SRAM。

1
sIdle ──► sPrime (加载前 5 行) ──► sActive (32 个输出行) ──► sDone

sActive 状态下,缓冲区每行输出 36 列,DMA 将下一行输入加载到单独的 tmpRow 寄存器中。当一行结束时,缓冲区向上移动——丢弃第 0 行,第 1 到 3 行向上移动,tmpRow 作为第 4 行进入缓冲区。如果没有 tmpRow,DMA 会覆盖仍在输出的行,并且加载和输出无法重叠——第三阶段流水线式 DMA 的端到端优势将无法体现。

零填充通过两种机制覆盖四个边界:

  • 上 / 下: 由五个缓冲行存的内容决定。输出行 0 时,顶部两行为零;输出行 31 时,底部两行为零。窗口下滑过程中,真实行自然轮转进出——无需额外控制逻辑。
  • 左 / 右: 每个输出行输出 36 列(2 左填充 + 32 数据 + 2 右填充)。colValid 信号标记数据列。低电平时,ShiftWindow 忽略 colOut 的值,自行填零。

Part B: ShiftWindow → KernelROM → ConvUnit

LineBuffer 每拍输出 5 个像素(一列)。MAC 单元需要完整的 5×5 窗口。三个模块完成剩余的组装工作:

ShiftWindow — 5×5 寄存器窗口。 内部维护一个 5×5 寄存器阵列。每拍所有列右移——最旧列(c4)丢弃,新列从 LineBuffer 进入 c0。colValid 为低时 c0 填零,实现左/右 padding。窗口以组合逻辑输出——400 比特的寄存器,比 BRAM 便宜,零读延迟。

KernelROM — 权值存储。 25 条目寄存器堆。计算开始前一次性写入,计算期间只读——对计算通路等效于 ROM。5×5 组合输出,零延迟,ConvUnit 同拍拿到 windowkernel 两个操作数。

ConvUnit — 5 级流水线 MAC 树。 若用纯组合逻辑(25 次乘法 + 巨型加法树),关键路径太长,频率上不去。解决方案:把 pairwise 加法树切为 5 级流水线,每级只需一次 32 位加法。

1
2
3
Stage 0(组合):25 个 16×16→32 并行乘法
Stage 1–5(寄存):25→13→7→4→2→1 pairwise 规约
Stage 5 顺带完成四舍五入(+0x80)、>>8、饱和截位

为什么 pairwise 不用 Wallace tree:pairwise 结构规整,深度刚好 ceil(log₂ 25) = 5 级,寄存器自然插在各级之间——关键路径仅为一次 32 位加法。

ConvEngine — 顶层黏合。 实例化 ShiftWindow、KernelROM、ConvUnit,把 colIn / colValid 接入 ShiftWindow,kernel / window 接入 ConvUnit。inValid 延迟 1 拍(RegNext)与 ShiftWindow 的寄存器输出对齐。stall 输入门控 colValid,反压时冻结整条流水线。outValidinValidShiftRegister 延迟 5 拍——高电平时 result 为有效卷积输出。


Phase 5:顶层集成与主 FSM

Phase 1 到 Phase 4 把四个零件各自造好了。Phase 5 做三件事:

  • 把 ConvControl、ConvDMA、LineBuffer、ConvEngine 拼成一个 ConvAccelTop
  • 在各级之间塞入弹性缓冲(InputQueue / StoreQueue)。
  • 对外暴露 SimpleMemIO 接口,测试时直连 FakeScratchpadMemory,上 Chipyard 后替换为 HellaCache。

ConvAccelTop:框架与 IO

ConvAccelTop 是一个独立的 Module——对外只有一套简单的 start / done 握手,外加内存基地址和 SimpleMemIO

1
2
3
4
5
6
7
8
9
10
11
12
               ┌────────────────────────────────────────────┐
start ─┤ ├─ done
kAddr ─┤ ├─ state[2:0]
iAddr ─┤ ConvAccelTop │
oAddr ─┤ ├─ mem.req.valid
│ ├─ mem.req.bits.addr
│ ├─ mem.req.bits.op
│ ├─ mem.req.bits.data
│ │
mem.rsp.valid ─┤ │
mem.rsp.data ─┤ │
└────────────────────────────────────────────┘
信号 位宽 方向 作用
start 1 输入 拉高一拍,启动一次卷积
kernelAddr 64 输入 5×5 卷积核在 SRAM 中的基地址
inputAddr 64 输入 32×32 输入图像的基地址
outputAddr 64 输入 32×32 输出图像的基地址
mem.req 输出 内存读写请求(valid / addr / op / data)
mem.rsp 输入 内存响应(valid / data),由 testbench 的 scratchpad 驱动
done 1 输出 FSM 进入 sDone 后拉高
state 3 输出 当前 FSM 状态

三个地址端口在 start 脉冲同一拍被锁存进内部寄存器,后续阶段全部用寄存器里的值——防止外部在计算途中改动地址。

子模块实例化与接线

五个子模块实例化——前三个是 Phase 3/4 自己写的,后两个直接用 Chisel 自带的 Queue

1
2
3
4
5
val dma        = Module(new ConvDMA)                        // Phase 3
val lineBuf = Module(new LineBuffer) // Phase 4
val engine = Module(new ConvEngine) // Phase 4
val storeQueue = Module(new Queue(SInt(16.W), 2048)) // Chisel 内置 FIFO
val inputQueue = Module(new Queue(UInt(16.W), 1024)) // Chisel 内置 FIFO

Queue 是 Chisel 的标准 FIFO——内部自动管理读写指针,满了反压上游,空了反压下游。不需要自己写任何 FIFO 逻辑。

1. io.mem ↔ DMA

1
io.mem <> dma.io.mem

<> 是 Chisel 的批量连接操作符。io.memdma.io.mem 都是 SimpleMemIO 类型,内部包含 req.validreq.bits.addrrsp.data 等多个信号。<> 把同名信号一一接上——一行替代八行 :=

2. DMA loadStream 扇出

DMA 读回的数据只有一个出口 loadStream,但在不同阶段要去不同地方:

  • sLoadKernelloadStreamengine.io.kernelData,把 25 个权重写入 kernel ROM。
  • sLoadInputloadStreaminputQueue.io.enq,1024 个像素全部缓冲起来。

一次只有一个状态活跃,用 when / elsewhen 分支就够了,不需要仲裁器或多路选择器。

3. 计算主通路(三段 daisy chain)

三段都是标准的 valid / ready 握手:

  • inputQueue → LineBufferinputQueue.io.deqlineBuf.io.in。只在 Queue 有数据(deq.valid)且 LineBuffer 能接收(in.ready)时数据才传递。这条路径只在 sLoadInputsCompute 期间通行。
  • LineBuffer → ConvEnginelineBuf.io.colOutengine.io.colIncolValid 多带一个条件——engine.stall 拉高时 colValid 强制拉低,冻结 ConvEngine 流水线。
  • ConvEngine → storeQueueengine.io.outValid 驱动 storeQueue.io.enq.valid。每个卷积结果塞入输出队列。

4. storeQueue → DMA(写回路径)

1
2
dma.io.storeStream.valid := storeQueue.io.deq.valid
storeQueue.io.deq.ready := dma.io.storeStream.ready

DMA 从 storeQueue 取结果写回内存。DMA 在突发写忙不过来时,storeStream.ready 拉低——队列停止出队,反压沿管线一路向上传导。

InputQueue 与 StoreQueue —— 弹性缓冲

DMA 和 ConvEngine 的工作节奏不同。DMA 是突发传输——快但不连续。ConvEngine 每拍产出一个像素——匀速但不灵活。没有缓冲,任何速度不匹配都会让整条流水线空转或丢数据。

两个 Chisel 的 Queue 夹在模块之间吸收速度差。Queue 是一个标准 FIFO,内部是环形 buffer + 读写指针 + 计数器。对外暴露出两个口——enq(写入端)和 deq(读出端),自动管理 valid/ready 握手:

  • Queue deq.valid = 0(没数据可读)。
  • Queue enq.ready = 0(没空间可写)。
  • Queue 既不空也不满:enq.readydeq.valid 同时为 1——数据可以一边进一边出。

不需要自己写 FIFO 逻辑,Chisel 标准库全部管好。

inputQueue

1
val inputQueue = Module(new Queue(UInt(16.W), 1024))

深度 1024 = 一整张 32×32 图。DMA 在 sLoadInput 阶段一次灌满,LineBuffer 在 sCompute 阶段一拍一个取走。无需同步。

storeQueue

1
val storeQueue = Module(new Queue(SInt(16.W), 2048))

深度 2048 = 1088 个结果 + 960 格余量。ConvEngine 一拍塞一个,DMA 突发写回。DMA 忙时队列暂存,追上即可。

反压链

硬件里的反压不是消息传递——是直接接线。当 storeQueue 满了,它内部的 enq.ready 从 1 变成 0。两个模块直接连在这根信号上:

1
2
engine.io.stall  := !storeQueue.io.enq.ready
lineBuf.io.stall := !storeQueue.io.enq.ready

enq.ready 一拉低,两个模块同一拍全看到。链式反应在两拍内完成:

1
2
3
4
5
6
7
storeQueue 满
→ storeQueue.io.enq.ready = 0
→ ConvEngine 停(outValid 无处可去)
→ LineBuffer 停(无新窗口被消费)
→ inputQueue 只进不出,堆满
→ inputQueue.io.enq.ready = 0
→ DMA load 停

没有握手,没有通知,没有软件参与。当 DMA 追上、storeQueue 腾出空位,enq.ready 回到 1,管线自动重启。

Master FSM in ConvControl

Master FSM 不会逐拍调度每一个 pixel 或每一个 convolution window。它只是在每个阶段打开对应模块的工作路径,然后让模块之间的 ready/valid 握手决定数据是否能在这一拍前进。换句话说,顶层 FSM 控制的是“阶段”,而不是每一个微操作。

sLoadKernel 阶段,DMA 的 load stream 被接到 ConvEngine 的 kernel 写端口。DMA 每吐出一个有效 word,就写入一个 kernel 元素。等所需 kernel 元素加载完成后,DMA 拉高 done,FSM 跳到 sLoadInput

sLoadInput 阶段,DMA 的 load stream 被接到 InputQueue。与此同时,InputQueue 也允许向 LineBuffer 出队,所以 input loading 和 line-buffer filling 是重叠的。等 LineBuffer 内部攒够足够的像素后,它开始向 ConvEngine 输出有效的 column/window。从这一刻开始,即使 input DMA 还在继续加载后面的像素,计算流水线也已经启动了。

sCompute 阶段,input DMA 已经完成,DMA 命令切换到 store 模式。InputQueueLineBufferConvEngineStoreQueue 中残留的数据继续向后 drain。同时,StoreQueue 将结果送给 DMA store stream,因此计算尾部和结果写回是重叠的。

1
2
3
4
5
6
7
8
sLoadKernel:
DMA load kernel -> ConvEngine kernel ROM

sLoadInput:
DMA load input -> InputQueue -> LineBuffer -> ConvEngine -> StoreQueue

sCompute:
InputQueue -> LineBuffer -> ConvEngine -> StoreQueue -> DMA store output

端到端数据流走读

下图是一次完整卷积的全生命周期——所有信号、所有队列、所有阶段,在时间轴上对齐:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
time ───────────────────────────────────────────────────────────────────────────────>

io.start ┌─┐
└─┘

state sIdle ──> sLoadKernel ──> sLoadInput ───────────> sCompute ──> sDone

dma.cmd load_kernel load_input store_output

dma.loadStream [ kernel data ] [ input pixels ........ ] idle

InputQueue.enq [ input pixels ........ ] idle
InputQueue.deq [ pixels -> LineBuffer ........... ][drain]

LineBuffer [ warm-up ][ colValid active ........ ][drain]

ConvEngine [ compute valid windows .... ][drain]

StoreQueue.enq [ results ............... ]
StoreQueue.deq [ results -> DMA .... ]

dma.storeStream [ output results .... ]

io.done ┌──
└──

三次重叠是性能提升的来源:

重叠 谁跟谁 阶段
加载 ↔ 计算 DMA 写 InputQueue LineBuffer 取
计算 ↔ 写回 ConvEngine 产结果 DMA 写 StoreQueue
管线排空 DMA load 已结束,计算仍在 drain sCompute 尾部

RoCC 响应协议

指令编码和软件可见的状态位已在 Phase 0 定义,由 Phase 2 的 ConvControl 实现。在顶层,Phase 5 只需确保这些控制响应正确接入 RoCC 响应通道。

一次 RoCC 响应的完成条件是:

1
io.resp.fire = io.resp.valid && io.resp.ready
1
2
io.resp.bits.rd   // 原始指令中的 rd
io.resp.bits.data // 确认值或状态值

加速器使用三种响应模式:

  • SET_ADDR_*:地址寄存器更新后立即响应,返回确认值即可——因为只改动了配置状态。

  • START_ACCEL:同样立即响应,但仅表示已接受启动请求,并不表示卷积已完成。接受后主 FSM 进入活跃状态,io.busy 保持高直到运行结束。

  • POLL_STATUS:软件观察完成状态的指令。响应数据来自状态寄存器,包含 busydoneoverflowaddr_err 等位。

顶层 io.busy 由主 FSM 驱动:

1
io.busy = state =/= sIdle && state =/= sDone

当前设计使用轮询而非中断,因此 io.interrupt 保持低。若后续添加中断支持,可在 FSM 进入 sDone 时拉高。

Bug 1: colValid 早关 2 拍导致结果丢失

现象。 goDone 改为 1024 后测试能跑完,但 1024 个 outValid 中只有 960 个对应真实图像数据,末尾 64 个丢失。

定位。 resultCnt → outValid → inValid → colValid,最终追到 LineBuffer 的 colValid = inImageinImage 仅在 outputCol 2-33 为 true,即每行只标记 32 列。但 ShiftWindow 的窗口中心(reg(2))落后 colOut 2 拍:

1
2
3
outputCol=33: reg = [img_31, img_30, img_29, img_28, img_27]  center = img_29
outputCol=34: reg = [0, img_31, img_30, img_29, img_28] center = img_30 ← colValid=0!
outputCol=35: reg = [0, 0, img_31, img_30, img_29] center = img_31 ← colValid=0!

img_30 和 img_31 已穿过窗口中心,卷积结果已算出,但 colValid 提前关闭,outValid 未标记。每行丢 2 个,32 行丢 64 个。

根因。 colValid = inImage 混淆了两件事:colOut 是否为图像列、窗口中心是否还持有有效像素。outputCol=34-35 时 colOut 是零(正确的 padding 行为),但 img_30、img_31 仍在 reg(2) 中排队,MAC 流水线已经在算了——只是 valid 信号没给。

修复。 right padding 区域 colValid 延长 2 拍,排空流水线尾部:

1
2
3
4
}.otherwise {
io.colOut := VecInit.fill(5)(0.S(16.W)) // colOut 填零,不从 buffer 读
io.colValid := outputCol >= 34.U && outputCol <= 35.U // ← 延长 2 拍
}

为什么不能直接扩大 inImage 到 35?因为 bufCol = (outputCol - 2.U)(4,0) 在 outputCol=34 时得到 32,5-bit 截断后回卷到 0,会从 buffer 读出错误数据。colOut 填零 + colValid 单独延长,把”从 buffer 读什么”和”流水线是否继续运转”拆开。

修复后每行 34 个 colValid × 32 行 = 1088 个 outValid:前 2 个是流水线填充气泡(窗口中心为零),中 30 个是有效结果,后 2 个是排空产出。

位置 修改前 修改后
LineBuffer.scala:118 io.colValid := false.B io.colValid := outputCol >= 34.U && outputCol <= 35.U
ConvAccelTop.scala:51 resultCnt >= 1024.U resultCnt >= 1088.U
ConvAccelTop.scala:73 store length = 2048.U 2176.U
测试 stride row * 32 + col row * 34 + col + 2

测试端 +2 跳过的是开头 2 个填充气泡(窗口中心尚未进入图像区域),不是修复前丢失的结果。

Bug 2: tmpRow 行切换时被覆写

现象。 737 个 mismatch。不是零星错——系统性的。前 4 行正确,第 5 行起整行出错。

定位。 最初只打印了前 2 行输出,看起来全对。扩大到全部 32 行后,第 5 行第一个像素是 0x00C0——row 6 的行号。测试数据是递增序列,像素值自带身份信息:

1
2
3
4
0x0000 = 第 0 行首个像素
0x0020 = 第 1 行首个像素(32)
0x00A0 = 第 5 行首个像素(160)
0x00C0 = 第 6 行首个像素(192)
1
2
3
4
5
6
7
row0 out: 0000 0000 0000 0001 0002 ... 001d  ← 正确
row1 out: 0000 0000 0020 0021 0022 ... 003d ← 正确
row2 out: 0000 0000 0040 0041 0042 ... 005d ← 正确
row3 out: 0000 0000 0060 0061 0062 ... 007d ← 正确
row4 out: 0000 0000 0080 0081 0082 ... 009d ← 正确
row5 out: 0000 0000 00c0 00c1 00c2 ... 00bd ← row6 的数据!!
row6 out: 0000 0000 00e0 00e1 00e2 ... ← 混合

row 6 的数据跳到了 row 5——错误跨越了整行。

追数据来源。 LineBuffer 在每行末尾将 5 行 buffer 上移:

1
2
3
4
5
6
7
when (outputRow >= 2.U) {
buffer(0) := buffer(1)
buffer(1) := buffer(2)
buffer(2) := buffer(3)
buffer(3) := buffer(4)
buffer(4) := tmpRow // 新数据的唯一入口
}

新数据只从一个地方进来:tmpRow。buffer 里有错,一定是 tmpRow 先错了。

tmpRow 在 sActive 期间逐像素从 DMA 加载:

1
2
3
4
5
6
7
8
when (io.in.valid && io.in.ready) {
tmpRow(loadCol) := io.in.bits.asSInt
when (loadCol === 31.U) {
loadCol := 0.U // 32 个像素加载完,回卷
}.otherwise {
loadCol := loadCol + 1.U
}
}

io.in.readyneedLoad 控制,只看行号不看列号:

1
2
val needLoad = outputRow >= 2 && outputRow + 3 < 32
io.in.ready := needLoad && !io.stall // ← 没有列范围限制

逐拍模拟 outputRow=2。 DMA 发完 row 5 的 32 个像素后继续往前发——它不知道有 padding 列:

1
2
3
4
5
6
7
8
outputCol:  0   1   2   3  ...  31  32  33  34  35
左填 左填 图像 图像 图像 图像 图像 右填 右填
needLoad: T T T T ... T T T T T

loadCol: 0 1 2 3 ... 29 30 31 0 1 ← 列 32 时回卷!
DMA 发来: R5 R5 R5 R5 R5 R5 R5 R6 R6 ← R5=row5, R6=row6

row6 覆写了 tmpRow(0) 和 tmpRow(1)!

列 32-35 期间,loadCol 已回卷到 0,但 needLoad 仍为 true。DMA 已经在发 row 6 的像素——它们覆写了 tmpRow 的前 4 个槽位。行尾移位 buffer(4) := tmpRow 把污染数据拉入 buffer。经过几轮移位,脏数据往上爬,最终在 outputRow=5 露出表面。

根因。 loadCol 模 32 回卷,outputCol 模 36 回卷。每行 4 个 padding 列形成了一个窗口——DMA 已经跑到下一行,loadCol 已复位,但 needLoad 还在无条件地让数据进来。

修复。 DMA 加载限制在图像列期间:

1
2
3
4
5
// 修改前
io.in.ready := needLoad && !io.stall

// 修改后
io.in.ready := needLoad && inImage && !io.stall

outputCol 34-35 期间 inImage 为 false,不再加载,tmpRow 保持完整,行尾移位进入 buffer 的数据是正确的。

Phase 6:Chipyard 集成与 Verilator 构建

TODO


Phase 7:裸机 C 测试程序

TODO


Phase 8:性能报告与总结