背景

卷积是深度学习和图像处理里最常见的计算之一。问题在于,如果直接让 CPU 在一个 32×32 矩阵上跑 5×5 kernel,CPU 会反复做地址计算、循环控制、load/store 和乘加运算,cycle 很快就被这些重复工作吃掉。

这个项目的目标就是用 RISC-V 的 RoCC(Rocket Custom Coprocessor)接口做一个专门计算卷积的硬件加速器。CPU 只负责告诉加速器数据在哪里、什么时候开始、什么时候查询状态;真正的数据搬运和卷积计算都交给硬件完成。

项目目标与难点

这个项目要做的是一个面向固定 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
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
| <----------------------------------- |

因此,这个加速器采用三步流程:set address → trigger → poll

CPU 先告诉加速器输入矩阵、kernel 和输出 buffer 在内存中的位置,再发出 START,最后轮询 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

为了让软件可以轮询加速器状态,硬件暴露了一个 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

设计决策

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

    一条 RoCC 指令只有两个源操作数(rs1rs2),没法一次塞下三个 base address。另一种做法是传一个结构体指针,让加速器再去内存里读配置,但这样会多一次 DMA 访问,也会让控制逻辑变复杂。这里选择三条独立指令,一条指令只设置一个地址,硬件接口最直接。

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

    non-blocking 的好处是 CPU 发出 START_ACCEL 之后不需要卡在原地等结果。这里选择 polling 而不是 interrupt,是因为 5×5 卷积的延迟短且可预测;如果为了这么短的任务引入中断控制器和上下文切换,反而可能得不偿失。

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

    连续编码让硬件解码非常简单:一个 funct7 <= 4 就能覆盖所有合法命令。跳跃编码没有带来额外收益,只会增加组合逻辑。

关键设计取舍

这几个设计选择会直接影响后面的 datapath 和控制逻辑,所以需要一开始就定好。

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

    如果支持可变 kernel,控制逻辑、窗口生成和计数边界都会复杂很多。这个项目最终固定为 5×5 硬件 datapath;如果软件侧有更小的 kernel,就先 zero-padding 到 5×5。

  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 把 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 已经有足够余量。

  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 次加法,合起来大约是 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.validfunct7rs1rdConvControl 返回 instrReady。只有当 validready 在同一个 cycle 同时为高时,这条指令才真正被接受。

指令解码

解码逻辑只需要五个比较器:funct7 === 0.Ufunct7 === 4.U。不需要 priority encoder,也不需要 lookup table。Phase 0 里选择连续编码的好处就在这里体现出来了:funct7 本身就可以当作命令编号。

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

SET_ADDR:存储三个基地址

根据 funct7 的值,rs1 会被写入 addrInaddrKeraddrOut。如果加速器处于 sError,任意一条 SET 指令都会清除错误状态,并把 FSM 拉回 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 的路径。

核心思路: 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
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 区分当前是在读还是在写。串行版本的规则很简单:发一个请求,等它完成,再处理数据,然后才发下一个请求。因此任意时刻最多只有一个 request in flight。

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

流水线化的核心就是把 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) ──↑

中间的 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(核心改动)

把串行版本里的 sIssuesWaitRespsUnpack 合并成一个 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 可以在同一拍拿到 windowkernel

ConvUnit — 5 级流水线 MAC 树。 如果把 25 次乘法和整棵加法树都放在一个组合路径里,critical path 会非常长。这里把 pairwise addition tree 切成 5 级 pipeline,每一级只承担一次 32-bit 加法。

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

这里没有用 Wallace tree,是因为 pairwise tree 结构更规整,深度正好是 ceil(log₂ 25) = 5 级,pipeline register 也很自然地插在每一级之间。这样 critical path 基本被压到一次 32-bit addition。

ConvEngine — 顶层黏合。 ConvEngineShiftWindowKernelROMConvUnit 接起来:colIn / colValid 进入 ShiftWindowwindowkernel 再送进 ConvUnitinValid 通过 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:

  • 实例化 ConvDMALineBufferConvEngine 和两个 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
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 里实现的 datapath 模块,后两个直接使用 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。它内部会自动维护读写指针、计数器和 valid/ready 反压逻辑,因此这里不需要手写 FIFO。

1. io.mem ↔ DMA

1
io.mem <> dma.io.mem

<> 是 Chisel 的 bulk connect 操作符。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 个像素全部缓冲起来。

由于同一时间只有一个 FSM 状态处于活跃状态,用 when / elsewhen 分支就够了,不需要额外的 arbiter。

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 会拉低,队列停止出队,反压就会沿着 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.readydeq.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
2
engine.io.stall  := !storeQueue.io.enq.ready
lineBuf.io.stall := !storeQueue.io.enq.ready

enq.ready 一旦拉低,ConvEngineLineBuffer 同一拍就能看到。接下来反压会沿着 pipeline 往上游传:

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,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 模式。InputQueueLineBufferConvEngineStoreQueue 里剩下的数据继续向后排空;同时,StoreQueue 把结果送给 DMA store stream。这样 compute tail 和 output writeback 也能重叠。

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

端到端数据流走读

下图把一次完整卷积的生命周期按时间轴展开:哪些阶段在跑,哪些队列在进出数据,哪些 pipeline 正在排空,都可以放在一张图里看。

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 ┌──
└──

性能提升主要来自三处 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
2
io.resp.bits.rd   // 原始指令中的 rd
io.resp.bits.data // 确认值或状态值

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

  • SET_ADDR_*:地址寄存器更新后立即响应,返回一个确认值即可,因为这类指令只修改配置状态。

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

  • POLL_STATUS:软件用它观察加速器是否完成。响应数据来自 status register,包含 busydoneoverflowaddr_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 往前追,最后问题落在 LineBuffercolValid = inImage 上。inImage 只在 outputCol = 2..33 为 true,也就是每行只标记 32 列。但 ShiftWindow 的窗口中心是 reg(2),它比刚进入窗口的新列慢 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_30img_31 明明已经滑到窗口中心,卷积结果也已经算出来了,但 colValid 提前关掉,导致这些结果没有被标记为有效。每行丢 2 个,32 行刚好丢 64 个。

根因。 colValid = inImage 把两件事混在了一起:当前 colOut 是不是图像列,以及窗口中心是不是还持有有效像素。outputCol = 34..35 时,colOut 应该是 0,这是正确的 right padding;但 img_30img_31 还在窗口里向中心移动,MAC pipeline 也已经在算,只是 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 里读出错误数据。正确做法是: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
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 里出现了 row 6 的数据,问题一定先发生在 tmpRow

tmpRowsActive 期间逐像素从 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.ready 只受 needLoad 控制,而 needLoad 只看行号,不看列号:

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

逐拍模拟 outputRow=2。 DMA 发完 row 5 的 32 个像素后会继续往前发,因为 DMA 本身不知道 LineBuffer 每行还有 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 前几个位置。行尾执行 buffer(4) := tmpRow 时,污染后的 tmpRow 被拉进 buffer。再经过几轮上移,错误数据就会在 outputRow = 5 暴露出来。

根因。 loadCol 是按 32 回卷的,但 outputCol 是按 36 回卷的。每行多出来的 4 个 padding 列形成了一个危险窗口:DMA 已经进入下一行,loadCol 也回到了 0,但 needLoad 还在放行输入。

修复。 让 DMA input 只在真实图像列期间被接收:

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

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

这样在 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_INSET_ADDR_KERSET_ADDR_OUTSTARTPOLL_STATUS 接口,但这些命令现在会真的从 CPU 发到 RoCC accelerator。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[C program]
|
| custom0
v
[Rocket CPU] -- RoCC cmd/resp --> [ConvAccelRoCC]
|-- [ConvControl]
| decode / address / status
|
`-- [ConvAccelTop]
standalone core
|
| RoCC mem
v
[DCache] -> [Memory]

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
2
3
4
5
6
7
8
9
class WithConvAccel extends Config((site, here, up) => {
case BuildRoCC =>
up(BuildRoCC) ++ Seq(
(p: Parameters) => {
val accel = LazyModule(new ConvAccelRoCC(OpcodeSet.custom0)(p))
accel
}
)
})

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
2
3
4
5
class ConvAccelRoCC(opcodes: OpcodeSet)(implicit p: Parameters)
extends LazyRoCC(opcodes) {

override lazy val module = new ConvAccelRoCCModule(this)
}

在 module 内部,会实例化两个重要模块:

1
2
val control = Module(new ConvControl)
val accel = Module(new ConvAccelTop)

ConvControl 负责 command decode、地址寄存器和 status bits。ConvAccelTop 则是前面 standalone 阶段已经完成的卷积 datapath。换句话说,RoCC wrapper 没有重写卷积计算逻辑,而是在 standalone datapath 外面加了一层“CPU 可控制”的外壳。

命令路径

当 Rocket 解码到一条 custom0 指令时,它会把这条指令里的关键字段拆出来,并打包成 RoCC 接口上的命令包,也就是 io.cmd.bits

1
2
3
4
io.cmd.bits.inst.funct  // funct7 command number
io.cmd.bits.rs1 // rs1 value, usually an address
io.cmd.bits.rs2 // rs2 value, unused here
io.cmd.bits.inst.rd // destination register for response

这条命令只有在 validready 同时为高时才会被真正接收:

1
2
io.cmd.ready := control.io.instrReady
val cmdFire = io.cmd.valid && io.cmd.ready

cmdFire 表示 RoCC 命令已经完成握手,并在当前 cycle 真正进入加速器。之后 RoCC wrapper 会把这条命令中已经解码好的字段继续转发给 ConvControl,让 ConvControl 根据 funct 判断这是设置 input 地址、设置 kernel 地址、设置 output 地址、启动计算,还是查询状态。

1
2
3
4
control.io.instrCmd.valid  := cmdFire
control.io.instrCmd.funct7 := io.cmd.bits.inst.funct
control.io.instrCmd.rs1 := io.cmd.bits.rs1
control.io.instrCmd.rd := io.cmd.bits.inst.rd

内存路径

Standalone 加速器使用的是项目里定义的 SimpleMemIO,它只描述了一个简单的 memory request/response 接口。进入 RoCC 之后,真正能访问内存的是 Rocket 暴露出来的 DCache 接口,也就是 io.mem。因此 RoCC wrapper 需要在这两种接口之间做一次转换。

1
2
3
4
5
6
7
io.mem.req.valid := accel.io.mem.req.valid
accel.io.mem.req.ready := io.mem.req.fire

io.mem.req.bits.addr := accel.io.mem.req.bits.addr
io.mem.req.bits.cmd := Mux(accel.io.mem.req.bits.isWrite, M_XWR, M_XRD)
io.mem.req.bits.size := log2Ceil(8).U // fixed 64-bit transfer
io.mem.req.bits.data := accel.io.mem.req.bits.data

这里最关键的是前两行:

1
2
io.mem.req.valid := accel.io.mem.req.valid
accel.io.mem.req.ready := io.mem.req.fire

第一行表示:只要 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
2
3
accel.io.mem.resp.valid := io.mem.resp.valid
accel.io.mem.resp.bits.data := io.mem.resp.bits.data
accel.io.mem.resp.bits.tag := io.mem.resp.bits.tag

完成这一阶段集成后,加速器就可以读取 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static inline void set_addr_in(uint64_t addr) {
ROCC_INSTRUCTION_SS(0, addr, 0, 0);
}

static inline void set_addr_ker(uint64_t addr) {
ROCC_INSTRUCTION_SS(0, addr, 0, 1);
}

static inline void set_addr_out(uint64_t addr) {
ROCC_INSTRUCTION_SS(0, addr, 0, 2);
}

static inline void start_accel(void) {
ROCC_INSTRUCTION_SS(0, 0, 0, 3);
}

static inline uint64_t poll_status(void) {
uint64_t status;
ROCC_INSTRUCTION_DSS(0, status, 0, 0, 4);
return status;
}

这里最容易混的是两个位置的 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
sw_start = rdcycle();
software_conv_5x5_same_q88();
sw_end = rdcycle();
sw_cycles = sw_end - sw_start;

set_addr_in((uint64_t)(uintptr_t)input);
set_addr_ker((uint64_t)(uintptr_t)kernel);
set_addr_out((uint64_t)(uintptr_t)hw_out);

fence_rw();

acc_start = rdcycle();
start_accel();

for (poll_count = 0; poll_count < MAX_POLL; poll_count++) {
status = poll_status();

if (poll_count > 10 && (status & 0x2)) {
break;
}
}

fence_rw();

acc_end = rdcycle();
acc_cycles = acc_end - acc_start;

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 搬进专用硬件:

  • LineBufferShiftWindow 复用输入像素,避免反复读取重叠窗口。
  • ConvUnit 把 25 路 MAC tree 做成流水线。
  • InputQueueStoreQueue 吸收 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.