人工智能芯片
从算法模型到可综合 RTL 的完整流程
记录神经网络算子从 Python 模型、定点化、接口定义到可综合 RTL 的工程拆解方法。
算法到 RTL 的关键不是“翻译代码”,而是把计算图转换成可调度、可存储、可验证的硬件结构。
在神经网络加速器设计中,Python 模型和 RTL 实现之间存在一个很大的语义差距。Python 描述的是张量运算,默认拥有近乎无限的临时变量、灵活的数据结构和顺序执行抽象;RTL 描述的是寄存器、组合逻辑、存储器端口、握手协议和时钟周期。所谓“从算法到 RTL”,本质上是把一个数学计算问题逐步约束成硬件可以执行的时空调度问题。
这篇笔记以卷积类算子为例,整理一个相对完整的拆解流程。内容偏工程方法,不包含任何未经确认的性能数据。
先定义边界
需要在实现前明确这些问题:
- 输入输出张量形状
- 数据类型和小数位
- 累加位宽和饱和策略
- 存储层级与访问顺序
- 参考模型与 RTL 的误差阈值
如果这些问题没有回答清楚,RTL 很容易进入一种危险状态:代码可以写下去,但每一步都在引入隐含假设。后面一旦发现接口、位宽或存储结构不合适,修改成本会非常高。
我通常会先写一份短规格说明,哪怕只有一页,也要包含:
| 项目 | 示例问题 | 影响 |
|---|---|---|
| 张量布局 | NCHW 还是 NHWC | 地址生成、burst 连续性 |
| 数据类型 | int8、uint8、int16 | 乘法器规模、符号扩展 |
| 累加类型 | int32 还是更窄 | 溢出风险、寄存器面积 |
| Padding | zero padding 还是 valid | 边界控制 |
| Stride | 固定还是可配置 | 控制状态和地址步进 |
| Batch | 是否支持 batch 维度 | 外层调度 |
第一版 RTL 不一定要支持所有情况。相反,明确“不支持什么”通常比模糊地说“可扩展”更有价值。
卷积算子示例
二维卷积可以写成:
硬件映射时,循环顺序直接影响片上缓存复用和外部带宽压力。
一个朴素的软件循环可能是:
for o in range(OC):
for h in range(OH):
for w in range(OW):
acc = 0
for i in range(IC):
for r in range(KH):
for s in range(KW):
acc += x[i][h + r][w + s] * weight[o][i][r][s]
y[o][h][w] = acc
硬件不会简单地把这六层循环逐行翻译成状态机。真正需要考虑的是:
- 哪一层循环映射成空间并行,例如多个 MAC 同时计算。
- 哪一层循环映射成时间复用,例如同一组 MAC 多周期累加。
- 哪些数据保存在片上 buffer,哪些从外部存储流入。
- 输出累加是在寄存器中完成,还是写回片上 SRAM 后继续累加。
建立浮点参考模型
第一步应该是一个尽可能直接、容易读懂的浮点参考模型。它不必快,但必须清晰。参考模型的价值是给后续所有实现提供“正确性锚点”。
import numpy as np
def conv2d_ref(x, w, bias=None):
oc, ic, kh, kw = w.shape
_, ih, iw = x.shape
oh = ih - kh + 1
ow = iw - kw + 1
y = np.zeros((oc, oh, ow), dtype=np.float32)
for o in range(oc):
for h in range(oh):
for col in range(ow):
acc = 0.0
for i in range(ic):
window = x[i, h:h + kh, col:col + kw]
acc += np.sum(window * w[o, i])
y[o, h, col] = acc + (bias[o] if bias is not None else 0.0)
return y
这段代码不是为了性能,而是为了减少歧义。等验证稳定后,可以再增加向量化版本用于生成更大规模的测试数据。
定点化模型
硬件实现通常不会直接使用浮点数。定点化阶段要把每个张量的范围、缩放因子和舍入方式固定下来。常见的对称量化可以抽象为:
其中 是缩放因子, 是量化位宽。对于卷积结果,还需要处理乘法后累加的尺度:
定点模型中最容易出错的地方包括:
- 有符号和无符号输入混用。
- 乘法结果位宽不足。
- 累加位宽没有覆盖最坏情况。
- ReLU、截断、饱和的顺序与 RTL 不一致。
- Python 中的整数无限精度掩盖了硬件溢出。
一个简单的累加位宽估算可以从最坏情况开始。假设输入和权重均为 signed int8,单次乘法最大幅值约为 ,累加项数为 ,则累加器至少需要覆盖:
实际位宽还要结合数据分布、量化策略和是否允许饱和来确定。学习阶段建议先保守一些,等验证充分后再优化位宽。
选择数据流
对卷积加速器来说,数据流决定了片上存储和带宽需求。常见思路包括:
| 数据流 | 保持不动的数据 | 适用场景 | 代价 |
|---|---|---|---|
| Weight-stationary | 权重 | 权重复用高、输出通道较多 | 输入窗口调度复杂 |
| Output-stationary | 部分和 | 累加局部性好 | 输出 buffer 压力较大 |
| Row-stationary | 行或窗口数据 | 平衡输入、权重和输出复用 | 控制复杂度较高 |
第一版原型不必追求最优数据流。更合理的做法是选择一个容易验证的结构,然后用脚本统计访存量和计算利用率,判断瓶颈在哪里。
Input Stream
↓
Line Buffer
↓
Window Generator
↓
MAC Array
↓
Accumulator
↓
Output Stream
这个结构非常常见,但具体实现差异很大。比如 line buffer 是寄存器阵列还是 SRAM,window generator 是否支持 stride,MAC array 是否按输出通道并行,都会影响 RTL 复杂度。
接口定义
接口最好在写数据通路之前固定下来。哪怕内部结构还会变化,外部接口也应该尽量稳定。一个最小控制接口可以包括 start、busy、done 和配置寄存器;数据接口则可以使用流式握手或存储器读写端口。
接口草图
module conv_engine #(
parameter int DATA_W = 8,
parameter int ACC_W = 32
) (
input logic clk,
input logic rst_n,
input logic start,
output logic done
);
// Datapath and control FSM are intentionally omitted.
endmodule
如果模块接入 AXI-Stream 风格接口,可以抽象成:
input logic in_valid,
output logic in_ready,
input logic [DATA_W-1:0] in_data,
output logic out_valid,
input logic out_ready,
output logic [ACC_W-1:0] out_data
握手接口的关键是所有路径都要考虑 backpressure。只在理想情况下 ready 永远为高的设计,到了系统集成阶段往往会暴露问题。
RTL 分块
我会避免把卷积引擎写成一个巨大模块。更可维护的分块方式是:
| 模块 | 职责 | 验证重点 |
|---|---|---|
cfg_regs | 保存尺寸、步长、模式等配置 | 读写一致性、非法配置 |
addr_gen | 生成输入、权重、输出地址 | 边界、stride、tile 切换 |
line_buffer | 缓存输入行 | 写读顺序、边界填充 |
window_gen | 形成卷积窗口 | 窗口顺序、valid 对齐 |
mac_array | 乘加计算 | 位宽、符号、流水线延迟 |
accumulator | 部分和累加和输出 | 清零、饱和、输出时序 |
ctrl_fsm | 全局调度 | 状态跳转、done 条件 |
分块后的好处是可以先对每个子模块做小规模 directed test,再进行系统级对拍。
验证闭环
| 阶段 | 产物 | 检查方式 |
|---|---|---|
| Python | 浮点参考模型 | 单元测试 |
| Quant | 定点参考模型 | 误差统计 |
| RTL | 可综合实现 | 仿真对拍 |
| Synthesis | 门级结构 | 时序和面积检查 |
这里不写任何具体性能指标。只有当数据来源、环境和约束都可公开时,才应在项目页展示量化结果。
一个最小的对拍流程可以是:
- Python 生成输入张量、权重和期望输出。
- 把输入数据导出为十六进制或二进制向量文件。
- SystemVerilog testbench 读取向量并驱动 DUT。
- RTL 输出结果写入文件或由 scoreboard 在线比较。
- Python 脚本汇总误差、失败位置和测试配置。
def compare(golden, dut, tolerance=0):
diff = np.abs(golden.astype(np.int64) - dut.astype(np.int64))
mismatches = np.argwhere(diff > tolerance)
return {
"passed": len(mismatches) == 0,
"max_abs_error": int(diff.max()) if diff.size else 0,
"mismatch_count": int(len(mismatches)),
}
这类比较脚本最好输出结构化结果,而不仅仅是打印 PASS 或 FAIL。失败时能直接看到第一个错误位置、期望值、实际值和配置参数,会显著减少调试时间。
从功能正确到结构优化
功能正确之后才适合讨论结构优化。常见优化方向包括:
- 增加 MAC 并行度,提高计算吞吐。
- 调整 tile 尺寸,提高片上数据复用。
- 通过双缓冲隐藏数据搬运延迟。
- 合并访存请求,让 burst 更连续。
- 对累加和量化阶段做流水线切分。
每一种优化都可能带来新的验证复杂度。例如双缓冲会引入 buffer 切换时序,流水线切分会引入 valid 对齐问题,增加并行度会增加数据重排和写回冲突风险。因此优化必须配合回归测试推进。
常见陷阱
把 Python 循环顺序当成硬件结构
软件循环只是表达计算语义。硬件结构需要重新考虑并行度、存储端口和数据到达顺序。照着循环写状态机通常会得到一个正确但低效、难扩展的实现。
先写 RTL,后补模型
没有参考模型时,RTL 的每一次调试都容易变成波形猜测。即使模型很简单,也应该先建立可自动比较的 golden path。
忽略无效周期
很多数据通路在理想流输入下看起来正确,但一旦加入 ready/valid stall 就会出现窗口错位、重复累加或输出丢失。测试中应主动插入随机 stall。
过早压缩位宽
位宽优化应该建立在误差统计和边界测试基础上。过早压缩会让错误同时来自功能逻辑和数值精度,增加定位难度。
阶段性总结
RTL 设计应优先保证结构清楚、接口稳定和仿真可复现。过早追求复杂优化,往往会让验证成本超过架构收益。
从算法模型到可综合 RTL 的完整流程,可以概括为:先定义边界,再建立浮点和定点参考模型,然后选择数据流、固定接口、拆分 RTL 模块,最后通过自动化对拍和回归测试逐步优化结构。对 AI 芯片原型来说,这套流程比单点技巧更重要。