第 27 环 · ISA 契约(把整网启动做成一条 NPU 指令)
还是同一个圆心:一台开机只有一个 AI 的手机。上一环「整张网」已经收进一个引擎了,可软硬之间那道契约还很啰嗦:核要说「跑整网」这件事,得先 sw NET_X 把残差流放进寄存器、sw NET_LAYERS 声明层数、sw NET_GO 发令,然后 lw NET_BUSY 一圈圈轮询等它跑完,最后逐 lane lw NET_Y0..3 再 slli/or 拼回一个 32bit——一轮解码里核围着寄存器转了十几条访存指令。
这一环把这道契约写进指令集:整网的「启动 / 等待 / 读回」各变成一条 NPU 指令。芯片软硬协同的真正分界线就在这里——硬件长出引擎,ISA 长出接口。
继续「端上紧凑、全定点、每步硬件自洽、只扩不推翻」的主线:
· 核升 v7,加三条 custom-0 指令(RISC-V 留给自定义加速器的 opcode 0001011):npu.net rs1, rs2(rs1=输入残差流、rs2=层数 → 一条指令启动整网)、npu.wait(整网没跑完就冻结整条流水线)、npu.rd rd, sel(rd 收结果,sel 选源:0=整网输出 4 lane packed、1=层号、2=末层两头落点、3=每层 K 条数);
· 操作数直接进硬件——npu.net 在 EX 拍把两个操作数当场当「启动脉冲 + 输入」送进整网引擎(不经过任何寄存器),这就是指令驱动外设;
· 读回并进 load 通路——npu.rd 的结果与 lw 走同一条回写通路(含 MEM→EX 转发),所以指令读回和访存读回天然同源;
· 只扩不推翻——前 26 圈的寄存器路径一个字没删(0x280~0x2BC 照旧可用、老 OS 照跑),新指令只是多了一条更短的入口;数据面(整网引擎)一个门都没动。
rtl/riscv_core.sv 升 v7(三条自定义指令 + 解码 + 阻塞 + 读回通路),rtl/min_bus.sv 把整网引擎的结果线直接接进核,rtl/layer_net.sv 在 start 那一拍锁存操作数,OS 主路改成三条指令:
// 核 v7:custom-0 三条指令 localparam OP_CUSTOM0 = 7'b0001011; NPU_NET = 3'b000 // npu.net rs1=输入残差流, rs2=层数 NPU_WAIT = 3'b001 // npu.wait 阻塞到整网跑完 NPU_RD = 3'b010 // npu.rd rd=结果, funct7=选源 assign npu_start = ex_npu_net; // EX 拍单脉冲 assign npu_x = ex_opA; // 操作数直接当输入 assign npu_layers = ex_opB; wire ex_npu_stall = ex_npu_wait && npu_busy; // npu.wait:整条流水线冻结 wire ex_slow_read = ex_mem_read || ex_npu_read; // npu.rd 与 load 同类:紧跟的消费者等一拍 # OS 主路:一轮解码 = 三条 NPU 指令(前 26 圈这里是十几条访存) .word 0x016A000B # npu.net s4, s6 ← 一条指令启动整网 .word 0x0000100B # npu.wait ← 一条指令等它跑完 .word 0x0000240B # npu.rd s0, sel=0 ← 一条指令读回整网输出(4 lane packed)
读回源:npu.rd sel=0 → 整网输出(4×INT8 packed,正好是一个寄存器)、sel=1 → 片内层号、sel=2 → 末层两头落点、sel=3 → 每层 K 条数;另加 0x2C0 NET_PROBE 只读探针,锁住「启动那一拍引擎到底收到了什么」(x 低 16bit / layers / 是否来过)。
这一环要证明「软硬之间那道契约真的变成指令了」,现场五条硬证据:
· 证据一:一条指令启动——一轮解码从「sw NET_X → sw NET_LAYERS → sw NET_GO → lw NET_BUSY 轮询 → 逐 lane lw NET_Y0..3 再拼」的十几条访存,缩成三条指令;
· 证据二:操作数真进了硬件——npu.net 的 rs1/rs2 就是引擎的输入与层数:layers=1 时片内层号只停在 1、layers=2 时跑到 2;只换 npu.net 的第二个操作数,整网的行为就跟着变;
· 证据三:npu.wait 真的阻塞——整网没跑完,核就冻结在 EX 拍(不阻塞的话第一条 npu.rd 会读到上一轮结果,SCREEN 立刻就不对了);
· 证据四:指令路径 == 寄存器路径——npu.rd 读回的整网输出与逐 lane lw NET_Y0..3 拼回的逐位相同,层号 / 每层 K 条数 / 两头落点也都与寄存器路径一致;
· 证据五:计算一个字没变——最终残差流 0xF905FD04 与第 25、26 环完全相同,SCREEN2 0x02040404 同值;新的 SCREEN3 0x04000204 = 层 0 环条数 4 | 末层两头落点 0 | 片内层号 2 | 输出 lane0 4,四项全部由 npu.rd 读回算出;
· 单元仿真新增 tb_isa_npu:六项逐条对表(层号=2 / 每层环条数 / 指令读回==逐 lane 拼回 / 指令层号==寄存器层号 / 指令落点==寄存器落点 / layers=1 时层号停在 1)。
第一课:先用后声的隐式线。新接的 32bit 操作数总线,代码里在声明之前就被用了;Verilog 见到「先用后声」会悄悄造一根 1bit 的隐式线——编译不报错、仿真不报错,只是那根 32bit 的总线上什么都没接上。表现是「指令启动了整网,但引擎收到的层数是 0」。教训:新加的总线,声明必须在任何使用之前,哪怕编译器不吭声。
第二课:紧跟在读指令后面的消费者。npu.rd 的结果要到 MEM 拍才拿得到,可它长得像一条 R 型指令,紧跟在后面的 mv 会被 EX→EX 转发塞进ALU 算出来的垃圾值(addi 的 0+0),于是残差流被静默清零、采样序列全乱。load 早就有「紧跟消费者等一拍」的保护,npu.rd 是新造的「读」,必须与 load 同类对待(消费者等一拍、分支紧跟也冻结重取)。
还有第三条小坑:引擎必须在 start 那一拍锁存操作数——指令的操作数只在总线上存在一拍,引擎却要到十几拍之后才用它(li_nxt >= layers)。不锁存的话,那一拍过后总线换回别的值,整网就按旧的层数跑完了。
本环走完一整圈:目标 → 一台只有一个对话窗口的手机,它的 OS 得像用 CPU 一样用 NPU → 硬件长出引擎之后,ISA 长出接口:核里多三条自定义指令,整网的启动 / 等待 / 读回各一条 → 数据面一个字没动,最终残差流与上一环逐位一致 → 单元仿真 + 协同仿真 + 回归全绿 → 回到目标。「用芯片」这件事从「敲寄存器」升级成「执行指令」——这才是一台手机里 OS 调 AI 该有的样子。下一环把重量级的东西交给芯片自己搬:层权重从内存流式取。
第 28 环:权重内存化 · 整网层权重从 SRAM 流式取——现在每层的 Wq/Wk/Wv/Wq2、FFN 两层、LayerNorm 的 gamma/beta 都还靠核一个一个寄存器写进去。一台真手机不可能这么喂权重(DeepSeek 级的权重根本放不进寄存器堆)。下一环把层权重搬进内存,让整网引擎在跑每层时自己从内存取——核只负责第一份、硬件负责后面的每一层。