基于 RISC-V RoCC 的卷积加速器
文章目录
- 1. 背景
- 2. Phase 0:方案规划 & 协议设计
- 3. Phase 1:架构总览
- 4. Phase 2:ConvControl-指令解码与控制FSM
- 5. Phase 3:ConvDMA — TileLink DMA 引擎
- 6. Phase 4:计算数据通路 — LineBuffer & ConvEngine
- 7. Phase 5:顶层集成与主 FSM
- 8. outputCol 34-35 期间 inImage 为 false,不再加载,tmpRow 保持完整,行尾移位进入 buffer 的数据是正确的。
- 9. Phase 6:Chipyard 集成与 Verilator 构建
- 10. Phase 7:裸机 C 测试程序
- 11. Phase 8:性能报告与总结
背景
卷积是深度学习和图像处理最基本的运算之一。当我们在 CPU 上用 5×5 kernel 对 32×32 的矩阵进行卷积运算的时候,需要大量的 cycle 去完成卷积运算,造成了严重的性能浪费。
因此我们使用基于 RISC-V 的 RoCC(Rocket Custom Coprocessor)接口设计了一个专门用于计算卷积的加速器——把算力密集的任务从 CPU 卸载到专用硬件上。CPU 只需要发出指令,RoCC 即可进行运算,其中不需要 CPU 进行任何参与。
- Repository:MyConvAccel
Phase 0:方案规划 & 协议设计
首先我们需要设计此项目的架构以及目标。
协议设计:RoCC 指令编码
RoCC 协议:CPU 通过 custom instruction 把操作数(rs1, rs2)发送给加速器,等待加速器计算结束后通过resp.data 再把结果发送回来。
1 | Rocket Core (CPU) Accelerator |
因此我们加速器使用三步走的流程:setting address → trigger → polling
CPU 先告诉输入矩阵、kernel、输出各在内存的什么位置,然后发一个 START,最后 polling status register 观察加速器是否完成运算。
Rocket Core 通过 RoCC 接口和加速器进行通信。每条 custom instruction 带有 funct7、rs1、rs2、rd 四个字段。
| 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 |
设计决策
为什么地址分三条独立指令?
一条 RoCC 指令只带两个操作数(
rs1和rs2),不够一次传三个base address。替代方案是传结构体指针,但那会让加速器为了读配置额外发起一次 DMA,导致复杂度和延迟增加。设置三条独立的指令,一次只传一个地址,硬件接口最干净。为什么non-blocking 启动 + polling,而不是阻塞等结果?
non-blocking 允许 CPU 发出
START_ACCEL后立刻去进行其他工作。选polling不选中断,是因为 5×5 卷积的延迟很短而且可预期——中断控制器和上下文切换的开销反而更大。为什么 funct7 连续编码而不是跳跃分配?
一个
funct7 <= 4判断就能覆盖全部合法指令,解码器最小。跳跃编码会增加combinational logic。
关键设计取舍
下面几个问题需要在项目开始前定下来。
kernel 大小是固定还是可配置的?
如果支持可变kernel导致control logic复杂度显著增加。最终选取固定的 5×5 硬件datapath。对于小于 5×5 的kernel由软件预先zero-padding补齐。
边界像素怎么处理?
zero-padding是最简单的办法——滑动窗口 FSM 不需要任何边界处理特殊逻辑。
为什么选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刚好能做到不溢出。
为什么有地址对齐约束?
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.valid、funct7、rs1 和 rd 信号,并返回 instrReady 信号。当 valid 和 ready 在同一周期内都为高电平时,即可执行指令。
指令解码
在解码过程中使用五个比较器——funct7 === 0.U 到 funct7 === 4.U,不需要优先级编码器,也不需要查找表。Phase 0 的连续编码在这里得到了回报:funct7 本身就是指令编号。
- SET (0–2): 在 sBusy 状态下阻塞。在计算过程中更改地址会破坏运算。
- START (3): 仅在 sIdle 或 sDone 状态下接受。不能启动已在运行的加速器。
- POLL (4): 始终接受。这是一个纯读取操作——不会对任何操作进行干扰。
SET_ADDR:存储三个基地址
根据 funct7 的值,rs1 被写入 addrIn、addrKer、addrOut 中。如果加速器处于 sError 状态,任何 SET 都会将其清除并回到 sIdle。
START_ACCEL:地址检查与触发
地址非零检查
如果任意一个基地址(
addrIn、addrKer、addrOut)为零,状态跳转到 sError。地址对齐检查
输入和输出矩阵要求 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 | START pass done |
- sIdle: 复位后的默认状态。等待 START。
- sBusy: 只接受 POLL。计数到零后拉高
done,进入 sDone。 - sDone: 保持
done = 1。CPU 可以重新 START(→ sBusy)或 SET 重配地址(→ sIdle)。 - sError: 保持
addrErr = 1。只有 SET 能拉回 sIdle——没有直达 sBusy 的路径。
Phase 3:ConvDMA — TileLink DMA 引擎
卷积引擎需要频繁地从 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 | ConvDMA SimpleMemIO L1 data cache |
串行 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 | Load path: sIdle → sIssue → sWaitResp → sUnpack(×4) ─┐ |
瓶颈:6 cycles/word
以 load path 为例,读一个 word 走 sIssue → sWaitResp → sUnpack×4 → 回到 sIssue,恰好 6 拍。sUnpack 占 4 拍,内存在这 4 拍完全空闲。瓶颈不在内存延迟,而在 FSM 不让发射和拆包重叠。
1 | cyc | state | mem.req | mem.resp | loadStream |
256 word × 6 + 1 cycle overhead = 1537 cycles。
流水线化 DMA
这一步解决串行 FSM 的瓶颈。核心思路:将发射(issue)和拆包(unpack)解耦为两个并发硬件进程,在时间上重叠。
1 | Serial: |
响应 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(核心改动)
将串行的三个状态 sIssue、sWaitResp、sUnpack 合并为一个 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 同拍拿到 window 和 kernel 两个操作数。
ConvUnit — 5 级流水线 MAC 树。 若用纯组合逻辑(25 次乘法 + 巨型加法树),关键路径太长,频率上不去。解决方案:把 pairwise 加法树切为 5 级流水线,每级只需一次 32 位加法。
1 | Stage 0(组合):25 个 16×16→32 并行乘法 |
为什么 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,反压时冻结整条流水线。outValid 是 inValid 经 ShiftRegister 延迟 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 | ┌────────────────────────────────────────────┐ |
| 信号 | 位宽 | 方向 | 作用 |
|---|---|---|---|
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 | val dma = Module(new ConvDMA) // Phase 3 |
Queue 是 Chisel 的标准 FIFO——内部自动管理读写指针,满了反压上游,空了反压下游。不需要自己写任何 FIFO 逻辑。
1. io.mem ↔ DMA
1 | io.mem <> dma.io.mem |
<> 是 Chisel 的批量连接操作符。io.mem 和 dma.io.mem 都是 SimpleMemIO 类型,内部包含 req.valid、req.bits.addr、rsp.data 等多个信号。<> 把同名信号一一接上——一行替代八行 :=。
2. DMA loadStream 扇出
DMA 读回的数据只有一个出口 loadStream,但在不同阶段要去不同地方:
sLoadKernel:loadStream→engine.io.kernelData,把 25 个权重写入 kernel ROM。sLoadInput:loadStream→inputQueue.io.enq,1024 个像素全部缓冲起来。
一次只有一个状态活跃,用 when / elsewhen 分支就够了,不需要仲裁器或多路选择器。
3. 计算主通路(三段 daisy chain)
三段都是标准的 valid / ready 握手:
- inputQueue → LineBuffer:
inputQueue.io.deq接lineBuf.io.in。只在 Queue 有数据(deq.valid)且 LineBuffer 能接收(in.ready)时数据才传递。这条路径只在sLoadInput和sCompute期间通行。 - LineBuffer → ConvEngine:
lineBuf.io.colOut接engine.io.colIn。colValid多带一个条件——engine.stall拉高时 colValid 强制拉低,冻结 ConvEngine 流水线。 - ConvEngine → storeQueue:
engine.io.outValid驱动storeQueue.io.enq.valid。每个卷积结果塞入输出队列。
4. storeQueue → DMA(写回路径)
1 | dma.io.storeStream.valid := storeQueue.io.deq.valid |
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.ready和deq.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 | engine.io.stall := !storeQueue.io.enq.ready |
enq.ready 一拉低,两个模块同一拍全看到。链式反应在两拍内完成:
1 | storeQueue 满 |
没有握手,没有通知,没有软件参与。当 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 模式。InputQueue、LineBuffer、ConvEngine 和 StoreQueue 中残留的数据继续向后 drain。同时,StoreQueue 将结果送给 DMA store stream,因此计算尾部和结果写回是重叠的。
1 | sLoadKernel: |
端到端数据流走读
下图是一次完整卷积的全生命周期——所有信号、所有队列、所有阶段,在时间轴上对齐:
1 | time ───────────────────────────────────────────────────────────────────────────────> |
三次重叠是性能提升的来源:
| 重叠 | 谁跟谁 | 阶段 |
|---|---|---|
| 加载 ↔ 计算 | 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 | io.resp.bits.rd // 原始指令中的 rd |
加速器使用三种响应模式:
SET_ADDR_*:地址寄存器更新后立即响应,返回确认值即可——因为只改动了配置状态。START_ACCEL:同样立即响应,但仅表示已接受启动请求,并不表示卷积已完成。接受后主 FSM 进入活跃状态,io.busy保持高直到运行结束。POLL_STATUS:软件观察完成状态的指令。响应数据来自状态寄存器,包含busy、done、overflow、addr_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 = inImage。inImage 仅在 outputCol 2-33 为 true,即每行只标记 32 列。但 ShiftWindow 的窗口中心(reg(2))落后 colOut 2 拍:
1 | outputCol=33: reg = [img_31, img_30, img_29, img_28, img_27] center = img_29 |
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 | }.otherwise { |
为什么不能直接扩大 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 | 0x0000 = 第 0 行首个像素 |
1 | row0 out: 0000 0000 0000 0001 0002 ... 001d ← 正确 |
row 6 的数据跳到了 row 5——错误跨越了整行。
追数据来源。 LineBuffer 在每行末尾将 5 行 buffer 上移:
1 | when (outputRow >= 2.U) { |
新数据只从一个地方进来:tmpRow。buffer 里有错,一定是 tmpRow 先错了。
tmpRow 在 sActive 期间逐像素从 DMA 加载:
1 | when (io.in.valid && io.in.ready) { |
io.in.ready 受 needLoad 控制,只看行号不看列号:
1 | val needLoad = outputRow >= 2 && outputRow + 3 < 32 |
逐拍模拟 outputRow=2。 DMA 发完 row 5 的 32 个像素后继续往前发——它不知道有 padding 列:
1 | outputCol: 0 1 2 3 ... 31 32 33 34 35 |
列 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 | // 修改前 |
outputCol 34-35 期间 inImage 为 false,不再加载,tmpRow 保持完整,行尾移位进入 buffer 的数据是正确的。
Phase 6:Chipyard 集成与 Verilator 构建
TODO
Phase 7:裸机 C 测试程序
TODO