基于 RISC-V RoCC 的卷积加速器
文章目录
背景
卷积是深度学习和图像处理里最常见的计算之一。问题在于,如果直接让 CPU 在一个 32×32 矩阵上跑 5×5 kernel,CPU 会反复做地址计算、循环控制、load/store 和乘加运算,cycle 很快就被这些重复工作吃掉。
这个项目的目标就是用 RISC-V 的 RoCC(Rocket Custom Coprocessor)接口做一个专门计算卷积的硬件加速器。CPU 只负责告诉加速器数据在哪里、什么时候开始、什么时候查询状态;真正的数据搬运和卷积计算都交给硬件完成。
- Repository:MyConvAccel
项目目标与难点
这个项目要做的是一个面向固定 5×5 卷积的硬件加速器,输入规模是 32×32。目标不是只让一个 standalone Chisel datapath 在 testbench 里跑通,而是把它进一步接入 Rocket/Chipyard,变成一个可以被 C 程序通过 RoCC 指令启动的系统级加速器。
真正的难点不只是“做 25 个乘加”。卷积加速器还必须处理滑动窗口的数据复用、DMA 读写、Q8.8 定点数计算、流水线之间的反压,以及 CPU 能看见的控制协议。
这个设计主要要打通三条路径:
- 计算路径:用 LineBuffer、ShiftWindow 和 MAC pipeline 完成 5×5 卷积。
- 内存路径:从内存读取 input/kernel,并把 output 写回软件可见的 buffer。
- 控制路径:Rocket 通过 RoCC custom instruction 设置地址、启动加速器并轮询状态。
预期结果是:CPU 不再逐像素执行卷积循环,而是只发少量 RoCC 指令;数据搬运、窗口构造、卷积计算和结果写回都由硬件自动完成。
Phase 0:方案规划 & 协议设计
在写 Chisel 之前,先把最基本的问题定下来:CPU 怎么控制加速器?软件要看到什么接口?硬件内部要支持哪些状态?
协议设计:RoCC 指令编码
从软件视角看,RoCC 协议并不复杂:CPU 通过 custom instruction 把寄存器里的值传给加速器,加速器处理完之后再通过 resp.data 返回确认值或状态值。
1 | Rocket Core (CPU) Accelerator |
因此,这个加速器采用三步流程:set address → trigger → poll。
CPU 先告诉加速器输入矩阵、kernel 和输出 buffer 在内存中的位置,再发出 START,最后轮询 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 |
为了让软件可以轮询加速器状态,硬件暴露了一个 4-bit status register:
| 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 而不是 interrupt,是因为 5×5 卷积的延迟短且可预测;如果为了这么短的任务引入中断控制器和上下文切换,反而可能得不偿失。为什么 funct7 连续编码而不是跳跃分配?
连续编码让硬件解码非常简单:一个
funct7 <= 4就能覆盖所有合法命令。跳跃编码没有带来额外收益,只会增加组合逻辑。
关键设计取舍
这几个设计选择会直接影响后面的 datapath 和控制逻辑,所以需要一开始就定好。
kernel 大小是固定还是可配置的?
如果支持可变 kernel,控制逻辑、窗口生成和计数边界都会复杂很多。这个项目最终固定为 5×5 硬件 datapath;如果软件侧有更小的 kernel,就先 zero-padding 到 5×5。
边界像素怎么处理?
使用 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 把 16 bit 分成两半:8 bit 整数部分(范围约为 ±128)和 8 bit 小数部分(精度 1/256)。
为什么 accumulator 是 32 位? 5×5 卷积要累加 25 个乘积。虽然单次 16-bit × 16-bit 乘法会产生 32-bit product,但 accumulator 的选择还要看 Q8.8 的数值范围。对于固定 5×5 datapath,signed 32-bit 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 次加法,合起来大约是 25,000 次乘加。听起来只是算术量大,但在 CPU 上,真正慢的往往不是乘加本身,而是“滑动窗口”这件事:每滑一次都要算地址偏移、更新循环变量、load 像素和权重、再 store 结果。大量指令其实花在循环控制和地址 bookkeeping 上。
粗略估计一下,在一颗简单的顺序 RISC-V 核上,每个输出像素可能要花 100~150 cycles。1024 个像素加起来就是 10 万到 15 万 cycles。而 RTL 仿真里,加速器完成同样的 32×32 case 只需要 2428 cycles,大致能看到 40× 到 50× 量级的加速空间。
把计算卸载进加速器中
把卷积卸载给加速器后,CPU 的角色就缩小成三件事:设置地址、发出 START、轮询状态。取数据、计算卷积、写回结果都由加速器自己完成。
加速器内部看上去是什么样的?
最朴素的做法是串行执行:DMA load → MAC compute → DMA store。这个设计很直观,但效率不高:DMA 搬数据时 MAC 在等,MAC 计算时 DMA 又可能空闲,同一时间经常只有一块硬件在干活。
实际实现里,我们尽量让这些阶段重叠。InputQueue 先吸收输入数据,LineBuffer 可以边加载边准备窗口;ConvEngine 是多级流水线,填满后基本能连续产出结果;StoreQueue 暂存计算结果,再由 DMA 写回内存。最终的性能提升就来自这些重叠,而不是单纯把乘法器换快。
模块划分
| Module | Role | Phase |
|---|---|---|
| ConvControl | RoCC decode + 4-state control FSM + status register | 2 |
| ConvDMA | 7-state DMA engine over TileLink | 3 |
| LineBuffer + ConvEngine | Sliding window + MAC pipeline, 6-stage compute datapath | 4 |
| InputQueue + StoreQueue | Elastic buffers with backpressure | 5 |
后面的几个 phase 会按模块展开,把每一块是怎么接起来的讲清楚。
Phase 2:ConvControl:指令解码与控制 FSM
Phase 1 里已经画出了整体架构。Phase 2 关注的是最上面的 RoCC control:CPU 发出带有 funct7 的 custom instruction,ConvControl 负责解码、执行,并在需要时通过 rd 返回结果。
接口
ConvControl 和 CPU 之间通过 valid/ready 握手通信。CPU 侧送来 instrCmd.valid、funct7、rs1 和 rd,ConvControl 返回 instrReady。只有当 valid 和 ready 在同一个 cycle 同时为高时,这条指令才真正被接受。
指令解码
解码逻辑只需要五个比较器:funct7 === 0.U 到 funct7 === 4.U。不需要 priority encoder,也不需要 lookup table。Phase 0 里选择连续编码的好处就在这里体现出来了:funct7 本身就可以当作命令编号。
- SET (0–2): 在 sBusy 状态下阻塞。在计算过程中更改地址会破坏运算。
- START (3): 仅在 sIdle 或 sDone 状态下接受。不能启动已在运行的加速器。
- POLL (4): 始终接受。这是一个纯读取操作——不会对任何操作进行干扰。
SET_ADDR:存储三个基地址
根据 funct7 的值,rs1 会被写入 addrIn、addrKer 或 addrOut。如果加速器处于 sError,任意一条 SET 指令都会清除错误状态,并把 FSM 拉回 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 引擎
核心思路: ConvDMA 把内存读写变成 valid/ready 数据流。串行版本方便验证;流水线版本通过重叠 request issue 和 response unpack 来提高吞吐。
卷积引擎需要不断从 SRAM 读取输入像素和 kernel 权重,最后还要把结果写回内存。ConvDMA 就是专门负责这些数据传输的模块。
Phase 3 先从最容易验证的串行 DMA 开始:同一时间只允许一个 memory request 在路上。这样 FSM 足够简单,测试时也容易逐拍对齐。确认串行版本正确之后,再考虑把请求发射和响应处理流水线化。
DMA 接口定义
DMA 和 L1 data cache 之间用三个 Bundle 描述接口。这些 Bundle 只定义信号本身,不包含控制逻辑;真正的数据是否能在某一拍传过去,仍然由 Decoupled 的 valid/ready 握手决定。
- SimpleMemReq — DMA 发给 L1 的请求,包含 64-bit 地址、64-bit 写数据、byte mask、读写标志和 4-bit tag。串行版本里 tag 恒为 0,后面做流水线时可以用 tag 匹配响应。
- SimpleMemResp — L1 返回给 DMA 的响应,包含 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 区分当前是在读还是在写。串行版本的规则很简单:发一个请求,等它完成,再处理数据,然后才发下一个请求。因此任意时刻最多只有一个 request in flight。
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
流水线化的核心就是把 issue 和 unpack 拆开,让它们变成两个可以同时工作的硬件过程。
1 | Serial: |
中间的 response FIFO 用来吸收速率差:issue engine 可以按 1 word/cycle 发请求,直到达到 inflight 上限;response 回来后先进入 FIFO;unpack engine 再以 4 cycles/word 的速度从 FIFO 里取出并拆成 16-bit 元素。这样 issue 和 unpack 不再互相等待,load 部分从 1537 cycles 降到约 1033 cycles,提升大约 33%。
串行到流水线的四项改动
1. Register → Queue
串行 DMA 只需要一个 respWord 寄存器,因为同一时间最多只有一个响应会回来。流水线版本则换成 response FIFO,可以缓存多个响应。FSM 不再直接盯着 mem.resp,而是在需要 unpack 时从 FIFO 里取数据。
2. sWaitResp 退化
sWaitResp 不再负责等待 mem.resp.valid,也不再负责锁存响应数据。响应已经由 FIFO 接住了,FSM 只需要等 FIFO 里有数据可取。
3. inflightCount — 信用制流控
issue engine 的发射速度是 1 word/cycle,而 unpack engine 的消费速度是 1 element/cycle,也就是 0.25 word/cycle。如果不限制,FIFO 很快会被塞满。inflightCount 就像一个 credit counter:issue 每发一个请求就占用一个 credit,unpack 每拆完一个 word 就归还一个 credit。inflightMax = 4 把同时在路上的请求限制在 4 个 word 以内。
4. sLoadActive — 并发 FSM(核心改动)
把串行版本里的 sIssue、sWaitResp、sUnpack 合并成一个 sLoadActive 状态。在这个状态里,两个独立的 when 块同时工作:issue engine 负责继续发请求,unpack engine 负责从 FIFO 里取数据拆包。两者可以在同一个 cycle 同时前进,这才是真正的 overlap。
Phase 4:计算数据通路 — LineBuffer & ConvEngine
核心思路: DMA 输入是 row-major 的单像素流,而卷积需要 5×5 window。LineBuffer 负责跨行复用,ShiftWindow 负责跨列拼窗,ConvUnit 负责流水线乘加。
DMA 每次送进来的只是一个像素,而且顺序是 row-major:第 0 行从左到右,然后第 1 行、第 2 行继续往下。但 5×5 卷积在计算输出像素 (r, c) 时,需要的是一个完整的 5×5 邻域。也就是说,单个像素本身并不能直接喂给 MAC 单元,datapath 必须先把连续输入流重组成一个 5×5 window。
这本质上是一个数据重排问题,可以自然拆成两个方向:LineBuffer 处理垂直方向,也就是行;ShiftWindow 处理水平方向,也就是列。LineBuffer 从 DMA stream 里收集 5 行像素,每拍输出同一列上的 5 个相邻像素;ShiftWindow 再缓存连续 5 列,最终拼出完整的 5×5 window。
Part A:LineBuffer
为什么不直接从 SRAM 读? 因为每个输出像素都要访问 5 个不同行的值。如果每拍都直接去 SRAM 里取,就相当于需要 5 个独立读端口,这对硬件来说很贵。LineBuffer 用一个写端口接收 DMA 输入,再用 5 路读出同一列的 5 行数据。160 个 entry 的寄存器堆,比一个 5-port SRAM 简单得多。
1 | sIdle ──► sPrime (加载前 5 行) ──► sActive (32 个输出行) ──► sDone |
在 sActive 状态下,buffer 每个输出行会吐出 36 列:左边 2 列 padding,中间 32 列真实图像,右边 2 列 padding。与此同时,DMA 会把下一行输入加载到单独的 tmpRow 里。等当前输出行结束,5 行 buffer 整体上移:第 0 行丢弃,第 1 到第 3 行上移,tmpRow 进入第 4 行。如果没有 tmpRow,DMA 就可能覆盖还在被读取的行,load 和 output 也没法重叠。
zero-padding 由两部分共同完成:
- 上 / 下: 由 5 行 buffer 里当前存的内容决定。输出第 0 行时,顶部两行是 0;输出第 31 行时,底部两行是 0。窗口往下滑时,真实行自然轮转进出,不需要额外分支。
- 左 / 右: 每个输出行输出 36 列(2 列左 padding + 32 列图像 + 2 列右 padding)。
colValid用来标记当前列是否是真实图像列;为低时,ShiftWindow不使用colOut,而是移入 0。
Part B:ShiftWindow → KernelROM → ConvUnit
LineBuffer 每拍只输出 5 个像素,也就是一列。MAC 单元需要的是完整的 5×5 window,所以还需要下面三个模块把最后一步补上:
ShiftWindow — 5×5 寄存器窗口。 内部维护一个 5×5 register array。每拍所有列右移:最旧的一列丢掉,新列从 LineBuffer 进入 c0。colValid 为低时,新列填 0,用来实现左右 padding。整个窗口通过组合逻辑输出,只有 400 bit 寄存器,比 BRAM 更轻,也没有读延迟。
KernelROM — 权重存储。 25 个 entry 的寄存器堆。计算开始前一次性写入,计算期间只读,所以对 compute datapath 来说等价于 ROM。它以组合逻辑输出 5×5 kernel,ConvUnit 可以在同一拍拿到 window 和 kernel。
ConvUnit — 5 级流水线 MAC 树。 如果把 25 次乘法和整棵加法树都放在一个组合路径里,critical path 会非常长。这里把 pairwise addition tree 切成 5 级 pipeline,每一级只承担一次 32-bit 加法。
1 | Stage 0(组合):25 个 16×16→32 并行乘法 |
这里没有用 Wallace tree,是因为 pairwise tree 结构更规整,深度正好是 ceil(log₂ 25) = 5 级,pipeline register 也很自然地插在每一级之间。这样 critical path 基本被压到一次 32-bit addition。
ConvEngine — 顶层黏合。 ConvEngine 把 ShiftWindow、KernelROM 和 ConvUnit 接起来:colIn / colValid 进入 ShiftWindow,window 和 kernel 再送进 ConvUnit。inValid 通过 RegNext 延迟 1 拍,用来对齐 ShiftWindow 的寄存器输出;outValid 则是 inValid 再经过 5 拍延迟,对齐 MAC pipeline 的输出。当 stall 拉高时,整条 compute pipeline 会被冻结,避免下游队列满了还继续产出结果。
Phase 5:顶层集成与主 FSM
核心思路: 顶层 FSM 只负责阶段切换,不逐拍调度每个像素。某一拍数据能不能前进,交给 Queue 和 valid/ready 握手决定。
Phase 1 到 Phase 4 已经把主要模块各自做出来了。Phase 5 的任务是把它们接成一个完整的 standalone top:
- 实例化
ConvDMA、LineBuffer、ConvEngine和两个 elastic Queue。 - 加一个 5-state execution FSM,负责任务阶段切换:load kernel、load input、compute/store。
- 对外暴露
SimpleMemIO,standalone 测试时接 fake scratchpad,上 Chipyard 后再接到 RoCC/DCache 路径。
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 里实现的 datapath 模块,后两个直接使用 Chisel 标准库里的 Queue。
1 | val dma = Module(new ConvDMA) // Phase 3 |
Queue 是 Chisel 的标准 FIFO。它内部会自动维护读写指针、计数器和 valid/ready 反压逻辑,因此这里不需要手写 FIFO。
1. io.mem ↔ DMA
1 | io.mem <> dma.io.mem |
<> 是 Chisel 的 bulk connect 操作符。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 个像素全部缓冲起来。
由于同一时间只有一个 FSM 状态处于活跃状态,用 when / elsewhen 分支就够了,不需要额外的 arbiter。
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 会拉低,队列停止出队,反压就会沿着 compute pipeline 一路往上传。
InputQueue 与 StoreQueue —— 弹性缓冲
DMA 和 ConvEngine 的节奏不一样。DMA 是 burst 型传输:快,但不是每一拍都稳定输出。ConvEngine 则更像一条固定节拍的流水线:一旦填满,就希望每拍都能产出一个像素。中间如果没有缓冲,任何一点速度不匹配都会让整条 pipeline 停下来,甚至丢数据。
所以这里在模块之间放了两个 Chisel Queue,用来吸收速率差。Queue 本质上就是一个标准 FIFO,内部有环形 buffer、读写指针和计数器。对外只有两个口:enq 是写入端,deq 是读出端,valid/ready 握手由它自动处理。
- Queue 空:
deq.valid= 0(没数据可读)。 - Queue 满:
enq.ready= 0(没空间可写)。 - Queue 既不空也不满:
enq.ready和deq.valid同时为 1——数据可以一边进一边出。
这也是直接用标准库的好处:我们只需要关心数据应该流向哪里,不需要再手写 FIFO 的边界条件。
inputQueue
1 | val inputQueue = Module(new Queue(UInt(16.W), 1024)) |
深度 1024 正好是一整张 32×32 图。DMA 在 sLoadInput 阶段把输入像素写进去,LineBuffer 可以同时或稍后从里面一拍一个取走。两边不用严格同步。
storeQueue
1 | val storeQueue = Module(new Queue(SInt(16.W), 2048)) |
深度 2048 足够放下 1088 个结果,并留出额外余量。ConvEngine 可以一拍塞一个结果;DMA 如果暂时忙不过来,结果就先堆在 storeQueue 里,等 DMA 空出来再写回内存。
反压链
硬件里的 backpressure 不是“发消息通知”,而是直接接线。当 storeQueue 满了,它的 enq.ready 会从 1 变成 0。下面两个模块直接看这根线:
1 | engine.io.stall := !storeQueue.io.enq.ready |
enq.ready 一旦拉低,ConvEngine 和 LineBuffer 同一拍就能看到。接下来反压会沿着 pipeline 往上游传:
1 | storeQueue 满 |
这里没有软件参与,也不需要额外控制信号。等 DMA 追上进度、storeQueue 腾出空间,enq.ready 回到 1,pipeline 就会自动继续流动。
主执行 FSM
Master FSM 并不会逐拍安排每一个 pixel 或每一个 convolution window。它做的是更粗粒度的阶段控制:当前是 load kernel、load input,还是 compute/store。至于某一拍数据能不能真的往前走,则交给模块之间的 valid / ready 握手决定。
在 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。也就是说,输入 DMA 还在加载后面几行时,compute pipeline 已经可以提前启动。
在 sCompute 阶段,输入 DMA 已经结束,DMA 切换到 store 模式。InputQueue、LineBuffer、ConvEngine 和 StoreQueue 里剩下的数据继续向后排空;同时,StoreQueue 把结果送给 DMA store stream。这样 compute tail 和 output writeback 也能重叠。
1 | sLoadKernel: |
端到端数据流走读
下图把一次完整卷积的生命周期按时间轴展开:哪些阶段在跑,哪些队列在进出数据,哪些 pipeline 正在排空,都可以放在一张图里看。
1 | time ───────────────────────────────────────────────────────────────────────────────> |
性能提升主要来自三处 overlap:
| 重叠 | 说明 | 阶段 |
|---|---|---|
| 加载 ↔ 计算 | DMA 写 InputQueue,同时 LineBuffer 从中取数据 |
sLoadInput |
| 计算 ↔ 写回 | ConvEngine 产结果,同时 DMA 从 StoreQueue 写回 |
sCompute |
| 管线排空 | DMA load 已结束,compute pipeline 继续 drain | sCompute 尾部 |
RoCC 响应协议
指令编码和软件可见的 status bits 已经在 Phase 0 定义,并由 Phase 2 的 ConvControl 实现。到顶层集成时,我们只需要确保这些响应能正确接到 RoCC response channel 上。
一次 RoCC 响应的完成条件是:
1 | io.resp.fire = io.resp.valid && io.resp.ready |
1 | io.resp.bits.rd // 原始指令中的 rd |
加速器使用三种响应模式:
SET_ADDR_*:地址寄存器更新后立即响应,返回一个确认值即可,因为这类指令只修改配置状态。START_ACCEL:同样立即响应,但这个响应只表示“启动请求已被接受”,不表示卷积已经完成。接受后 master FSM 进入活跃状态,io.busy保持高,直到运行结束。POLL_STATUS:软件用它观察加速器是否完成。响应数据来自 status register,包含busy、done、overflow、addr_err等位。
顶层 io.busy 由主 FSM 驱动:
1 | io.busy = state =/= sIdle && state =/= sDone |
当前设计使用 polling,而不是 interrupt,所以 io.interrupt 一直保持低。如果后面要加中断,可以在 FSM 进入 sDone 时拉高。
下面两个 bug 保留下来,是因为它们记录了这个项目里最容易忘的时序教训:数据走到了正确位置,并不等于 valid 信号也已经和它对齐。
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),它比刚进入窗口的新列慢 2 拍:
1 | outputCol=33: reg = [img_31, img_30, img_29, img_28, img_27] center = img_29 |
也就是说,img_30 和 img_31 明明已经滑到窗口中心,卷积结果也已经算出来了,但 colValid 提前关掉,导致这些结果没有被标记为有效。每行丢 2 个,32 行刚好丢 64 个。
根因。 colValid = inImage 把两件事混在了一起:当前 colOut 是不是图像列,以及窗口中心是不是还持有有效像素。outputCol = 34..35 时,colOut 应该是 0,这是正确的 right padding;但 img_30、img_31 还在窗口里向中心移动,MAC pipeline 也已经在算,只是 valid 信号提前断了。
修复。 在 right padding 区域把 colValid 多保持 2 拍,让窗口尾部的有效像素有时间排空:
1 | }.otherwise { |
为什么不能直接把 inImage 扩到 35?因为 bufCol = (outputCol - 2.U)(4,0) 在 outputCol = 34 时得到 32,5-bit 截断后会回卷到 0,结果从 buffer 里读出错误数据。正确做法是:padding 区域的 colOut 仍然填 0,但 colValid 单独延长。这样就把“从 buffer 读什么”和“pipeline 是否继续前进”拆开了。
修复后,每行会有 34 个 colValid,32 行一共 1088 个 outValid。其中前 2 个是 pipeline fill bubble,中间是有效结果,最后 2 个用于 drain window 尾部。
| 位置 | 修改前 | 修改后 |
|---|---|---|
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,而且不是随机错。前 5 行(row0 到 row4)输出正确,从 row5 开始整行都偏了。
定位。 一开始只打印前 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 里出现了 row 6 的数据,问题一定先发生在 tmpRow。
tmpRow 在 sActive 期间逐像素从 DMA 加载:
1 | when (io.in.valid && io.in.ready) { |
当时 io.in.ready 只受 needLoad 控制,而 needLoad 只看行号,不看列号:
1 | val needLoad = outputRow >= 2 && outputRow + 3 < 32 |
逐拍模拟 outputRow=2。 DMA 发完 row 5 的 32 个像素后会继续往前发,因为 DMA 本身不知道 LineBuffer 每行还有 padding 列:
1 | outputCol: 0 1 2 3 ... 31 32 33 34 35 |
在列 32..35 期间,loadCol 已经回卷到 0,但 needLoad 仍然是 true。此时 DMA 已经开始发送 row 6 的像素,于是这些像素覆写了 tmpRow 前几个位置。行尾执行 buffer(4) := tmpRow 时,污染后的 tmpRow 被拉进 buffer。再经过几轮上移,错误数据就会在 outputRow = 5 暴露出来。
根因。 loadCol 是按 32 回卷的,但 outputCol 是按 36 回卷的。每行多出来的 4 个 padding 列形成了一个危险窗口:DMA 已经进入下一行,loadCol 也回到了 0,但 needLoad 还在放行输入。
修复。 让 DMA input 只在真实图像列期间被接收:
1 | // 修改前 |
这样在 outputCol = 34..35 期间,inImage 为 false,LineBuffer 不再接收新的 DMA 数据,tmpRow 就不会被下一行提前覆写。
Phase 6:Chipyard 集成与 Verilator 构建
核心思路: RoCC wrapper 不重写 standalone datapath,而是在外面接上 CPU command path,并把加速器原来的简单内存接口转换成 Rocket 的 DCache 接口。
到 Phase 5 为止,加速器仍然是一个 standalone Chisel module。测试时,testbench 直接拉高 start,直接提供输入矩阵、kernel 和输出矩阵的基地址,并把 SimpleMemIO 接到一个模拟内存上。这样可以验证 datapath 是否正确,但它还不是一个真正由处理器控制的加速器。
Phase 6 的目标是把 standalone 设计移植到 Chipyard 里,让 Rocket core 执行裸机 C 程序,并通过 custom RoCC 指令控制卷积加速器。也就是说,软件侧仍然使用 Phase 0 里定义的 SET_ADDR_IN、SET_ADDR_KER、SET_ADDR_OUT、START 和 POLL_STATUS 接口,但这些命令现在会真的从 CPU 发到 RoCC accelerator。
1 | [C program] |
Chipyard 提供了什么
Chipyard 不只是一个用来仿真单个 Chisel module 的工具。它会围绕 Rocket core 生成一个完整的 RISC-V SoC,其中包括:
- Rocket CPU,用于运行 C benchmark。
- L1 instruction cache 和 data cache。
- cache 背后的内存系统。
- 外设支持,例如 bare-metal 程序里的
printf。 - RoCC interface,用于连接 custom accelerator。
- Verilator flow,用于全系统逐周期仿真。
对于这个项目来说,最重要的几个部分是 Rocket、RoCC、DCache 和 Verilator。Rocket core 负责执行 C benchmark;RoCC 负责承载 CPU 发给加速器的 custom command;DCache 负责让加速器访问内存;Verilator 则负责对整个系统进行 cycle-level 仿真。
注册加速器
卷积加速器是通过 Chipyard 的 config fragment 挂到 Rocket core 旁边的:
1 | class WithConvAccel extends Config((site, here, up) => { |
BuildRoCC 是 Rocket tile 里的一个配置入口,用来决定这个 Rocket 核心旁边要挂哪些 RoCC 加速器。up(BuildRoCC) 会先保留基础配置里已经定义好的 RoCC 加速器,++ Seq(...) 再把我们的卷积加速器追加进去。因此这段代码不是替换原来的 RoCC 列表,而是在原有配置上多挂了一个 ConvAccelRoCC。
OpcodeSet.custom0 是软件和硬件之间的桥梁。在 C 程序中,ROCC_INSTRUCTION_SS(0, ...) 会生成一条 custom0 指令;在硬件中,OpcodeSet.custom0 则告诉 Rocket:遇到这些 custom0 指令时,把它们路由到 ConvAccelRoCC。
LazyRoCC 包装
RoCC wrapper 分成两层。ConvAccelRoCC 是外层的 LazyRoCC 声明,告诉 Rocket 这个加速器存在,以及它监听哪一组 opcode。ConvAccelRoCCModule 才是真正包含硬件逻辑的实现层。
1 | class ConvAccelRoCC(opcodes: OpcodeSet)(implicit p: Parameters) |
在 module 内部,会实例化两个重要模块:
1 | val control = Module(new ConvControl) |
ConvControl 负责 command decode、地址寄存器和 status bits。ConvAccelTop 则是前面 standalone 阶段已经完成的卷积 datapath。换句话说,RoCC wrapper 没有重写卷积计算逻辑,而是在 standalone datapath 外面加了一层“CPU 可控制”的外壳。
命令路径
当 Rocket 解码到一条 custom0 指令时,它会把这条指令里的关键字段拆出来,并打包成 RoCC 接口上的命令包,也就是 io.cmd.bits。
1 | io.cmd.bits.inst.funct // funct7 command number |
这条命令只有在 valid 和 ready 同时为高时才会被真正接收:
1 | io.cmd.ready := control.io.instrReady |
cmdFire 表示 RoCC 命令已经完成握手,并在当前 cycle 真正进入加速器。之后 RoCC wrapper 会把这条命令中已经解码好的字段继续转发给 ConvControl,让 ConvControl 根据 funct 判断这是设置 input 地址、设置 kernel 地址、设置 output 地址、启动计算,还是查询状态。
1 | control.io.instrCmd.valid := cmdFire |
内存路径
Standalone 加速器使用的是项目里定义的 SimpleMemIO,它只描述了一个简单的 memory request/response 接口。进入 RoCC 之后,真正能访问内存的是 Rocket 暴露出来的 DCache 接口,也就是 io.mem。因此 RoCC wrapper 需要在这两种接口之间做一次转换。
1 | io.mem.req.valid := accel.io.mem.req.valid |
这里最关键的是前两行:
1 | io.mem.req.valid := accel.io.mem.req.valid |
第一行表示:只要 ConvAccelTop 发出了有效的 memory request,wrapper 就把这个请求继续转发给 DCache。第二行表示:wrapper 不会在“看到请求”时就立刻告诉 ConvAccelTop 请求成功,而是要等 DCache 真的接收这条请求之后,才把 ready 返回给 ConvAccelTop。
io.mem.req.fire 等价于:
1 | io.mem.req.valid && io.mem.req.ready |
因此,io.mem.req.valid 只是表示“加速器手里有请求要发”,而 io.mem.req.fire 才表示“这一拍 DCache 已经接收了这条请求”。把 fire 反馈成 accel.io.mem.req.ready,可以保证 ConvDMA 只有在 cache 侧真的完成握手之后才继续前进。这样一来,ConvDMA 仍然可以认为自己在和一个简单的 64-bit memory interface 通信,但这个请求实际会被 wrapper 翻译成 Rocket DCache request。
响应路径则是反方向:
1 | accel.io.mem.resp.valid := io.mem.resp.valid |
完成这一阶段集成后,加速器就可以读取 C 程序中分配的 input 和 kernel 数组,并把卷积结果写回软件可见的 output buffer。此时设计已经从“testbench 驱动的 standalone module”变成了“Rocket CPU 通过 RoCC 控制的系统级加速器”。
Phase 7:裸机 C 测试程序
核心思路: C 程序就是 Phase 0 协议的软件侧实现:准备 input/kernel,发 custom0 RoCC 指令,轮询 done,最后把硬件输出和软件 reference 对比。
加速器接入 Chipyard 之后,下一步就是从软件侧真正控制它。这里使用的是运行在 Rocket core 上的 bare-metal C benchmark。它不是通过 device driver 或 MMIO register 访问加速器,而是直接发 RoCC custom instruction。
RoCC 软件封装
C 程序把五条 RoCC 命令封装成几个很薄的 helper function:
1 | static inline void set_addr_in(uint64_t addr) { |
这里最容易混的是两个位置的 0。第一个参数 0 表示使用 custom0 opcode;最后一个参数才是 funct7,也就是 ConvControl 内部用来区分命令类型的字段。
| C helper | Opcode set | funct7 | 含义 |
|---|---|---|---|
set_addr_in(addr) |
custom0 |
0 | 写入 input base address |
set_addr_ker(addr) |
custom0 |
1 | 写入 kernel base address |
set_addr_out(addr) |
custom0 |
2 | 写入 output base address |
start_accel() |
custom0 |
3 | 启动加速器 |
poll_status() |
custom0 |
4 | 读取 status register |
也就是说,Phase 0 里定义的指令协议,Phase 6 里接入的 RoCC wrapper,在这里终于从 C 程序里被真正调用起来。
Benchmark 流程
benchmark 会先跑一遍软件卷积作为 reference,再用同一组 input 和 kernel 启动硬件加速器:
1 | sw_start = rdcycle(); |
rdcycle 用来读取 RISC-V cycle counter。软件卷积和硬件加速器都用同一个方法计时,这样可以直接比较 cycle 数。
fence rw, rw 也很关键。启动 accelerator 之前,CPU 已经往 input 和 kernel buffer 写入了测试数据。由于 CPU 和 accelerator 共享内存,fence 可以保证这些写入在 accelerator 读内存之前已经对内存系统可见。
启动之后,C 程序不断调用 poll_status(),直到 status register 的 bit 1 被置位:
1 | status & 0x2 -> done |
当 done 出现后,程序再把硬件输出 buffer 和软件 reference 逐项比较。这个阶段验证了两件事:Rocket 可以通过 RoCC 控制加速器;加速器也可以正确读取 C 程序分配的内存,并把结果写回软件可见的 output buffer。
Phase 8:性能报告与总结
核心思路: 这个加速比来自把完整的卷积数据流搬进专用硬件,而不是把某一条 CPU 指令换成更快的一条指令。
最后,用同一个 bare-metal benchmark 对比软件卷积和 RoCC accelerator 的 cycle 数。
| Version | Cycles |
|---|---|
| Software convolution | 586,893 |
| RoCC accelerator | 3,312 |
| Speedup | 177.20× |
| Cycle reduction | 99.43% |
软件版本的大部分时间都花在嵌套循环里:地址计算、边界判断、load、multiply、add、store 和 branch。加速器把这些重复工作从 CPU 中移走,让 CPU 只负责少量 setup、start 和 polling 指令。
加速并不是来自某一个更快的 multiplier,而是来自把完整的 convolution dataflow 搬进专用硬件:
LineBuffer和ShiftWindow复用输入像素,避免反复读取重叠窗口。ConvUnit把 25 路 MAC tree 做成流水线。InputQueue和StoreQueue吸收 DMA 和 compute 之间的速率差。- RoCC 让 CPU 只需要发少量 custom instruction,而不是逐像素参与计算。
到这里,整个项目已经不只是一个 standalone Chisel module。它已经变成了一个可以由 Rocket CPU 通过 RoCC 控制的系统级加速器:软件负责配置和启动,硬件负责内存访问、卷积计算和结果写回。
参考文献
[1] Chipyard, “Adding a RoCC Accelerator,” Chipyard Documentation. [Online]. Available: https://chipyard.readthedocs.io/en/stable/Customization/RoCC-Accelerators.html. Accessed: Jun. 25, 2026.
[2] CHIPS Alliance, “Rocket Chip Generator,” GitHub repository. [Online]. Available: https://github.com/chipsalliance/rocket-chip. Accessed: Jun. 25, 2026.
[3] RISC-V International, “RISC-V Instruction Set Manual,” GitHub repository. [Online]. Available: https://github.com/riscv/riscv-isa-manual. Accessed: Jun. 25, 2026.
[4] Chisel, “Interfaces and Connections,” Chisel Documentation. [Online]. Available: https://www.chisel-lang.org/docs/explanations/interfaces-and-connections. Accessed: Jun. 25, 2026.
[5] Verilator, “Verilator User’s Guide,” Verilator Documentation. [Online]. Available: https://verilator.org/guide/latest/. Accessed: Jun. 25, 2026.
[6] S. Eldridge, “rocket-rocc-examples,” GitHub repository. [Online]. Available: https://github.com/seldridge/rocket-rocc-examples. Accessed: Jun. 25, 2026.