第 29 环 · 权重预取双缓冲(算本层时取下层)
还是同一个圆心:一台开机只有一个 AI 的手机。上一环权重的确住进了内存,可取法是串行的:每层开跑前,引擎先停下来逐字取满 16 个字(ST_WLD → ST_WLDR),取满才进计算——算一层、停一次、取一层、再算一层。层数一多,取权重的这几百拍全落在关键路径上,NPU 有一半时间在等内存。
真芯片不这么干:把「等一下层」藏进「算本层」里。这一环给整网引擎装两个权重 bank(乒乓)+ 一个与计算并行的装载器:算本层时读 bank A,同时把下层的 16 个字偷偷取进 bank B;本层算完,bank B 已经满了,一拍换 bank 直接开跑。
· 片内权重扩成两份:一层权重的片内落点从一份(wq_r/wq2_r/wk_r/wv_r/w1_r/w2_r/g_r/b_r)扩成两个 bank(wq_b[...] 等,索引 bank_r)——计算读一份、装载器写另一份,读写互不打架,预取才可能与计算同时进行;
· 装载器独立出来:权重装载变成一个与层循环并行的子引擎(PF_IDLE / PF_REQ / PF_WAIT / PF_DONE),主循环只跟它握手(ld_ready / ld_layer / ld_buf);
· 预取那一刻:新增状态 ST_PREK——在开跑本层的那一刻,按「层号 × 16」给下一层起一次装载,写进 ~bank_r(计算正在读的那份一个字都不动);
· 换 bank 只花一拍:ST_WLD 只在「就绪的层号 == 本层号」时换 bank(ld_ready && ld_layer == lcnt);就绪了就是一拍过,不再等;
· 只扩不推翻:w_pf 选择预取——1 = 双缓冲预取(本环新增、主路默认),0 = 第 28 圈的逐层串行路径(一字未删,只是不在计算期间预取);w_src 的寄存器驻留路径、前 28 圈的地址映射一个字没动;
· 探针:0x2DC NET_W_PF_CNT = 与计算重叠取回的字数——把「取权重被计算盖住了」钉在寄存器上。
rtl/layer_net.sv:装载器与层循环并行,预取写进另一个 bank:
// 预取那一刻:开跑本层,就把下层权重取进另一个 bank
ST_PREK: begin
if (w_pf && (li_nxt < layers_r)) begin
ld_ready <= 1'b0; // 旧 bank 的就绪标志先撤下
pf_layer <= li_nxt; // 取下一层
pf_buf <= ~bank_r; // 写进「计算不读」的那个 bank
pf_go <= 1'b1;
end
st <= ST_PROJ;
end
// 换 bank:就绪的正好是本层 → 一拍过(那一拍就是「不等」)
ST_WLD: if (ld_ready && (ld_layer == lcnt)) begin
bank_r <= ld_buf;
st <= ST_PREK;
end else if (w_src && !pf_busy && !pf_go) begin
ld_ready <= 1'b0; // w_pf=0 或预取晚一步:现在起装载
pf_layer <= li;
pf_buf <= (lcnt == 0) ? 1'b0 : ~bank_r;
pf_go <= 1'b1;
end
// 探针:这个字是「在算别的东西的时候」取回来的吗?
if (in_compute) pf_ovl <= pf_ovl + 8'h1;
rtl/min_bus.sv:新接口 + 探针接到核可见的寄存器:
// 0x2D8 NET_W_PF 1 = 双缓冲预取(算本层时取下层)
// 0x2DC NET_W_PF_CNT 与计算重叠取回的字数(探针)
6'd54: net_w_pf_r <= dmem_wdata[0]; // 写
6'd55: dmem_rdata_reg = {24'b0, net_w_pf_cnt}; // 读
OS 一行不改主路逻辑,只在开机时多一句 sw 1, 0x2D8(NET_W_PF=1)——此后核不必管权重什么时候取,硬件自己算好时间。
单元仿真 tb_layer_net29(引擎 + 行为级权重 SRAM 模型,5 组用例):串行路径 y=0xFA03FC07、重叠 0;预取路径 同一个 y=0xFA03FC07(逐位一致)、重叠 16;单层(没有下层可预取)w_cnt=16、重叠 0;改 SRAM 里层 1 的 FFN W2 → 预取路径变 0xF703F90A;寄存器路径不受影响仍 0xFA03FC07。
协同仿真 tb_min_soc29(整机:核 → OS → 解码循环 → 收尾九项试验):
| 探针 / 断言 | 结果 | 说明 |
|---|---|---|
| 预取路径 NET_W_CNT | 32 | 每层 16 字 × 2 层,真从 SRAM 取 |
| 预取路径 NET_W_PF_CNT | 16 | 下层 16 字全在算本层时取回 |
| 串行路径 NET_W_CNT | 32 | 取的字数一样 |
| 串行路径 NET_W_PF_CNT | 0 | 取权重时没在算任何东西 |
| 预取路径 vs 串行路径输出 | 逐位一致 | 只改了「谁等谁」,计算一个字没动 |
SCREEN2 = 0x02040404(整网 digest,同第 26~28 环)、SCREEN3 = 0x04000204(ISA 契约 digest,同第 27/28 环)、SCREEN4 = 0x10200707(预取 digest:重叠 16 字 | 累计取 32 字 | 预取路径 lane0 = 7 | 串行路径 lane0 = 7 —— 最后两位相等正是「两条路径逐位一致」)。全量回归 43 项(新增 layer_net29 与 min_soc29)全部通过。
调试中还踩实两颗非显而易见的坑:① 就绪标志没跨层撤销 → 串行路径第二层会复用第一层的 bank(已修:加 ld_layer 比对 + 起装载时撤就绪);② PF_DONE 与「层循环发现权重没就绪」同一拍并发 → 同一层被重复装载(已修:pf_busy 定义成 pf_st != PF_IDLE)。
这一环之后,权重不再是「算一层等一层」:算本层的时候,下层已经在路上了。NPU 的空转被本层的计算填满——同样的层数、同样的权重、逐位一样的结果,少掉的只是「干等内存」的那些拍。
对「开机只有一个 AI」的手机来说,这是让算力不被内存拖住的第一步:真模型的层多得数不清,如果每层都要停下来等权重,再强的 NPU 也只是在等总线。取数并行起来,这条通往真 DeepSeek 的路才不会堵在带宽上。
权重分块 · 一层权重分多拍取(层大过片上缓冲)。现在一层只有 16 个字,两个 bank 整块装得下;真模型一层有几十万到上百万个权重,片上缓冲根本装不下。下一环把一层权重切成多块:算一块、取下一块,块内流水——「双缓冲」从「层与层之间」下沉到「块与块之间」。