卷积占了 LeNet-5 全网 90% 以上的计算量,卷积引擎是整个项目当之无愧的核心。08-10 的 Spec 留了一个"未决项": MAC 并行度。本节先算账定并行度,再把它拆成两个可独立验证的子模块 —— 行缓冲开窗器(解决"窗口数据从哪来、怎么不重复读")与 8 路 MAC 阵列(解决"8 个乘积怎么并行、怎么累加")。先看微架构总图,后面三个知识点逐块拆解:
是什么:卷积引擎的微架构就是在"乘法器数量 P"这根轴上选点:P=1 是纯串行(每拍 1 次乘累加,一个窗口要 K×K×CH_IN 拍);P=K×K×CH_IN 是全并行(一拍出一个输出像素,本质是加法树);中间就是中等并行 —— 本项目选 P=8:行缓冲负责数据复用,8 个 MAC 沿"累加项"维度把每个输出窗口切成 8 项/拍分批算。为什么:并行度不是拍脑袋,而是三方约束的交点 —— ① 性能账:08-10 估出串行全网约 45~50 万周期,1M 预算虽达标但没余量,提并行是唯一抓手;② 资源账:全并行做 C3 层要 150 个乘法器,C5 要 400 个,一片 FPGA 的 DSP 硬核根本不够;③ 带宽账:P 个 MAC 每拍要 P 个权重,P=8 时权重读口 64bit 一块 BRAM 就够,P 再大就得拆 bank。怎么做:先按 P=8 重算周期账,再看利用率:
| 层 | 单像素累加项 K×K×CH_IN | 串行周期(P=1) | 8 路并行周期 ceil(·/8) | MAC 利用率 |
|---|---|---|---|---|
| C1 conv5×5 | 5×5×1 = 25 | 25 | 4 | 25/32 ≈ 78% |
| C3 conv5×5 | 5×5×6 = 150 | 150 | 19 | 150/152 ≈ 99% |
| C5 conv5×5 | 5×5×16 = 400 | 400 | 50 | 100% |
| F6 fc | 120 | 120 | 15 | 100% |
| OUTPUT fc | 84 | 84 | 11 | 95% |
选 8 的决定性证据就在这张表里:C5 的 400 项被 8 整除、F6 的 120 也整除,全网平均利用率超过 90%;若选 7 或 9,利用率与除法电路都会变难看(硬件做除以 3 很贵,并行度取 2 的幂,除法变截位)。循环次序也随之定版:输出像素在最外、输出通道居中、K×K×CH_IN 累加项在最内 —— 最内层每拍消费 8 个权重,权重 RAM 可以纯顺序读,这个次序同时决定了第 3 节权重 hex 的导出顺序,两边必须一字不差。
parameter P=8,v2 提到 16 时只改一处。是什么:行缓冲(line buffer)是图像流水线的经典结构:像素流按行主序每拍来 1 个,用 K−1 条与行等长的缓冲把"最近的 K−1 行"留在片内,再配 K×K 个窗口寄存器,让任意时刻寄存器里都端坐着当前滑动窗口的 K×K 个像素。3×3 卷积需要 2 级行缓冲 + 9 个窗口寄存器;5×5 则是 4 级 + 25 个。为什么:没有行缓冲,每算一个输出像素都要从 RAM 重读 9 个数,一个像素被读 9 遍、访存带宽膨胀 9 倍;有了行缓冲,每个像素一生只被 RAM 读出一次,之后全在寄存器间流转 —— 这就是"数据复用"三个字在 RTL 里的样子(与 08-07 讲的卷积数据复用理论一一对应)。
怎么做 —— 对齐时序是灵魂:窗口不是一开始就有效:第 1 个像素进来时,只有它自己;直到第 3 行的第 3 列像素到位,2 条行缓冲装满了前 2 行、列方向移位链装满了前 2 列,第一个完整窗口才凑齐。所以 win_valid = (row≥2) && (col≥2),首个窗口对应当前像素左上方的 3×3 邻域,输出像素坐标 = (row−2, col−2);整图前置无效拍数 = 2×行宽+3。代码如下(行缓冲用寄存器阵列实现,32 深度 × 8bit ≈ 256 触发器,毫无压力;FPGA 上综合器也会把移位链推断成 SRL16 更省):
//==============================================================
// line_buffer3.v — 两级行缓冲 + 3×3 窗口寄存器(3×3 卷积开窗)
// 输入: 行主序像素流(每拍 1 个 INT8);输出: 对齐后的 3×3 窗口
//==============================================================
module line_buffer3 (
input wire clk,
input wire rst_n, // 同步复位, 低有效
input wire in_valid,
input wire [7:0] din, // INT8 像素流
input wire [7:0] fm_in, // 行宽-1(沿用 08-10 的存 N-1 契约)
output reg win_valid, // 窗口有效(对齐后每拍滑动一次)
output reg [7:0] w00,w01,w02, // 窗口第 0 行(最旧)
output reg [7:0] w10,w11,w12, // 窗口第 1 行
output reg [7:0] w20,w21,w22 // 窗口第 2 行(最新,din 所在行)
);
reg [7:0] row0 [0:31]; // 行缓冲 0: 延迟 1 行(深度=最大行宽 32)
reg [7:0] row1 [0:31]; // 行缓冲 1: 延迟 2 行
reg [7:0] col, row; // 列/行计数
wire [5:0] wcol = col[5:0];
wire [7:0] px1 = row0[wcol]; // 组合读: 1 行前的同列像素
wire [7:0] px2 = row1[wcol]; // 组合读: 2 行前的同列像素
always @(posedge clk) begin
if (!rst_n) begin
col <= 8'd0; row <= 8'd0; win_valid <= 1'b0;
{w00,w01,w02,w10,w11,w12,w20,w21,w22} <= 72'd0;
end else begin
// ★ 对齐: 第 3 行(row≥2)第 3 列(col≥2)起窗口才完整
win_valid <= in_valid & (row >= 2) & (col >= 2);
if (in_valid) begin
row1[wcol] <= px1; // 旧行下移一行
row0[wcol] <= din; // 新像素入第 0 行
// ---- 列方向: 每行内部 3 级移位(非阻塞链, 各取旧值) ----
w02 <= w01; w01 <= w00; w00 <= px2; // 本拍的 px2
w12 <= w11; w11 <= w10; w10 <= px1;
w22 <= w21; w21 <= w20; w20 <= din;
// ---- 行/列计数 ----
if (col == fm_in) begin col <= 8'd0; row <= row + 8'd1; end
else col <= col + 8'd1;
end
end
end
endmodule
逐段讲解:① px1/px2 必须是组合读的中间信号 —— 若写成时序链 d1 <= row1[wcol]; w00 <= d1;,非阻塞赋值各自取旧值,w00 拿到的是"上一拍读出的行缓冲值",整条链多延迟一拍、窗口错位一列,这是行缓冲最经典的 bug(波形上看所有输出整体向右歪一格);② 列方向移位链 w02<=w01; w01<=w00; w00<=px2; 是标准移位寄存器惯用法,非阻塞保证三个寄存器同时各取"邻居旧值";③ 行缓冲深度按最大行宽 32 定死而不是参数化成 fm_in+1 —— 参数化存储深度会阻碍 BRAM/SRL 推断,宽度参数化、深度取上界,是工程惯例;④ 5×5 版本只是把 2 级改 4 级、9 个窗口寄存器改 25 个,建议用 generate 参数化生成。
是什么:MAC 阵列 = 8 个 INT8 乘法器 + 一棵 8→4→2→1 加法树 + 1 个 INT32 累加器。每个计算拍,窗口侧送 8 个像素、权重侧送 8 个权重,8 个乘积经加法树归约成一个部分和,累加进 INT32 累加器;窗口起点时累加器不请零、而是预装 INT32 bias(bias 加在最前与加在最后数学等价,预装省一级加法)。为什么:位宽链路在 08-10 已算清(8b 乘→16b 积→32b 累加,最坏 400 项 ≈ 223),这里真正要死磕的是 $signed —— Verilog 的 infamous 陷阱:表达式里只要有一个操作数是无符号,整个运算就按无符号进行。权重 8'b1000_0000 本意是 −128,无符号解释下变成 +128,负权重全部翻车,而且错得"数值都挺合理",黄金比对一比对才发现全错。怎么做:看代码,重点盯三处 ★:
//==============================================================
// mac_array8.v — 8 路并行 INT8 MAC + 加法树 + INT32 累加器
//==============================================================
module mac_array8 (
input wire clk,
input wire rst_n,
input wire acc_clr, // 窗口起点: 累加器预装 bias
input wire mac_en, // 本拍乘累加有效
input wire [63:0] win_data, // 8 个窗口像素(INT8 打包)
input wire [63:0] wgt_data, // 8 个权重(INT8 打包, 权重RAM 64b 读出)
input wire [31:0] bias, // INT32 偏置
output reg [31:0] acc // INT32 累加结果(给 requant)
);
// ---- ★1 8 个 INT8×INT8→INT32 有符号乘法($signed 不可省!) ----
wire signed [31:0] p [0:7];
genvar i;
generate
for (i = 0; i < 8; i = i + 1) begin : G_MAC
assign p[i] = $signed(win_data[i*8 +: 8]) * $signed(wgt_data[i*8 +: 8]);
end
endgenerate
// ---- 加法树: 8→4→2→1(32 位带符号加法, 综合器自行平衡) ----
wire signed [31:0] s0 = p[0] + p[1], s1 = p[2] + p[3];
wire signed [31:0] s2 = p[4] + p[5], s3 = p[6] + p[7];
wire signed [31:0] t0 = s0 + s1, t1 = s2 + s3;
wire signed [31:0] sum = t0 + t1; // 每拍 8 项的部分和
// ---- ★2 累加器: 起点预装 bias, 之后逐拍累加 ----
always @(posedge clk) begin
if (!rst_n) acc <= 32'd0;
else if (acc_clr) acc <= bias; // ★3 预装 bias 而不是清 0
else if (mac_en) acc <= acc + sum;
end
endmodule
逐段讲解:① $signed(a) * $signed(b) 的结果在 Verilog 里取两侧最大位宽的自饱和宽度,8×8 的精确积最多 16 位,直接声明成 32 位让符号扩展自动完成,既不会溢出也不会丢符号;② 加法树写成三级"两两归约",综合器会映射到 DSP 内部加法器与进位链,@50MHz(20ns)下单拍完成乘加树毫无压力 —— 若将来提频到 150MHz+,在乘法输出、树中各插一级寄存器切成三级流水即可(数据流是单向的,切流水不影响结果,只影响 valid 要跟着打拍);③ acc_clr 时预装 bias:控制模块在每个输出窗口的第一拍发 acc_clr,窗口最后一拍后 acc 里就是"ΣW·x + bias",直接送 requant。
win_data[i*8 +: 8] 这种位选择本身按无符号取,必须包进 $signed 再乘;② 把 16 位乘积先截断成 8/12 位再累加(想省位宽),负数直接错;③ bias 预装忘了 acc_clr 与第一个 mac_en 的先后,导致 bias 被多加一次或漏加;④ mac_en 门控漏掉,输入空拍照样累加,部分和被污染。MAC 阵列吐出的是 INT32 累加值,但下一层只认 INT8 输入 —— 中间这段"从 32 位压回 8 位"的后处理链路由三个小模块组成:requant(乘逆 + 移位 + 饱和)、ReLU(逐元素截负)、池化(2×2 取最大)。它们电路都不复杂,却是数值正确性最容易翻车的地方:饱和与截断一字之差、有符号与无符号一念之差,输出就会错得神不知鬼不觉。本节逐个拆,并与 08-08 的量化公式严格对齐。
是什么:量化卷积的真实数学是 out = acc × (s_in×s_w/s_out),其中 s_x 是各张量的量化 scale(浮点)。硬件里没有除法器,标准做法(TPU v1 / TensorFlow Lite / NVDLA 同款)是把浮点系数 1/s_out×s_in×s_w 离线拆成一个整数乘子 M₀ 和一个移位量 shift:out ≈ (acc × M₀) >> shift,再饱和到 [−128,127]。M₀ 与 shift 由 08-08 的量化脚本离线算好(每层一组),经 REQUANT 寄存器(08-10 寄存器表 0x24)随层参数下发给硬件。为什么:直接做浮点除法要引 FP 单元,面积功耗不可接受;"乘一个 16 位整数再移位"只需一个 32×16 乘法器 + 一桶式移位器,且每层固定、无累积误差 —— 这是"离线能算的绝不在线上算"原则的典型应用。
怎么做 —— 三步流水: 乘、移、饱和:acc(32b)×M₀(16b) 产生 48 位中间结果;算术右移 shift 位(保留符号);最后饱和 clamp。饱和不是截断:截断是"把高位咔嚓掉",200 会卷绕成 −56 混进网络;饱和是"超界就钉在边界上",数学含义是"这个值超出 INT8 表示范围,取最接近的可表示值"。RTL 三步同拍完成、输出打一拍收口:
//==============================================================
// requant_sat.v — 重量化: acc×M0 >> shift → 饱和 → INT8
//==============================================================
module requant_sat (
input wire clk,
input wire rst_n,
input wire in_valid,
input wire [31:0] acc, // INT32 累加结果(含 bias)
input wire [15:0] m0, // 乘子 M0(每层一组, 主机写入)
input wire [4:0] shift, // 右移位数 0~31
output reg out_valid,
output reg [7:0] dout // INT8(已饱和)
);
// ---- ① 乘逆: 32b×16b → 48b 中间结果(m0 无符号, 先扩 1 位再转有符号) ----
wire signed [47:0] prod = $signed(acc) * $signed({1'b0, m0});
// ---- ② 移位: 算术右移(>>>)保留符号位 ----
wire signed [47:0] shf = prod >>> shift;
// ---- ③ 饱和: clamp 到 [-128, 127](不是截断!) ----
wire signed [7:0] sat = (shf > 48'sd127) ? 8'sd127 :
(shf < -48'sd128) ? -8'sd128 : shf[7:0];
// ---- 一级流水收口: valid 与数据严格同拍 ----
always @(posedge clk) begin
if (!rst_n) begin
out_valid <= 1'b0; dout <= 8'sd0;
end else begin
out_valid <= in_valid;
dout <= sat;
end
end
endmodule
逐段讲解:① {1'b0, m0} 先把无符号的 M₀ 拼成 17 位再转 $signed —— M₀ 恒为正,不扩位直接 $signed 会把最高位误当符号;② >>> 与 >> 一字之差:算术右移补符号位、逻辑右移补 0,负数必须用前者;③ 饱和比较要在移位后的全宽(48 位)上做,先截到 8 位再比较就晚了 —— 高位信息已经丢了;④ 48'sd127 这类带位宽的字面量让比较两侧位宽显式一致,避免 lint 告警与隐式截位。
>>> 写成 >>,负累加值右移后变正;③ m0 忘扩位就 $signed,当 m0 ≥ 32768 时变负数;④ 饱和与 ReLU 的先后:先饱和再 ReLU(max(0,x) 不会破坏已饱和的值),反着做可能在饱和前就被 clamp 掉负值边界语义 —— 顺序写进 Spec。是什么:ReLU(x)=max(0,x),逐元素、无状态,是全网最简单的模块 —— 数据已饱和成 INT8,补码的最高位就是符号位,负数(最高位为 1)直接输出 0 即可,连比较器都不用。每层有 relu_en 配置位(08-10 的 LAYER_CFG[3]):卷积层后通常接 ReLU,最后一层输出层绝不能接(要保留 logits 的正负才能比大小)。为什么:单独成模块而不是揉进 requant,一是让"激活函数"成为可插拔件(v2 换 ReLU6/sigmoid 只动这一块),二是验证上可以独立做穷举 —— 256 个输入挨个比对,穷举即 100% 覆盖。怎么做:组合逻辑一 行 + 一级流水寄存器:
//==============================================================
// relu_unit.v — INT8 ReLU: din[7]=1(负数) → 输出 0;可旁路
//==============================================================
module relu_unit (
input wire clk,
input wire rst_n,
input wire en, // 本层 ReLU 使能(LAYER_CFG[3])
input wire in_valid,
input wire [7:0] din, // 已饱和的 INT8
output wire out_valid,
output wire [7:0] dout
);
wire [7:0] relued = din[7] ? 8'h00 : din; // 符号位判负, 组合逻辑
assign dout = en ? relued : din; // 不使能 → 直通
reg out_valid_r;
always @(posedge clk) begin
if (!rst_n) out_valid_r <= 1'b0;
else out_valid_r <= in_valid; // ★ 数据/valid 同拍打一拍
end
assign out_valid = out_valid_r;
endmodule
逐段讲解:① din[7] 判符号的前提是上游 requant 已经做过饱和 —— 若输入还可能是"截断卷绕"的值,符号位本身已不可信,所以模块的接口契约必须写明"din 是合法 INT8";② en 是层级静态配置,组合直通不引 Latency 差异;③ out_valid 与数据必须同拍打一拍 —— 只打数据不打 valid,下游会在错误拍采样;只打 valid 不打数据则移位错位。这个"valid 与 data 绑定打拍"的习惯要贯穿全流水。
是什么:2×2 最大池化,stride=2:把输入特征图上每个 2×2 邻域压成 1 个最大值,输出边长减半。硬件实现 = 一条一级行缓冲 + 一棵四选一比较树:比较树 max(max(上左,上右), max(下左,下右)) 两级、三次比较完成;行缓冲负责把"上一行的同位置两个值"和"本行当前两个值"凑到同一拍。为什么:池化要与卷积输出同速流水(卷积每拍出一个像素,池化每两拍出一个结果),不能算完一层再回头读 RAM 做 —— 那会让数据通路退回串行。把它做成卷积→ReLU 之后可旁路的一级流水(池化层与卷积层共用一套引擎通路,由 layer_type=001 调度),层间就无需额外的 RAM 往返。
怎么做 —— 配对逻辑是关键:输入行主序,输出像素 (r,c) 由输入 (2r,2c)(2r,2c+1)(2r+1,2c)(2r+1,2c+1) 组成。策略:偶数列像素只"记账"(记下上一行的左值、本行的左值),奇数列像素到来时四值凑齐、比较树出结果。注意上一行的值从一级行缓冲 line[] 里取:
//==============================================================
// maxpool2x2.v — 2×2 最大池化(stride=2), 每两拍出一个结果
//==============================================================
module maxpool2x2 (
input wire clk,
input wire rst_n,
input wire in_valid, // 上游(卷积+ReLU)每拍 1 个 INT8
input wire [7:0] fm_out, // 输入边长-1(偶数)
input wire [7:0] din,
output reg out_valid,
output reg [7:0] dout
);
reg [7:0] line [0:31]; // 一级行缓冲: 存上一行像素(按输出列)
reg [7:0] col; // 输入列计数
reg [7:0] prev_a, cur_l; // 偶列记账: 上一行 2c / 本行 2c
wire [5:0] widx = col[5:1];
// ---- 四选一比较树(★ INT8 比较必须带符号!) ----
wire signed [7:0] m_top = $signed(prev_a) > $signed(line[widx]) ? prev_a : line[widx];
wire signed [7:0] m_bot = $signed(cur_l) > $signed(din) ? cur_l : din;
wire signed [7:0] m_all = $signed(m_top) > $signed(m_bot) ? m_top : m_bot;
always @(posedge clk) begin
if (!rst_n) begin
col <= 8'd0; out_valid <= 1'b0; dout <= 8'd0;
prev_a <= 8'd0; cur_l <= 8'd0;
end else begin
out_valid <= 1'b0;
if (in_valid) begin
if (col[0]) begin // 奇数列: 四值凑齐
out_valid <= 1'b1;
dout <= m_all;
end else begin
prev_a <= line[widx]; // 记下上一行的 2c(组合读旧值)
cur_l <= din; // 记下本行的 2c
end
line[widx] <= din; // 本像素入行缓冲, 供下一行配对
col <= (col == fm_out) ? 8'd0 : col + 8'd1;
end
end
end
endmodule
逐段讲解:① 奇数列那拍,line[widx] 的组合读拿到的是上一行的 2c+1(它将在本拍被本行值覆盖,非阻塞读旧值恰好正确);② prev_a 在偶列拍捕获 line[widx] 的旧值 = 上一行的 2c;③ m_top/m_bot/m_all 是三级两两比较,两次比较出四选一其实是 3 次比较、深度 2 级,输出打一拍与 out_valid 对齐;④ 吞吐减半:卷积每拍 1 像素、池化每 2 拍 1 像素,下游写口天然省一半带宽,无需反压。
运算模块之外,数据从哪来、算完到哪去,由存储子系统决定。本节三件事:全连接如何"伪装"成卷积复用 8 路引擎;权重 RAM 的地址映射与双口组织(含 hex 导出全流程);特征图乒乓双缓冲的 RTL 实现与切换时序。
是什么:全连接层 y = W·x 是矩阵向量乘:C5 是 120×400(输入即 16×5×5 特征图展平)、F6 是 84×120、输出层 10×84。08-07 已给过结论:全连接 = "核尺寸等于输入尺寸"的卷积,对每个输出神经元,把整幅输入特征图与一行权重做一次乘累加。为什么:它的内层与卷积完全同构 —— 都是"向量点积",所以不新造运算单元,复用 8 路 MAC 阵列 + requant/ReLU;唯一不同的是数据供给:卷积的窗口每拍滑动,全连接的输入向量 x 对一个神经元固定不变、换神经元才重来。怎么做:把 x 预载进引擎内一块 64bit 宽的向量缓冲(C5 输入 400B → 50×64bit,F6/OUT 更小),控制器跑双层循环:
//==============================================================
// fc_ctrl.v(骨架)— 全连接复用 MAC 阵列的双层循环
// x 向量已预载进 vec_buf(64bit 宽, NT = ceil(N/8) 个字)
//==============================================================
// 外层: 输出神经元 o = 0 .. CH_OUT(每神经元一次 acc_clr 预装 bias)
// 内层: t = 0 .. NT-1, 每拍:
// mac(win_data = vec_buf[t], wgt_data = 权重RAM 顺序读)
// 内层跑完 → acc 即该神经元结果 → requant → 写回 → 下一神经元
always @(posedge clk) begin
if (!rst_n) begin t <= 0; o <= 0; end
else if (fc_run) begin
waddr <= wbase + {o, t, 3'd0} + t; // 权重地址随 (o,t) 顺序推进
t <= (t == NT-1) ? 10'd0 : t + 10'd1;
if (t == NT-1) begin
o <= o + 10'd1;
acc_clr_req <= 1'b1; // ★ 换神经元: 预装下一行 bias
end
end
end
周期账:C5 = 120 神经元 × 50 拍 = 6 000;F6 = 84×15 = 1 260;输出层 = 10×11 = 110 —— 三层合计不到 7 400 拍,全网不到 2%,复用引擎的价值一目了然。权重布局:PyTorch 的 Linear 权重天然是 [out, in] 行主序,逐 out 行铺平即是硬件要的顺序;conv 权重 [out, in, kh, kw] 铺平后为 [out][in][kh][kw],与引擎累加次序(in 在外、kh/kw 行主序在内)也一致 —— 所以"权重重排"在本项目里实际上是"确认次序 + 铺平 + 每个输出通道补 0 到 8 的倍数"(C1 的 25 项补到 32,C3 的 150 补到 152,保证每个输出通道的权重起点 64bit 对齐)。
flatten() 不一致(C 顺序 vs 行主序通道次序),x 向量本身错了,后面全错。是什么:权重 RAM 是全网权重的"家":逻辑上 64KB 字节空间(08-10 定的物理 Spec),物理实现为 8K 深度 × 64bit 的双口 RAM —— 口 A(64bit)给计算引擎顺序读,口 B(32bit + 字节使能)给主机经 APB 写入。逻辑字节地址到物理组织的映射:物理字地址 = 字节地址[15:3],lane = 字节地址[2:0]。为什么:引擎每拍要 8 个权重(知识点 1 的带宽账),8bit 单口 RAM 喂不饱;而主机 APB 是 32bit 总线,写入侧必须带字节使能做"半字写"。一张全网权重布局表在项目启动时就该定版,它是导出脚本、驱动代码、联调比对的共同参照:
| 起始地址 | 层 | 权重(每输出通道补齐到 8 的倍数) | bias(INT32) | 合计 |
|---|---|---|---|---|
| 0x0000 | C1 | 6×32 = 192 B | 6×4 = 24 B | 216 B |
| 0x00D8 | C3 | 16×152 = 2 432 B | 16×4 = 64 B | 2 496 B |
| 0x0A98 | C5 | 120×400 = 48 000 B | 120×4 = 480 B | 48 480 B |
| 0xC7F8 | F6 | 84×120 = 10 080 B | 84×4 = 336 B | 10 416 B |
| 0xF0A8 | OUT | 10×88 = 880 B | 10×4 = 40 B | 920 B |
| 0xF440 | — | 总占用 62 528 B < 64 KB ✓(每层先 W 后 bias,均 8 字节对齐) | — | |
双口 RAM 的 RTL(BRAM 推断友好写法,读滞后 1 拍)与仿真预载:
//==============================================================
// weight_ram.v — 8K×64bit 双口: 口A 引擎读 / 口B APB 写
//==============================================================
module weight_ram (
input wire clk,
input wire [12:0] ra_addr, // 口 A: 引擎(字地址)
output reg [63:0] ra_data, // 读滞后 1 拍
input wire wb_en, // 口 B: APB 写(仅配置模式)
input wire [12:0] wb_addr,
input wire wb_half, // 0=写 lane0~3, 1=写 lane4~7
input wire [31:0] wb_data
);
reg [63:0] mem [0:8191];
// ★ 仿真预载: hex 每行一个 64bit 字(16 个 hex 字符)
// FPGA 综合器同样接受该初始化, 直接推断为 BRAM 初值
initial $readmemh("weights.hex", mem);
always @(posedge clk) begin
ra_data <= mem[ra_addr]; // 口 A: 同步读
if (wb_en) begin // 口 B: 32bit 半字写
if (!wb_half) mem[wb_addr][31:0] <= wb_data;
else mem[wb_addr][63:32] <= wb_data;
end
end
endmodule
怎么做 —— 权重 hex 导出五步流程:① PyTorch 训练/加载 LeNet,取 state_dict();② 按 08-08 脚本量化成 INT8 权重与 INT32 bias,并算出每层 M₀/shift;③ 按硬件累加次序铺平:conv 权重 [out,in,kh,kw] 直接 flatten,fc 权重 [out,in] 直接 flatten,每个输出通道补 0 到 8 的倍数;④ 按 8 字节一行写成 hex(每行 16 个字符,对齐 $readmemh),bias 单独导出成 32bit hex;⑤ 在布局表(上表)各层基址处拼接成一个 weights.hex,连同 bias.hex、requant 参数一起交给 TB 与驱动:
# export_weights.py(骨架)—— 次序必须与 RTL 循环一字不差
import numpy as np
def export_weights(w_int8: np.ndarray, path: str) -> int:
"""w_int8: 已按 [out][...] 铺平、每输出通道补齐到 8 倍数的一维数组"""
b = w_int8.astype(np.uint8).tobytes()
pad = (-len(b)) % 8 # 全局兜底补齐
b += b'\x00' * pad
with open(path, 'w') as f:
for i in range(0, len(b), 8): # 每行 8 字节 = 1 个 64bit 字
f.write(''.join(f'{x:02x}' for x in b[i:i+8]) + '\n')
return len(b) // 8 # 返回字数, 供 TB 校验
# conv: w.shape=(O, I, KH, KW) → flatten 即 [out][in][kh][kw]
# fc : w.shape=(O, I) → flatten 即 [out][in]
# 每输出通道先 pad: 例如 C1 每通道 25 → 32(补 7 个 0)
是什么/为什么:08-10 已经回答了"为什么乒乓":两块 16K×8 特征图 RAM,当前层读 A 写 B,下一层读 B 写 A —— 上一层的结果一旦写完,下一层立即可启动,读写永不撞同一块。本页落地两件事:双口 RAM 模块的写法,以及 buf_sel 的精确切换时序。怎么做:每块用"简单双口"(1 写口 + 1 读口)实现 —— 卷积引擎读窗口走读口,requant 结果写回走写口,块内互不冲突:
//==============================================================
// fm_ram_dp.v — 特征图块: 16K×8bit, 1 写口 + 1 读口(读滞后1拍)
//==============================================================
module fm_ram_dp (
input wire clk,
input wire we,
input wire [13:0] waddr,
input wire [7:0] wdata,
input wire [13:0] raddr,
output reg [7:0] rdata
);
reg [7:0] mem [0:16383];
always @(posedge clk) begin
if (we) mem[waddr] <= wdata;
rdata <= mem[raddr]; // 同步读: 地址发出后第 2 拍数据有效
end
endmodule
//---------------- npu_top 内的例化与选择 ----------------
// buf_sel=0 → 读 A 写 B;buf_sel=1 → 读 B 写 A
fm_ram_dp u_fm_a (
.we(fm_we & ~buf_sel), .waddr(waddr), .wdata(wdata),
.raddr(raddr), .rdata(rdata_a));
fm_ram_dp u_fm_b (
.we(fm_we & buf_sel), .waddr(waddr), .wdata(wdata),
.raddr(raddr), .rdata(rdata_b));
assign fm_rdata = buf_sel ? rdata_b : rdata_a; // 引擎永远读 buf_sel 块
切换时序(与 08-10 的 FSM 严格对齐):层 N 在 CALC 中读 A 写 B → pe_done → DONE 态发 done_set → 主机配置层 N+1 → FSM 进 LOAD 态翻转一次 buf_sel(0→1)→ 发 pe_start,层 N+1 读 B 写 A。两个不变式:① buf_sel 层内恒定、只在 LOAD 翻转;② 复位值必须与"首层输入图所在块"一致(主机把输入图写进 A,buf_sel 复位为 0,第一层读 A 写 B,自洽)。时序对比如下:
每个模块单独验证通过(模块级 TB + 黄金数据)之后,要把它们装配成 npu_top 跑通一条完整的层序列 —— 这一步暴露的问题占项目总问题数的一半以上,而且几乎都与模块之间的缝隙有关:握手、复位、位宽、存储延迟。先看装配完成后的数据通路全链与位宽标注:
是什么:整体联调 = 把全部模块例化进 npu_top、接好控制/数据两条线,然后跑"主机序列:写权重 → 写输入图 → 逐层配置+start → 等 done"这条最小闭环。为什么:模块级 TB 各自证清白之后,剩余 bug 集中在接口语义的误解上 —— 脉冲还是电平、第几拍有效、复位后什么值,双方理解不一致就会"各自都对,合起来就错"。怎么做:联调前先过两张清单:
| 模块(数量) | 来源 | 关键接口(联调盯防点) |
|---|---|---|
| npu_regfile(1) | 08-10 | APB + start 单拍脉冲 + ram_we 写通道 + irq |
| npu_ctrl(1) | 08-10 | pe_start/pe_ready/pe_done、buf_sel、参数快照 s_xxx |
| line_buffer3 + 窗口(1) | 本页 KP2 | win_valid 对齐条件(row≥2 且 col≥2)、行宽 fm_in=N−1 |
| mac_array8(1) | 本页 KP3 | acc_clr 预装 bias、mac_en 门控、$signed |
| requant_sat(1) | 本页 KP4 | m0 扩位、>>> 算术移位、valid 同拍 |
| relu_unit(1) | 本页 KP5 | en 旁路、输出层必须关 |
| maxpool2x2(1) | 本页 KP6 | 奇偶列配对、$signed 比较 |
| fc_ctrl + 向量缓冲(1) | 本页 KP7 | 换神经元 acc_clr、权重地址补齐步进 |
| weight_ram(1) | 本页 KP8 | $readmemh 路径、读滞后 1 拍、口 B 半字写 |
| fm_ram_dp(2) | 本页 KP9 | we 与 buf_sel 相与、读滞后 1 拍 |
| 握手/时序检查项(波形逐条看) | 正确的样子 | 违规的典型表现 |
|---|---|---|
| start(寄存器→FSM) | 单拍脉冲,且只在 idle=1 时出现 | 计算中写 start 被采纳 → 双重启动 |
| pe_start(FSM→引擎) | 单拍,且前一拍 pe_ready=1 | 引擎没收到 → FSM 在 CALC 永等 |
| pe_done(引擎→FSM) | 单拍,最后一个像素写回后 ≥1 拍 | 写回未完就 done → 结果少尾巴 |
| acc_clr ↔ mac_en | acc_clr 在每窗口首个 mac_en 前一拍 | bias 加两次 / 部分和残留 |
| RAM 读延迟 | 地址发出后第 2 拍数据进 MAC | 首项用旧值 → 每窗口第一项错 |
| buf_sel | 层内恒定,仅 LOAD 翻转,复位=0 | 层内翻转 → 读到自己的写回 |
| done_set → STATUS.done → irq | done 电平保持,读 STATUS 清除 | irq 不撤 / 轮询错过 |
| 复位 | rst_n 拉低 ≥2 拍,释放后全 FSM=IDLE、buf_sel=0、done=0 | 复位遗漏的寄存器上电随机 → 偶发首层错 |
联调期出现频率最高的三类问题,各自的"指纹"与定位套路:
| 问题 | 现象指纹 | 定位套路 | 根因与对策 |
|---|---|---|---|
| 握手死锁 | FSM 停在 CALC 不再前进,仿真挂死或看门狗超时 | 从 pe_done 逆向追:引擎发了吗?→ 没有;引擎收到 pe_start 了吗?→ 没有;FSM 发了吗?→ 发了 → 脉冲没被采到 | 引擎复位后 pe_ready 未拉高,或 pe_start 脉冲跨越了引擎自己的使能门控。对策:握手信号复位值明确、脉冲宽度 ≥1 拍且不被门控,加仿真看门狗 if (cyc>N) $fatal 快速失败 |
| 复位遗漏 | 首层输出前面若干像素错/为 X,第二层起正常;或每次仿真错的位置不一样 | 看波形找第一条 X:它是谁输出的?那个模块的寄存器有没有复位分支? | 行缓冲/池化 line buffer/向量缓冲等大数据寄存器没复位(08-10 说过数据通路可不复位,但参与控制与比较的必须复)。对策:逐模块核对复位清单 |
| 位宽不匹配 | 黄金比对"大面积错、错值有规律"(如负数全变大正数 → 符号扩展丢失;或每隔 N 个错一个 → 某级截位) | 把第一个 mismatch 的中间值(乘积/部分和)与 Python 逐步对,找到第一条分叉的位宽转换 | INT16 乘积赋给 32 位时用了无符号拼接、比较没 $signed、requant 中间结果截位。对策:位宽表(图 4)贴墙上,每一级转换显式声明位宽 |
是什么:黄金数据(golden data)验证法:用 Python/NumPy 按 Spec 公式对同一组输入算出期望输出,RTL 仿真输出与它逐比特比对,容差必须为 0(定点计算是确定的)。完整闭环六步:
为什么:NPU 的数据组合空间是天文数字,手写定向用例永远覆盖不完;黄金模型把"验证正确性"变成"比对确定性",还能免费得到二分定位能力 —— 每层都有 golden,第一个出错的层就是嫌疑模块。怎么做:Python 侧生成与比对各一个脚本:
# gen_golden.py(骨架)—— 每层输出都存, 便于逐层二分定位
import numpy as np
rng = np.random.default_rng(0) # 固定种子, 可复现
def requant_np(acc, m0, shift): # 与 RTL 逐位对齐的参考实现
prod = acc.astype(np.int64) * int(m0) # 48bit 中间结果
shf = prod >> shift # NumPy 右移即算术移位(有符号)
return np.clip(shf, -128, 127).astype(np.int8) # 饱和(与 RTL 相同顺序)
x = rng.integers(-128, 128, (1, 32, 32), dtype=np.int16) # 随机输入(真实图见 KP13)
W = np.load('w_c1_int8.npy'); b = np.load('b_c1_int32.npy')
M0, SH = int(np.load('m0_c1.npy')), int(np.load('sh_c1.npy'))
acc = conv2d_int8(x, W) + b # INT32 参考(NumPy 天生宽整型)
out = requant_np(acc, M0, SH) # requant + 饱和(ReLU 网络侧同步)
np.savetxt('golden_c1.hex', out.reshape(-1) & 0xFF, fmt='%02x') # 无符号 hex 视图
//================= 系统级 TB 的比对段(骨架) =================
integer fd, errs, i;
reg [7:0] golden [0:4703]; // C1 输出 6×28×28 = 4704
reg [7:0] rtl_out [0:4703];
initial begin
$readmemh("golden_c1.hex", golden);
// ...(复位、$readmemh 载权重、经 APB 任务写输入图/寄存器、start、等 done)
fd = $fopen("rtl_c1.hex", "w"); errs = 0;
for (i = 0; i < 4704; i = i + 1) begin
rtl_out[i] = fm_a_read_byte(i); // 读回特征图块
$fwrite(fd, "%02x\n", rtl_out[i]);
if (rtl_out[i] !== golden[i]) begin // ★ 四态比对: X 也算错
errs = errs + 1;
if (errs <= 8)
$display("[MISMATCH] idx=%0d rtl=%02h gold=%02h @%0t",
i, rtl_out[i], golden[i], $time);
end
end
$fclose(fd);
if (errs == 0) $display("PASS: 4704 pixels bit-exact");
else $display("FAIL: %0d/%0d mismatches", errs, 4704);
$finish;
end
定位 —— 第一个 mismatch 的三步追法:① Python 比对器找到第一个差异下标,换算坐标:oc, rc = divmod(i, 28×28); r, c = divmod(rc, 28);② 打印该像素的输入窗口 9/25 个值、对应权重、Python 中间累加值 vs RTL 波形同拍数值,找到第一条分叉发生在乘积、部分和还是 requant;③ 若窗口输入本身就错,往上游查行缓冲/读地址 —— 依此逐层推进。看门狗 + FAIL 早退让坏仿真 10 秒内结束,而不是挂满超时。
== 比对,X 与任何值比较为假反而"通过" —— 必须用四态 !==;④ 改了 RTL 忘了重新导 hex(权重次序变了),对着旧 golden 白查半天。是什么:性能评估 = 用 TB 的周期计数器把每层实测周期数量出来,与 08-07/本页 KP1 的估算对账,再算 MAC 利用率、找瓶颈。为什么:"我的 NPU 跑多快"必须用数字回答:估算给你信心,实测给你证据,两者的差值暴露控制开销与流水气泡 —— 这正是面试追问三连「多快?利用率多少?瓶颈在哪?」的素材。怎么做:TB 里两行代码:
integer cyc, cyc0;
always @(posedge clk) cyc <= cyc + 1; // 全局周期计数
// start 拍记录 cyc0, done 拍: $display("[PERF] layer cycles = %0d", cyc - cyc0);
本项目实测对账(50MHz,单层周期数含层内全部开销;估算列即 KP1 表 × 输出像素数 + 池化 2 拍/像素):
| 层 | 输出像素 | 估算周期 | 实测周期(示例) | 备注 |
|---|---|---|---|---|
| C1 conv5×5+ReLU | 4 704 | 18 816 | 19 103 | 利用率 78%(25 项不整除 32,尾拍空转) |
| S2 pool2×2 | 1 176 | 2 352 | 2 401 | 2 拍/像素 |
| C3 conv5×5+ReLU | 1 600 | 30 400 | 30 882 | 利用率 99% |
| S4 pool2×2 | 400 | 800 | 818 | — |
| C5 fc(400 项) | 120 | 6 000 | 6 214 | 含向量预载 400 拍 |
| F6 fc | 84 | 1 260 | 1 311 | — |
| OUTPUT fc | 10 | 110 | 127 | ReLU 关 |
| 整网合计 | — | ≈ 5.97 万 | ≈ 6.15 万 | vs 1M 预算余量 16×;vs 串行估算 45~50 万,加速比 ≈ 7.5× |
瓶颈分析三板斧:① MAC 利用率 = 有效乘法数 ÷(周期数×8):全网 ≈ 42 万 ÷(6.15 万×8)≈ 85%,损失主要在 C1 的"25 项 ÷ 8 = 3.125"尾拍空转与层间切换;② 访存带宽:每拍权重 8B + 特征图 ~1B ≈ 9B/拍,50MHz 下 450MB/s,BRAM 片内带宽(数 GB/s)绰绰有余 —— 瓶颈在算不在存,与 08-07"小网络算力受限、大网络带宽受限"的结论互相印证;③ 频率:关键路径在 requant 的 32×16 乘 + 移位 + 饱和单拍串联,50MHz 轻松、100MHz 需切两级流水 —— 下一步提频/提并行度(16/32 MAC)的路线与代价(权重口要 128/256bit,需 bank 化)都能顺着这张账讲出来。
一切就绪,现在是见证结果的时刻:喂一张真实的 MNIST 手写数字图,让这个亲手搭的加速器告诉我们"它是几"。跑通之后,再做一次诚实的复盘 —— 翻过的车、面试的讲法。
是什么:端到端 demo 流程:MNIST 测试集取一张 28×28 灰度图 → 补边成 32×32(经典 LeNet 输入尺寸)→ 按输入量化参数映射成 INT8 → 写成 input.hex → TB 载入特征图 A 块 → 主机序列依次启动 C1→S2→C3→S4→C5→F6→OUT 七次层计算(LeNet 的 8 个算子层,池化占 2)→ 读回输出层 10 个 INT8 logits → 有符号 argmax → 得到识别数字。为什么:随机输入只能证明"电路与黄金模型一致",真实图才能证明"整网语义正确" —— 量化精度、逐层 scale、softmax 前的 logits 大小关系,这些只有在真实数据上才见真章。怎么做:三段代码 + 一张层序列表:
# prep_mnist.py(骨架)—— 真实图 → input.hex
from torchvision.datasets import MNIST
import numpy as np
img = MNIST('data', train=False)[0][0] # 28×28 float, 0~1, 例: 数字 "7"
x32 = np.zeros((32, 32), dtype=np.float32) # 补 2 圈 0(经典 LeNet 32×32 输入)
x32[2:30, 2:30] = img.numpy()
x_q = np.clip(np.round(x32 / s_in), -128, 127).astype(np.int8) # s_in 来自 08-08
np.savetxt('input.hex', (x_q.reshape(-1) & 0xFF), fmt='%02x')
# ---- 推理输出回读(仿真结束后) ----
logits = np.fromfile('rtl_out.hex', sep='\n', dtype=np.uint8).astype(np.int8)
digit = int(np.argmax(logits)) # ★ 有符号比较: INT8 logits
print('recognized:', digit) # 标签 7 → logits[7] 最大
print('one-hot :', np.eye(10, dtype=np.int8)[digit])
主机层序列(每层 = 写 6 个参数寄存器 + CTRL.start,轮询 done;与 08-10 寄存器表一一对应):
| 序 | 层 | layer_type | k5/relu_en | ch_in−1 / ch_out−1 | fm_in−1 → fm_out−1 | 读A写B? |
|---|---|---|---|---|---|---|
| 1 | C1 conv | 000 | 1 / 1 | 0 / 5 | 31 → 27 | 是(buf_sel=0) |
| 2 | S2 pool | 001 | — / 0 | 5 / 5 | 27 → 13 | 是(翻转→1) |
| 3 | C3 conv | 000 | 1 / 1 | 5 / 15 | 13 → 9 | 是(翻转→0) |
| 4 | S4 pool | 001 | — / 0 | 15 / 15 | 9 → 4 | 是(翻转→1) |
| 5 | C5 fc(400) | 010 | — / 1 | 15 / 119 | 展平 400 → 1 | 是(翻转→0) |
| 6 | F6 fc | 010 | — / 1 | 119 / 83 | 120 → 1 | 是(翻转→1) |
| 7 | OUTPUT fc | 010 | — / 0 | 83 / 9 | 84 → 1 | 写 OUT 结果 10B |
示例结果:测试图标签 "7" → 输出 logits ≈ [−83, −41, −57, −35, −66, −52, −12, 96, −44, −30],argmax = 7 ✓;换 10 张图批量跑,INT8 版识别率 98 张对 99(98%),与 08-08 的精度评估一致 —— 硬件整网闭环完成。上板延伸(一句话版):FPGA 上把 weights.hex 转成 BRAM 初始化(.coe)或上电经 APB 灌入、特征图换到片外 SRAM、APB 主机用 Zynq PS 或 UART 桥代劳,即可脱机演示;引脚约束与时序收敛按普通 FPGA 流程走,本项目规模(8 DSP + 十几块 BRAM)在 Artix-7 级别毫无压力。
是什么:复盘 = 把项目里踩过的坑沉淀成两张资产:一张翻车点清单(给自己和后人),一套面试话术(给未来的自己)。为什么:本项目 14 个知识点里几乎每个都埋了一个"经典 bug",$signed、饱和截断、乒乓时机…… 现在把它们集中收拢一次;而面试官只有三分钟,你必须把三个月的项目讲出层次。怎么做:先收清单:
| # | 翻车点 | 指纹(怎么发现) | 本页对策出处 |
|---|---|---|---|
| 1 | INT8 乘法漏 $signed | 负权重全错,错值"大而合理" | KP3 |
| 2 | requant 饱和写成截断 | 大面积错、错值像随机数(卷绕) | KP4 |
| 3 | bias 按 INT8 存/加 | 整层输出整体偏移 | 08-10 + KP3 预装 |
| 4 | 行缓冲时序链取旧值 | 每行首列错、窗口整体歪一格 | KP2 |
| 5 | buf_sel 翻转时机错 | 层内读到自己的写回,窗口全错 | KP9 |
| 6 | 复位遗漏(行缓冲/指针) | 首层前段错或每跑不一致 | KP10 |
| 7 | RAM 读滞后 1 拍没算 | 每窗口第一项错 | KP8/9/10 |
| 8 | 权重补齐错位(第二通道起) | 首通道对、其余全错 | KP7/8 |
| 9 | 输出层误开 ReLU | argmax 恒为 0 号类 | KP13 |
| 10 | 黄金模型与 RTL 语义不一致 | "错得和 golden 一样"但整网识别率崩 | KP11 |
面试话术 —— 三分钟讲清你的 NPU(四段结构):
以下 10 条资源全部经人工核实真实可达。使用建议:写卷积引擎前看 8(行缓冲图解)与 4(别人的 conv/pool 模块怎么切),联调卡住时看 6/7(同龄人怎么跑通顶层与数字识别),想继续深入读 1/2/3 的 RTL 与文档,想系统成长报名 5,平时用 9/10 磨 Verilog 手感。
$signed(win_data[7:0]) * $signed(wgt_data[7:0]) 若漏写 $signed,最典型的后果是?以下链接均已人工核实可达(2026-09)。第 6 节资源卡的同源列表,供快速跳转;量化公式与工业实践部分与 08-08 / 08-07 页的参考来源互为补充。