⚙️ NPU 项目实战(下): 四大运算模块设计与整网联调

上半场(08-10)我们冻结了 LeNet-5 INT8 加速器的 Spec、寄存器、控制 FSM 与顶层架构;本页完成"下半场"—— 把数据通路一块块造出来: 行缓冲 + 8 路并行 MAC 的卷积引擎、requant 重量化、ReLU、池化、全连接、权重存储与乒乓双缓冲,然后整体联调,用 Python 黄金数据逐比特比对,最终在仿真里跑通 MNIST 手写数字识别。
NPU 项目实战 卷积引擎 行缓冲 line buffer MAC 阵列 requant 量化 黄金模型验证
🎯 本页学习目标
1. 能说清卷积模块"行缓冲 + 8 路并行 MAC"微架构的选型理由,并推算各层周期数与 MAC 利用率
2. 能独立写出行缓冲开窗、INT8 MAC 阵列($signed)、requant 饱和量化、ReLU、2×2 池化的可综合 Verilog
3. 能设计权重 RAM 的层/通道地址映射与 64bit 读口组织,并用 $readmemh 完成 hex 预载
4. 能把全部模块联调成 npu_top,按"握手检查清单"定位死锁、复位遗漏、位宽不匹配三类高频问题
5. 能搭建"PyTorch → INT8 量化 → hex 导出 → RTL 仿真 → 逐比特比对"的黄金数据闭环,并跑通 MNIST 一张图的完整 8 层推理
建议用时:约 70 分钟(正文 45 分钟 + 代码精读与自测 25 分钟)
前置要求:建议先读完 08-10 NPU项目实战(上)(本页沿用其寄存器表与 FSM),并复习 08-07 硬件映射08-08 模型量化

1 卷积引擎: 并行度选型、行缓冲与 MAC 阵列

卷积占了 LeNet-5 全网 90% 以上的计算量,卷积引擎是整个项目当之无愧的核心。08-10 的 Spec 留了一个"未决项": MAC 并行度。本节先算账定并行度,再把它拆成两个可独立验证的子模块 —— 行缓冲开窗器(解决"窗口数据从哪来、怎么不重复读")与 8 路 MAC 阵列(解决"8 个乘积怎么并行、怎么累加")。先看微架构总图,后面三个知识点逐块拆解:

权重 RAM 64bit 读口 = 8×INT8/拍 8 权重/拍 特征图 RAM 乒乓 A/B 16K×8bit 行缓冲 2 级 × 32 × 8bit 移位寄存器行缓存 3×3 窗口寄存器 w00…w22 · 9×8bit 8 路并行 MAC MAC0 win[k+0]×w[k+0] MAC1 win[k+1]×w[k+1] MAC7 win[k+7]×w[k+7] 加法树 8→4 4→2→1 INT32 累加器 INT32 起点预装 bias 8b 3 行 8b×8 16b×8 32b → requant / ReLU / 池化 / 写回(第 2 节) ★ 每拍推进一个像素:行缓冲让 3×3 窗口 每拍滑动一次,同一像素最多被读 1 次 一个输出窗口 = K×K×CH_IN 项,按 8 项/拍分批累加:C5 层 400 项 → 恰好 50 拍/像素,利用率 100%
图 1 · 卷积模块微架构: 行缓冲 + 3×3 窗口 + 8 路并行 MAC + 累加树;蓝色为数据流(标注位宽),绿色为存储,橙色为运算

知识点 1 · 卷积模块总体方案: 串行 MAC vs 并行 MAC 阵列

是什么:卷积引擎的微架构就是在"乘法器数量 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×55×5×1 = 2525425/32 ≈ 78%
C3 conv5×55×5×6 = 15015019150/152 ≈ 99%
C5 conv5×55×5×16 = 40040050100%
F6 fc12012015100%
OUTPUT fc84841195%

选 8 的决定性证据就在这张表里:C5 的 400 项被 8 整除、F6 的 120 也整除,全网平均利用率超过 90%;若选 7 或 9,利用率与除法电路都会变难看(硬件做除以 3 很贵,并行度取 2 的幂,除法变截位)。循环次序也随之定版:输出像素在最外、输出通道居中、K×K×CH_IN 累加项在最内 —— 最内层每拍消费 8 个权重,权重 RAM 可以纯顺序读,这个次序同时决定了第 3 节权重 hex 的导出顺序,两边必须一字不差。

🛠 工程怎么问:面试官问「你的卷积加速器并行度怎么定的?」标准答法是三笔账:性能账(预算周期数 ÷ 总 MAC 数 = 需要的最低并行度,本项目 1M/42 万 ≈ 2.4,取 8 留 3 倍余量)、资源账(DSP 数量,8 个 8×8 乘法器一块 Artix-7 都绰绰有余)、带宽账(P=8 → 权重口 64bit,单 BRAM 可支撑;P=64 就要 512bit,得 8 bank 交错)。三笔账都能当场算,才叫"选型",否则叫"抄参数"。
⚠️ 易错点:① 只数乘法器不数读口 —— 8 个 MAC 需要每拍 8 个权重 + 8 个窗口数据,行缓冲解决后者,权重 RAM 拓宽到 64bit 解决前者,漏一个引擎就"饿着转";② 循环次序与权重导出顺序不一致,联调时每个数都"错得很有规律";③ 并行度写死在代码里 —— 应该用 parameter P=8,v2 提到 16 时只改一处。

知识点 2 · 行缓冲 line buffer 设计: 两级移位寄存器开 3×3 窗口

是什么:行缓冲(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 参数化生成。

🛠 工程怎么问:「行缓冲为什么不用 FIFO 实现?」—— 两种都可行:寄存器版好理解、窗口取数是纯组合、仿真波形直观,FPGA 小行宽下面积可接受;FIFO/Shift-RAM 版省触发器(映射 SRL16/BRAM),行宽大(如 1080p 视频)时必须用,但要选"首字直通/show-ahead"模式并小心 FIFO 的读延迟。追问「边界怎么办?」—— 本项目卷积无 padding,行缓冲天然不管边界,窗口只在 row≥K−1、col≥K−1 后有效,这正是把"无 padding"写进 Spec 省下来的控制逻辑。
⚠️ 易错点:① 时序链取旧值导致窗口错位一拍/一列(见上,输出整体歪一格,黄金比对时"每行第一个像素错");② win_valid 忘了与 in_valid 相与,输入空拍也输出假窗口;③ 行缓冲没复位,仿真首层输出前面带一大串 X,容易误判成"引擎坏了";④ 行宽寄存器用 fm_in(存 N−1)却拿它直接当列数比较上界,少算一列。

知识点 3 · 乘累加 MAC 阵列: INT8×INT8→INT32 与 $signed 之争

是什么: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。

🛠 工程怎么问:「这 8 个乘法器综合后映射成什么?」—— Xilinx 7 系列上,8×8 有符号乘 + 累加会被综合器打包进 DSP48E1 硬核(每核一个 25×18 乘法器 + 后加法器,8×8 绰绰有余),加法树走 DSP 级联与进位链;报告里看到 8 个 DSP48 就说明推断成功,若看到大量 LUT 乘法说明位宽/符号写法阻碍了推断。「为什么用加法树而不是 8 个累加器最后再合?」—— 加法树只有 log₂8=3 级深度,8 累加器方案要 8 个 32 位寄存器且最后还要一次 8→1 归约,面积时序都不划算。
⚠️ 易错点:① 忘 $signed 或只给一侧加 —— 两侧都必须,还有 win_data[i*8 +: 8] 这种位选择本身按无符号取,必须包进 $signed 再乘;② 把 16 位乘积先截断成 8/12 位再累加(想省位宽),负数直接错;③ bias 预装忘了 acc_clr 与第一个 mac_en 的先后,导致 bias 被多加一次或漏加;④ mac_en 门控漏掉,输入空拍照样累加,部分和被污染。

2 定点后处理: requant 重量化、ReLU 与池化

MAC 阵列吐出的是 INT32 累加值,但下一层只认 INT8 输入 —— 中间这段"从 32 位压回 8 位"的后处理链路由三个小模块组成:requant(乘逆 + 移位 + 饱和)、ReLU(逐元素截负)、池化(2×2 取最大)。它们电路都不复杂,却是数值正确性最容易翻车的地方:饱和与截断一字之差、有符号与无符号一念之差,输出就会错得神不知鬼不觉。本节逐个拆,并与 08-08 的量化公式严格对齐。

知识点 4 · requant 重量化: 除以 scale 为什么变成"乘逆 + 右移"

是什么:量化卷积的真实数学是 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 告警与隐式截位。

🛠 工程怎么问:「M₀ 和 shift 怎么来的?为什么硬件不直接除?」—— 答:M₀ = round(2^shift × s_in × s_w / s_out),量化脚本遍历选 shift 使 M₀ 尽量接近 2^15~2^16 以保留精度(完整推导见 08-08);硬件做除法器要几十倍于乘法器的面积,而且 scale 是每层常量,完全该离线算好。"为什么 requant 放在累加终点而不是每项乘完就量化?"—— 每项量化会累积舍入误差并丢掉中间小数,标准做法只在累加终点量化一次。
⚠️ 易错点:① 把饱和写成"直接取低 8 位"—— 200 截断成 −56,网络后半段全崩,这种错的特征是"黄金比对大面积错、错值像随机数";② >>> 写成 >>,负累加值右移后变正;③ m0 忘扩位就 $signed,当 m0 ≥ 32768 时变负数;④ 饱和与 ReLU 的先后:先饱和再 ReLU(max(0,x) 不会破坏已饱和的值),反着做可能在饱和前就被 clamp 掉负值边界语义 —— 顺序写进 Spec。

知识点 5 · ReLU 模块: 组合逻辑 max(0,x) 与流水寄存

是什么: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 绑定打拍"的习惯要贯穿全流水。

🛠 工程怎么问:「ReLU 要流水吗?关键路径在哪?」—— 本体只有一级 2 选 1,不构成关键路径;但要注意它在整条链 MAC树→requant乘法→移位→饱和→ReLU 的末端,若 requant 的 32×16 乘法 + 移位 + 饱和挤在一拍里,50MHz 可能都吃紧 —— 提频时把"乘"与"移+饱和"切成两级,ReLU 跟在末级。「为什么用 din[7] 而不是 $signed(din) < 0 比较?」—— 两者等价,但位选择零成本,综合结果一致;写出哪种都能过面试,说清"0 在补码里表示唯一,所以直接置 0 无歧义"才是加分点。
⚠️ 易错点:① 把 relu_en 做成运行时动态切换而 valid 没对齐,层尾最后几个像素用错配置;② 输出层误开 ReLU,logits 全为非负,argmax 仍可能对但置信信息全丢 —— 黄金模型同步开/关才能比对一致;③ 忘了 dout 在 en=0 时是旁路而不是清零。

知识点 6 · 池化模块: 2×2 max pool 与四选一比较树

是什么: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 像素,下游写口天然省一半带宽,无需反压。

🛠 工程怎么问:「池化为什么要比较树而不是四个一比?」—— 4 输入取 max 的组合深度:比较树 2 级;而"四个一起比"在硬件里不存在,任何多输入比较最终都展开成两两比较,树形展开深度最短。「INT8 池化有什么坑?」—— 符号比较:0x80 是 −128,无符号比较下它是"最大值",池化后整片区域被 −128 污染 —— 这是初学者第 2 常见的量化 bug(第 1 是 $signed 乘法)。「池化层和卷积层怎么共用引擎?」—— 池化单元作为可旁路的一级挂在 requant/ReLU 之后,layer_type=001 时控制 FSM 令卷积循环退化为"直通"(把输入原样流过窗口通路),或干脆由主机把池化层单独配置给同一条流水 —— 两种方案取舍在评审时能讲清即可。
⚠️ 易错点:① 忘 $signed,负值区池化结果全错;② 行缓冲按输入列索引而不是 col[5:1] 按输出列索引,配对错位;③ fm_out 是"边长−1"契约,col==fm_out 才回 0,写成 col==fm_out−1 每行少一列;④ 池化层行末没有处理偶数边长的余数(本项目保证边长为偶,Spec 的"不支持列表"里要写明)。

3 全连接与存储子系统: 权重 RAM 与乒乓双缓冲

运算模块之外,数据从哪来、算完到哪去,由存储子系统决定。本节三件事:全连接如何"伪装"成卷积复用 8 路引擎;权重 RAM 的地址映射与双口组织(含 hex 导出全流程);特征图乒乓双缓冲的 RTL 实现与切换时序。

知识点 7 · 全连接模块: 矩阵向量乘复用 MAC 阵列

是什么:全连接层 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 对齐)。

🛠 工程怎么问:「FC 为什么不单独做阵列?」—— 三层 FC 只占全网 2% 计算量,单独造一个 84 或 120 路的矩阵阵列,面积翻倍却只换来 2% 提速,典型的"优化错地方";真到了 GEMM 占大头的网络(BERT/LLM),才会像 Gemmini 那样把矩阵乘做成主阵列。「C5 输入向量放引擎里,那特征图 RAM 干什么?」—— 预载只是一次性搬运(400 拍),换来其后 120 次重复读全走片内宽口;这是"搬一次、用 N 次"的复用决策,能用周期账算给面试官听即可。
⚠️ 易错点:① 换输出神经元时忘了 acc_clr 预装 bias,上一神经元的累加残值混进下一行 —— 错法"沿对角线污染",黄金比对一眼识别;② 权重地址生成器没按"每行补齐到 8"跳过 padding 字节,从第二行起全部错位;③ 展平顺序与 Python 侧 flatten() 不一致(C 顺序 vs 行主序通道次序),x 向量本身错了,后面全错。

知识点 8 · 权重存储模块: 地址映射、双口 RAM 与 $readmemh

是什么:权重 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)合计
0x0000C16×32 = 192 B6×4 = 24 B216 B
0x00D8C316×152 = 2 432 B16×4 = 64 B2 496 B
0x0A98C5120×400 = 48 000 B120×4 = 480 B48 480 B
0xC7F8F684×120 = 10 080 B84×4 = 336 B10 416 B
0xF0A8OUT10×88 = 880 B10×4 = 40 B920 B
0xF440总占用 62 528 B < 64 KB ✓(每层先 W 后 bias,均 8 字节对齐)
逻辑字节地址映射(64KB) C1: W 192B + bias 24B @0x0000 · 共 216B C3: W 2432B + bias 64B @0x00D8 · 共 2496B C5: W 48000B + bias 480B @0x0A98 · 共 48480B(大头) 120 神经元 × 400 项, 顺序读 F6: W 10080B + bias 336B @0xC7F8 · 共 10416B OUT @0xF0A8 · 共 920B 空闲 → 0xFFFF(v2 扩层用) 地址拆分 字节地址[15:3] → 字地址 字节地址[2:0] → lane 8K×64bit = 64KB 层基址 = WEIGHT_ADDR 引擎从基址起顺序读 每拍 +1 字(8 权重) 物理 RAM: 8K × 64bit 双口 一个 64bit 字 = 8 个 INT8 lane 口 A: 引擎读, 64bit/拍 口 B: APB 写, 32bit+字节使能 byte_addr[2]=0 → lane0~3 byte_addr[2]=1 → lane4~7 配置模式才允许写(RAM 口互斥) 计算中写"正在读的块" → pslverr 拒绝 读路径: WEIGHT_ADDR(层基址) + 顺序偏移 → 字地址[12:0] → 口 A 64bit → 8 个 MAC;bias 段按 INT32 排在每层 W 之后
图 2 · 权重存储组织: 逻辑 64KB 按层分段(C5 独占 3/4),字节地址高 13 位索引物理字、低 3 位选 lane;引擎 64bit 顺读与 APB 32bit 写经双口互不打架

双口 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)
🛠 工程怎么问:「权重为什么 64bit 组织而不是 8 个单口 RAM?」—— 一块 8K×64 BRAM 的面积功耗远小于 8 块 8K×8,而且引擎侧天然要 8 连续权重,顺序读一个宽字即可;「$readmemh 上板还能用吗?」—— FPGA 综合器(Vivado/Quartus)支持 initial+$readmemh 推断 BRAM 初始化,ASIC 流程则要换成 memory BIST/加载机制;上板另一条路是保留 APB 写口、上电由主机灌权重(本项目驱动已具备该能力)。
⚠️ 易错点:① 导出脚本忘了每输出通道补齐,第二通道起 64bit 对齐被破坏,权重整体错位 —— 错法特征:第一通道结果对、后面全错;② bias 排布忘了在每层 W 之后(而不是文件末尾统一放),基址计算全偏;③ $readmemh 的文件路径相对于仿真工作目录而非源码目录,换目录仿真直接"全 X";④ 口 A 读滞后 1 拍没被引擎的取数时序考虑,每窗口首项用到旧值(联调问题清单常客,见第 4 节)。

知识点 9 · 特征图乒乓双缓冲: 计算与写回并行的实现

是什么/为什么: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,自洽)。时序对比如下:

层 N(C1) 层 N+1(S2) 层 N+2(C3) 运算引擎 卷积/池化/全连接复用 buf_sel: 0 → 1 → 0(每层 LOAD 态翻转一次) 特征图块 A 16K × 8bit 特征图块 B 16K × 8bit 读 A 写 B 层 N+1: 读 B(上一层的输出) 层 N+1: 写 A(闲块) 层 N+2: 读 A 层 N+2: 写 B 单缓冲: 层N计算 → 写完 → 停 → 层N+1才能读(层间串行等待, 还可能边读边写同地址) 乒乓: 层N写完 B 的瞬间, buf_sel 翻转, 层N+1 立即读 B —— 层间零等待, 读/写块永不相交
图 3 · 乒乓双缓冲: 奇偶层交替"读一块、写另一块",buf_sel 只在 LOAD 态翻转一次;蓝色为读路径、橙色为写路径
🛠 工程怎么问:「乒乓的代价与边界?」—— 代价是 2 倍特征图存储(本项目 32KB,BRAM 充裕);边界是它只解决"层间"并行,层内读写本就不同块,无需再拆;若网络出现 skip connection(ResNet),双块不够,要升级成多 buffer + 描述符 —— 这正是 NVDLA 用"多 buffer 组 + 寄存器配基地址"的原因。「计算模式中主机写另一块允许吗?」—— 教学版一律拒绝(简单、安全),工程版允许写闲块以腾出配置时间 —— 两种策略都写进 Spec 并让 TB 覆盖。
⚠️ 易错点:① buf_sel 复位值与首层输入块不一致,第一层读到的不是输入图;② 写口使能忘了和 buf_sel 相与,一层计算把两块同时写花;③ buf_sel 在每个输出像素/每通道翻转,而不是每层翻转一次 —— 层内就"自己读自己刚写的数据",窗口全错;④ 读滞后 1 拍:地址第 0 拍发、数据第 1 拍才回来,引擎若按组合读设计,整条链提前一拍采数。

4 整体联调: 全链装配、黄金数据比对与性能评估

每个模块单独验证通过(模块级 TB + 黄金数据)之后,要把它们装配成 npu_top 跑通一条完整的层序列 —— 这一步暴露的问题占项目总问题数的一半以上,而且几乎都与模块之间的缝隙有关:握手、复位、位宽、存储延迟。先看装配完成后的数据通路全链与位宽标注:

权重 RAM 64bit/拍 = 8 权重 特征图 RAM 乒乓 A/B 读口 8bit 行缓冲+窗口 line_buffer3 像素流 8bit 8×MAC INT8×INT8 $signed 加法树+acc 8→4→2→1 预装 bias requant ×M0 >> shift 饱和 ReLU max(0,x) 可旁路 2×2池化 max 树 可旁路 8b 8b×8 16b×8 32b 8b 8b INT8 写回「对侧」乒乓块 · 写口 8bit · 全部像素写完 → pe_done requant: 32b×16b → 48b 中间结果 全网 8 层 = 5×(本链全开) + 池化层(卷积循环旁路) + 输出层(ReLU 关、池化关);每层由主机写一次参数 + start
图 4 · 数据通路全链与位宽: 8b 像素进、8b 结果出,中间乘积 16b、累加 32b、requant 中间 48b;这是贴在你工位上那张图的最终版

知识点 10 · 整体联调: 例化清单、握手检查与三类高频问题

是什么:整体联调 = 把全部模块例化进 npu_top、接好控制/数据两条线,然后跑"主机序列:写权重 → 写输入图 → 逐层配置+start → 等 done"这条最小闭环。为什么:模块级 TB 各自证清白之后,剩余 bug 集中在接口语义的误解上 —— 脉冲还是电平、第几拍有效、复位后什么值,双方理解不一致就会"各自都对,合起来就错"。怎么做:联调前先过两张清单:

模块(数量)来源关键接口(联调盯防点)
npu_regfile(1)08-10APB + start 单拍脉冲 + ram_we 写通道 + irq
npu_ctrl(1)08-10pe_start/pe_ready/pe_done、buf_sel、参数快照 s_xxx
line_buffer3 + 窗口(1)本页 KP2win_valid 对齐条件(row≥2 且 col≥2)、行宽 fm_in=N−1
mac_array8(1)本页 KP3acc_clr 预装 bias、mac_en 门控、$signed
requant_sat(1)本页 KP4m0 扩位、>>> 算术移位、valid 同拍
relu_unit(1)本页 KP5en 旁路、输出层必须关
maxpool2x2(1)本页 KP6奇偶列配对、$signed 比较
fc_ctrl + 向量缓冲(1)本页 KP7换神经元 acc_clr、权重地址补齐步进
weight_ram(1)本页 KP8$readmemh 路径、读滞后 1 拍、口 B 半字写
fm_ram_dp(2)本页 KP9we 与 buf_sel 相与、读滞后 1 拍
握手/时序检查项(波形逐条看)正确的样子违规的典型表现
start(寄存器→FSM)单拍脉冲,且只在 idle=1 时出现计算中写 start 被采纳 → 双重启动
pe_start(FSM→引擎)单拍,且前一拍 pe_ready=1引擎没收到 → FSM 在 CALC 永等
pe_done(引擎→FSM)单拍,最后一个像素写回后 ≥1 拍写回未完就 done → 结果少尾巴
acc_clr ↔ mac_enacc_clr 在每窗口首个 mac_en 前一拍bias 加两次 / 部分和残留
RAM 读延迟地址发出后第 2 拍数据进 MAC首项用旧值 → 每窗口第一项错
buf_sel层内恒定,仅 LOAD 翻转,复位=0层内翻转 → 读到自己的写回
done_set → STATUS.done → irqdone 电平保持,读 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)贴墙上,每一级转换显式声明位宽
🛠 工程怎么问:「联调第一步做什么?」—— 不是跑整网,而是让第一层的前 10 个输出像素正确:小图(6×6、1 通道)配黄金数据,波形从头到尾走一遍,确认握手清单 8 项全绿;然后放大图,然后逐层,最后整网 —— 问题空间从"8 个模块 × 所有交互"收敛到"一次一层"。「两个模块对不上,谁的错?」—— 回到 Spec:接口行为以 Spec 文字为准,谁违背谁改;Spec 没写清,先补 Spec 再改码 —— 这习惯比任何调试技巧都值钱。
⚠️ 易错点:① 例化时端口位宽顺手写错(14 位地址接成 13 位),lint 能抓但没人跑 lint;② 忘了把 08-10 的 ram_err 接到 regfile,计算模式写 RAM 不再被拒,乒乓被主机写花;③ TB 没加看门狗,死锁仿真要跑满超时才发现,浪费几十分钟;④ 顶层连了两个 clk 源(手滑),仿真对不上拍。

知识点 11 · 功能仿真与黄金数据比对: 从 PyTorch 到逐比特 PASS

是什么:黄金数据(golden data)验证法:用 Python/NumPy 按 Spec 公式对同一组输入算出期望输出,RTL 仿真输出与它逐比特比对,容差必须为 0(定点计算是确定的)。完整闭环六步:

① PyTorch LeNet 训练/加载 state_dict ② INT8 量化 08-08 脚本 M0/shift/每层 ③ 导出 hex weights/input +golden 各层 ④ RTL 仿真 TB $readmemh $fwrite rtl.hex ⑤ 逐比特比对 Python 比对器 容差 = 0 ⑥ 定位 首个 mismatch 层/坐标→回溯 FAIL 定位改完 → 从③重跑(脚本化, 一条 make 命令) 定点计算确定 → 六步全部脚本化, 改一次 RTL 只需重跑 ③④⑤
图 5 · 黄金数据比对闭环: ③④⑤ 必须脚本化,让"改一行 RTL → 60 秒内知道对不对"成为日常

为什么: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 秒内结束,而不是挂满超时。

🛠 工程怎么问:「为什么用 NumPy 不用 PyTorch 直接出 golden?」—— NumPy 的逐元素表达与 Spec 公式一一对应(requant_np 十行写完),而框架算子内部实现(如 conv 的累加顺序、rounding 模式)不完全透明 —— 但最终整网级还要与 PyTorch FP32 的预测标签交叉比对一次,确认量化本身没把精度带崩。「跑得慢怎么办?」—— iverilog 跑定向、Verilator 编译成 C++ 跑回归,速度差一个数量级;商业模拟器(Questa/Xcelium)有 SVA 立即断言加持,定位更快。
⚠️ 易错点:① 黄金模型自己写错(饱和与移位顺序、ReLU 是否打开),RTL"错得和 golden 一样"整网也过不了 —— 每层 golden 要先和 PyTorch FP32 的标签交叉校验;② hex 导出符号数没转无符号视图(&0xFF),$readmemh 读进 8 位寄存器变成截断值;③ TB 用 == 比对,X 与任何值比较为假反而"通过" —— 必须用四态 !==;④ 改了 RTL 忘了重新导 hex(权重次序变了),对着旧 golden 白查半天。

知识点 12 · 性能评估: 实测周期数、MAC 利用率与瓶颈分析

是什么:性能评估 = 用 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+ReLU4 70418 81619 103利用率 78%(25 项不整除 32,尾拍空转)
S2 pool2×21 1762 3522 4012 拍/像素
C3 conv5×5+ReLU1 60030 40030 882利用率 99%
S4 pool2×2400800818
C5 fc(400 项)1206 0006 214含向量预载 400 拍
F6 fc841 2601 311
OUTPUT fc10110127ReLU 关
整网合计≈ 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 化)都能顺着这张账讲出来。

🛠 工程怎么问:「实测比估算多 3%,为什么?」—— 层间 FSM 切换(CONFIG/LOAD 各 1~2 拍)、FC 向量预载、行缓冲对齐期的无效拍(每层 2×W+3)—— 这笔"开销账"能列出来的都是加分项。「如果预算收紧到 10 万周期怎么办?」—— 现有 6.15 万已达标,真要再快:并行度 8→16(C1 尾拍损失减半)+ requant 切流水提频到 100MHz,理论 4 倍 —— 先说清现有余量,再谈优化,才是工程师的答法。
⚠️ 易错点:① 只报整网周期不拆层,瓶颈无从谈起;② 周期计数包含了 TB 的等待拍(主机配置时间),数值虚高 —— 从 start 采到 done 才是层周期;③ 拿仿真周期直接换算"等效频率",仿真周期数与综合后 fmax 是两回事,fmax 必须跑综合时序报告;④ 忘记"08-07 估算"的口径(串行 1 MAC)与本次口径(8 MAC)不可直接混比,要换算成加速比。

5 跑通数字识别与项目复盘

一切就绪,现在是见证结果的时刻:喂一张真实的 MNIST 手写数字图,让这个亲手搭的加速器告诉我们"它是几"。跑通之后,再做一次诚实的复盘 —— 翻过的车、面试的讲法。

知识点 13 · 数字识别功能实现: 一张图走完全部 8 层

是什么:端到端 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_typek5/relu_ench_in−1 / ch_out−1fm_in−1 → fm_out−1读A写B?
1C1 conv0001 / 10 / 531 → 27是(buf_sel=0)
2S2 pool001— / 05 / 527 → 13是(翻转→1)
3C3 conv0001 / 15 / 1513 → 9是(翻转→0)
4S4 pool001— / 015 / 159 → 4是(翻转→1)
5C5 fc(400)010— / 115 / 119展平 400 → 1是(翻转→0)
6F6 fc010— / 1119 / 83120 → 1是(翻转→1)
7OUTPUT fc010— / 083 / 984 → 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 级别毫无压力。

🛠 工程怎么问:「随机输入已经逐比特过了,为什么还要跑真实图?」—— 两级验证各司其职:随机+黄金证"电路=Spec",真实图+FP32 标签证"Spec=算法意图"(量化精度损失、逐层 scale 错误只会在真实数据上显形);「argmax 为什么强调有符号?」—— INT8 logits 有正有负,无符号比较会把 −1(0xFF)当最大值,识别永远输出"某个固定错类"。
⚠️ 易错点:① MNIST 像素 0~255 直接截成 INT8,负半轴全是"锯齿"—— 必须过 scale 量化;② 补边用了 255 而不是 0(量化后背景应为 0),等效给每张图加了一圈白框;③ 输出层忘了关 ReLU,负 logits 全被钳到 0,argmax 恒为 0 号类;④ C5/S4 之间的展平顺序与训练侧不一致(通道优先 vs 行优先),识别率骤降到 10% 附近 —— 是"语义错"而非"电路错"的典型信号。

知识点 14 · 项目复盘: 翻车点清单与"三分钟讲清你的 NPU"

是什么:复盘 = 把项目里踩过的坑沉淀成两张资产:一张翻车点清单(给自己和后人),一套面试话术(给未来的自己)。为什么:本项目 14 个知识点里几乎每个都埋了一个"经典 bug",$signed、饱和截断、乒乓时机…… 现在把它们集中收拢一次;而面试官只有三分钟,你必须把三个月的项目讲出层次。怎么做:先收清单:

#翻车点指纹(怎么发现)本页对策出处
1INT8 乘法漏 $signed负权重全错,错值"大而合理"KP3
2requant 饱和写成截断大面积错、错值像随机数(卷绕)KP4
3bias 按 INT8 存/加整层输出整体偏移08-10 + KP3 预装
4行缓冲时序链取旧值每行首列错、窗口整体歪一格KP2
5buf_sel 翻转时机错层内读到自己的写回,窗口全错KP9
6复位遗漏(行缓冲/指针)首层前段错或每跑不一致KP10
7RAM 读滞后 1 拍没算每窗口第一项错KP8/9/10
8权重补齐错位(第二通道起)首通道对、其余全错KP7/8
9输出层误开 ReLUargmax 恒为 0 号类KP13
10黄金模型与 RTL 语义不一致"错得和 golden 一样"但整网识别率崩KP11

面试话术 —— 三分钟讲清你的 NPU(四段结构):

🛠 工程怎么问:常见追问预备:「为什么不用脉动阵列?」—— 小网络做 8×8 脉动阵,数据装载开销吃掉收益,中等并行 + 行缓冲是同规模 FPGA 项目的更优点;「和 NVDLA 比差什么?」—— 指令化层调度、多 buffer 描述符、可配置卷积核、完整验证层级 —— 差距说得越具体,越显得你真读过;「项目里最难的一个 bug?」—— 从清单里挑一个,按"现象→定位过程→根因→沉淀"四步讲,重点讲定位手段(黄金数据二分、波形逆向追握手),这才是面试官想听的工程能力。
⚠️ 易错点(复盘写法):① 把复盘写成流水账("第一周干了啥"),翻车点没有"指纹→对策"结构,三个月后自己也看不懂;② 话术堆术语没有数字 —— 没有周期数、利用率、识别率的项目经历,在面试官眼里约等于"跑了个例程";③ 只讲成功不讲反思,主动暴露"我知道它哪里还不够好 + 我知道怎么改",反而更显工程成熟度。

6 项目资源: 开源工程、视频与文章(均已核实可达)

以下 10 条资源全部经人工核实真实可达。使用建议:写卷积引擎前看 8(行缓冲图解)与 4(别人的 conv/pool 模块怎么切),联调卡住时看 6/7(同龄人怎么跑通顶层与数字识别),想继续深入读 1/2/3 的 RTL 与文档,想系统成长报名 5,平时用 9/10 磨 Verilog 手感。

🧱
⭐ NVIDIA nvdla/hw —— NVDLA 官方硬件仓库(约 2.1k stars)
工业级开源 NPU 的 RTL、C 模型与测试平台。本页做完后去看它的卷积流水线与多 buffer 描述符,你会发现自己项目的每一个"简化决策"(行缓冲、乒乓、逐层启动)在工业版里对应什么形态 —— 差距清单就是进阶路线图。
GitHubNVIDIA 官方⭐ 工业对照高级
🧱
Gemmini(UC Berkeley)—— 可配置脉动阵列生成器(约 1.4k stars)
Berkeley 的空间阵列生成器(Chisel,Chipyard 生态),含 scratchpad、DMA 与 ReLU 激活电路。看它的累加与激活模块设计,对照本页 KP3/KP5 的教学版实现,理解"参数化生成"这一现代 NPU 开发范式。
GitHub⭐ 体系结构进阶高级
🧱
jofrfu/tinyTPU —— 面向嵌入式的小型 TPU 实现(约 580 stars)
参照 Google TPU 架构的 FPGA 张量处理单元,VHDL 编写,模块划分清晰(矩阵单元、激活、权重/特征加载)。它是本页"运算模块 + 存储 + 控制"三分法的最佳同量级对照物,适合联调前后通读。
GitHubTPU 架构高级
🧱
QShen3/CNN-FPGA —— Verilog CNN 模块库(约 590 stars)
中文作者开源的 Verilog CNN 模块(Conv2d、Max_pool 等,只推理不训练)。写本页运算模块前对照它的模块切分与端口风格,再回头看自己的参数化接口设计,会有具体的评判标尺。
GitHub⭐ 代码参考中级
🎓
⭐ 一生一芯(ySyx)公益芯片设计项目官网
中科院计算所发起:从零设计 RISC-V 处理器并真实流片,完整训练"需求→RTL→验证→流片"工程流程。做完本站 NPU 项目后,它是最合适的下一级跳板;其"黄金模型 + 逐阶段验证"方法论与本页 KP11 完全同源。
官方公益项目⭐ 强烈推荐中级
🎬
⭐ B站:《基于 PYNQ-Z2 的手写数字识别卷积加速器设计》(第 8 集: 顶层模块设计)
与本项目同题的完整系列(共 8 集):卷积模块、池化、顶层集成到 ZYNQ 上板跑手写数字识别。联调卡住时看别人怎么接顶层、怎么在板上验证,代入感极强 —— 作者雪天鱼(复旦微博士)讲解细致。
B站视频⭐ 同题实战中级
🎬
B站:《手把手教你用 Verilog 在 FPGA 上实现 CNN》系列课程介绍
纯 Verilog(非 HLS)逐模块实现 CNN 的视频系列:卷积、池化、全连接与数据搬运。适合在本页代码读不顺时换个讲法再学一遍 —— 视频里写模块的顺序与本页 KP2~KP7 高度对应。
B站视频Verilog CNN中级
📄
⭐ CSDN:《行缓存(line buffer)在图像卷积中的工作方式》
用 9×9 小图逐拍演示三行缓存如何随像素流滚动、3×3 窗口如何对齐、9 值如何并行乘加 —— 正是本页 KP2 的图文对照版。先把本文的图看懂,再回去读 RTL,行缓冲一遍就通。
CSDN 文章⭐ 行缓冲图解入门友好
🛠️
Verilator 官方用户指南(开源仿真器)
把 Verilog 编译成 C++ 的开源仿真器,回归速度比解释型模拟器快一个数量级。本页整网仿真(数十万拍 × 多张图)用 iverilog 会等到怀疑人生,Verilator 是黄金数据回归的正确工具;指南含 SystemVerilog 支持说明与覆盖率用法。
官方文档仿真工具中级
🛠️
HDLBits —— Verilog 在线练习场
免费在线 Verilog 习题集,即时仿真判题。写本页模块前若有语法不熟(位拼接 +:、generate、$signed 上下文),去 Combinational Logic 与 Verification 分类各刷几题,回来读 KP3/KP11 的代码会顺畅得多。
免费练习入门友好

7 本节自测

1. MAC 阵列中,8 位有符号乘法 $signed(win_data[7:0]) * $signed(wgt_data[7:0]) 若漏写 $signed,最典型的后果是?
💡 解析:选 B。Verilog 规定表达式中有无符号操作数就整体按无符号计算,补码负数的高位被当数值位;错值"数值合理但符号全反",黄金比对才会大面积发现。A 错在 8×8 位宽仍正确、只是数值解释错;C 不会报错 —— 这正是它阴险的地方。
2. 用行缓冲为 5×5 卷积开窗,需要几级行缓冲和多少个窗口寄存器?首个窗口何时有效?
💡 解析:选 C。K×K 窗口需要 K−1 级行缓冲(留住最近 K−1 行)+ K×K 个窗口寄存器;首个完整窗口要等第 K 行的第 K 个像素到位,对齐条件 row≥K−1 且 col≥K−1。B 是 3×3 的配置(2 级/9 个);A 多一级浪费;D 混淆了流式与帧缓存。
3. requant 模块把 INT32 累加值压回 INT8 时,为什么必须用饱和(saturate)而不能截断(取低 8 位)?
💡 解析:选 D。截断丢掉的是符号位邻域的高位,200(8 位需 9 位表示)低 8 位是 0xC8 = −56,错得面目全非且随层数放大;饱和把越界值钉在边界,是 TPU v1/NVDLA/TFLite 的通行做法。A"损失精度"说轻了 —— 是语义错误而非精度问题。
4. 关于特征图乒乓双缓冲的 buf_sel,下列说法正确的是?
💡 解析:选 B。层内翻转会让引擎读到自己的写回(窗口全错);主机手动管容易忘且违背"一次 start 一层"的调度模型;复位值必须与输入图所在块构成"第一层读 A 写 B"的自洽关系。对照 KP9 的两个不变式。
5. 整网仿真与黄金数据比对发现 C3 层有 mismatch。按本页方法,最高效的定位起点是?
💡 解析:选 A。逐层 golden + 四态比对的体系,天然提供"二分定位":先锁定第一个错像素(不是第一个错层,是错像素),再围绕它做数值级对照,C 是危险的"调参修 bug",D 违背定点确定性(容差必须为 0)。这正是 KP11"第一个 mismatch 三步追法"的套路。
📌 本节小结:① 卷积引擎选"行缓冲 + 8 路并行 MAC":并行度由性能/资源/带宽三笔账定出,400 项的 C5 被 8 整除、全网平均利用率约 85%;② 行缓冲 2 级 + 9 窗口寄存器(3×3)让每个像素只读一次 RAM,valid 对齐 row≥2 且 col≥2;③ MAC 阵列 $signed 乘法 + 加法树 + bias 预装,requant 走"×M₀→算术右移→饱和"工业链路,ReLU 用符号位一行实现,池化是行缓冲 + 四选一比较树;④ 全连接复用 MAC 阵列算向量点积,权重按 [out][in] 铺平、每输出通道补齐 8 的倍数,权重 RAM 8K×64bit 双口 + $readmemh 预载;⑤ 乒乓双缓冲层间零等待,buf_sel 只在 LOAD 翻转;⑥ 联调盯"握手/复位/位宽"三类问题,黄金数据六步闭环(量化→hex→仿真→逐比特→首个 mismatch 定位→回归);⑦ 实测整网 6.15 万周期(预算 1M),MNIST 识别率 98%,最后用四段话术把三个月讲成三分钟。
🤔 思考题: 1. 若把并行度从 8 提到 16,权重 RAM、窗口供给、C1 层利用率分别要做什么改动?列出每个改动的周期/资源账。
2. requant 目前是"乘+移+饱和"单拍串联:画出切成两级流水后的框图,说明 valid 要怎么跟着打拍,以及为什么切流水不改变任何输出数值。
3. 若网络出现 ResNet 式的 skip connection(两层输出要相加),乒乓双缓冲和主机逐层启动模式分别会遇到什么问题?你会怎么扩展架构?(提示:第三块 buffer / 片上累加路径)
4. 你的黄金模型用的是自己手写的 requant_np:如何证明"黄金模型自己"是对的?(提示:整网标签交叉校验只是必要条件,不是充分条件)

8 参考来源与延伸资源

以下链接均已人工核实可达(2026-09)。第 6 节资源卡的同源列表,供快速跳转;量化公式与工业实践部分与 08-08 / 08-07 页的参考来源互为补充。