第 40 环 · 链预投下层(算本层时下层权重已在路上)
第 39 环把主路的层权重收进了内存里的描述符链(核只给「链在哪、多长」),可吃法是阻塞的:引擎在 ST_WLD 把 req 举给链,链按描述符把本层 14 个字一个字一个字投完,引擎才开算——算一层、停一次、等链一次。NET_CHN_WAIT 记的就是这段等待。
链整个落在关键路径上。第 29 环早就教过一次怎么治这个病(乒乓双缓冲:算本层、取下层),只是那次治的是「按公式取权重」那条路。这一环把同一个办法搬到链上:算本层时,让链去投下层,落进另一个 bank,走到下层 ST_WLD 时常常一拍换 bank 就开跑。
链的握手是电平的(投完停在 ST_DONE,rdy 一直举着,直到 req 落下才回空闲)。所以「要一层权重」这件事不能再由 ST_WLD 那一拍直接举着——那只能表现成「停下等」。本环新增一个链取子引擎专门举这个电平:
CP_IDLE --cp_go--> CP_REQ --链把本层投完(rdy)--> CP_RLS --> CP_IDLE
│ │
│ 举着 req 走完一整轮 └─ 把「这个 bank 装的是哪一层」
│ (投完立刻撤 req 放链回空闲) 记进就绪标志 ld_ready/ld_layer
于是插入点只剩三处:冷启动(ST_IDLE 给第 0 层起一次链取——第 0 层没法预投)、预投(ST_PREK:算本层时给下层 li_nxt 起一次链取,投进 ~bank_r,同时把旧的就绪标志撤下)、换 bank(ST_WLD:就绪标志指着本层 → 一拍换 bank_r 就开算)。
两个 bank 乒乓:链写的是 cp_buf_r(在飞那一轮的目标 bank),计算读的是 rd_bank(阻塞路径读链正在写的那一个、预投路径读本层那一个)——写读永不打架,本层那份在整层跑完前一个字都不会被改。
核这边只多写一个寄存器:WC_PF(0x83C,1=链预投下层)。WC_PF=0 就是第 39 环的老语义(停下等链),一字未删。新探针 NET_CHN_PF(链取了几次)/ NET_CHN_OVL(有多少字是在算别的东西的时候投进来的),新观察点 SCREEN15(0x848)。前 39 圈地址映射一字不改。
| 证据 | 结果 | 说明 |
|---|---|---|
| D 趟(预投)vs A 趟(第 39 环阻塞链) | 逐位一致 | 只改了「谁等谁」,算出来的东西一个字没变 |
E 趟(WC_PF=0 回到阻塞) | == A 趟 | 老路径原样保留、结果不动(递增不推翻) |
| F 趟(再关预投跑一趟) | 两个探针都不再长 | 重叠确实来自预投路径,不是碰巧 |
NET_CHN_PF | 2 次 | 冷启动第 0 层 + 预投第 1 层,两层各一次链取 |
NET_CHN_OVL | 14 字 | 下层的 14 个字全是在算本层的时候投回另一个 bank 的 |
| 等链拍数:阻塞趟 → 预投趟 | 98 拍 → 52 拍(省 46) | 等链的拍被本层的计算盖住了——这是本环唯一想要的东西 |
| 改一条描述符(层1 LN 借住层0 LN 地址) | 预投路径输出跟着变 | 预投拍到的真是内存里那一层的权重 |
单元仿真 tb_chain_pf:预投路径 y=0xFA03FC07、链投 28 字、链取 2 次、重叠 14 字、166 拍;阻塞路径 210 拍、重叠 0 字;公式路径(第 28~30 环)输出与之逐位一致;改描述符输出跟着变、还原后逐位回到原值;单层只有冷启动 1 次链取、没有下层可预投(诚实口径);预热 8 趟后的 A(链)/B(公式)/C(链) 三趟两两一致。
协同仿真 tb_min_soc40:整机跑完解码循环 → 采样序列仍是 [0,2,0,0,0,3](第 6 步吐停止符 3 而停);SCREEN = 0xF905FD04、SCREEN2 = 0x02040404、SCREEN14 = 0x1C3C0107 与第 25~39 环逐位一致。全量回归全绿。
其一(读 1 写 0)。链的阻塞路径一直写 bank 0(第 39 环写死的),本环把「读哪个 bank」也统一成「链正在写的那一个」之后,才发现旧代码是写 0 读 bank_r——而前面第 29/30 圈的收尾试验会把 bank_r 留成 1。于是 A 趟(链)读到的是另一个 bank 里的旧字,A ≠ B,收尾自曝在阶段 27。单元仿真里 bank_r 从头到尾是 0,所以单测全绿、整机才炸:同一个模块,状态是前面几十圈攒下来的——单测跑不出这类缝隙,整机协同仿真才跑得出来。
其二(电平握手的半程不能撤)。链的 rdy 是电平,req 一撤它就回空闲。若把「要一层权重」做成单拍脉冲,撤了以后没人接那半程握手,链会一直停在 ST_DONE。所以必须由一个子引擎举着 req 走完一整轮(CP_REQ→CP_RLS),投完那一拍才撤。
其三(等链拍数怎么量)。NET_CHN_WAIT 在每趟 start 时归零,所以「一趟跑完读到的就是本趟的等链拍数」。我一开始按「跑完减跑前」量(差值),结果在预投趟上得到 52 − 98 = −46,8bit 一看是 210——差点把「预投比阻塞省 46 拍」这条正确结论读成「预投反而多等 210 拍」。先把口径说清,再读数。
其四(页内序号错一位)。SCREEN15 的写译码我写成了 6'd16(= 0x840,那是只读探针槽),屏幕永远是 0——而 RTL 里的自检其实全过了。修正到 6'd18(=0x848)后数字才出来。地址按 [7:2] 编号时,字节地址÷4 要再数一遍。
第 39 环让「权重住哪」由内存里的描述符说了算;这一环让「链在什么时候把权重送进来」也有了提前量——算本层时下层已经在路上。NET_CHN_WAIT 从 98 拍掉到 52 拍,掉掉的正是那两层里被判为「可以提前」的部分。
离目标更近的一点在于:真模型每层权重是几 MB 到几 GB,取一层的耗时远大于算一层——只要取权重还落在关键路径上,NPU 就有一半时间在等内存。这一环把「等」收进计算背后,是「以权重流带动计算」而不是「以计算迁就权重」的那一步。
本环的提前量只在一趟之内:第 0 层永远没法预投(还没开算),所以每一趟 npu.net 都要付一次冷启动(那 52 拍里的主要部分)。可解码循环是一趟接一趟跑的(每个 token 一趟),趟与趟之间本可以接着流水——下一环让上一趟的末尾就把下一趟的第 0 层投好,顺便把「链配置一变,这份预投就作废」的作废通路一起做出来:跨趟预投 · 下一趟首层在上一趟末尾已在路上。