03 · 纯 C11 在 MCU 上写 Transformer 推理:无 SIMD 的标量内核全解析
English version:en/03-scalar-inference-kernel.md
本篇对应源码:
main/kmcu.c·main/kmcu.h·main/main.c
目标:理解
kmcu.c/h如何在一个 32 位 RISC-V MCU 上、用纯标量 C跑通
Mistral 家族的 Transformer decode,以及为什么 32 MB PSRAM 能装下 12 MB 权重。
1. 整体架构
main.c(入口) ├─ shs_selftest() SHS 公理算子自检(S_0/UPA) ├─ probe_psram() PSRAM 容量/带宽探针 ├─ probe_sdcard() TF 卡探针(默认关) ├─ pie_bench() PIE 单算子对拍 + 测速 └─ model_test() 模型加载 + decode kmcu.c/h(核心推理) ├─ km_open() 解析 KMCU header + 目录 ├─ km_fast_build_ex() 把 q4 从 Flash 分块展开为 int8 + fp32 scale(PSRAM) ├─ km_decode_step() 单步 decode:输入 token → 输出下一个 token ├─ gemv_q8() GEMV(int8 权重 × 激活) ├─ rms_norm()/rope_apply() 归一化 / 位置编码 └─ q8_row_get() 读嵌入行(fp32 反量化)2. 两条权重路径
| 路径 | 说明 |
|---|---|
| 慢路径 | 直接从 q4 原图逐元素反量化(正确性基准,已不用于 decode) |
| 快路径 | 装载时把 q4展开为int8 q+fp32 scale d(默认) |
快路径的核心结构:
typedefstruct{constint8_t*q;/* 展开后的 int8(行对齐,行尾含零填充) */constfloat*d;/* 每 32 元素一块的 fp32 scale(块号 = r*nblk + b) */constfloat*f32;/* 非 q4 张量(norm)的 fp32 副本 */uint32_tn;uint8_tis_q4;}km_ft_t;为什么「分块展开」而不是整份载入?
12.26 MB 镜像 + 23 MB 展开后的 arena,会超过 32 MB PSRAM 预算。
所以用km_read_fn回调从 Flash 分块读,逐块展开,展开完就释放 Flash 原图:
typedefint(*km_read_fn)(void*ctx,uint32_toff,void*dst,uint32_tlen);快路径不引入新的量化近似:
q和d完全来自原 q4 数据,只把半字节提取从
「热循环」移到「装载期」。
3. decode 单步流程
km_decode_step()输入一个 token id,输出下一个 token(贪心 argmax):
输入嵌入: x = tok_embed[token] (q8_row_get, fp32) for L in 0..n_layers: ├─ h = RMSNorm(x, in_ln) ├─ q = GEMV(q_proj, h); k = GEMV(k_proj, h); v = GEMV(v_proj, h) ├─ RoPE(q); RoPE(k) ├─ 写 KV cache ├─ attention(GQA + causal + softmax)→ ao ├─ x += GEMV(o_proj, ao) ├─ h = RMSNorm(x, post_ln) ├─ g = GEMV(gate, h); u = GEMV(up, h) ├─ act = silu(g) * u └─ x += GEMV(down, act) h = RMSNorm(x, final_norm) logits = h @ tok_embed^T(tied lm_head) return argmax(logits)各算子的标量实现要点
RMSNorm(Mistral 用 RMSNorm,非 LayerNorm):
ss=Σ x[i]^2/n out[i]=x[i]/sqrt(ss+eps)*w[i]RoPE(与 HF 一致,out1 = x1·cos − x2·sin; out2 = x2·cos + x1·sin):
forh in heads:fori in half:inv=1/theta^(2i/hd)ang=pos*inv v[i]=a·cos(ang)− b·sin(ang)v[i+half]=b·cos(ang)+a·sin(ang)GQA attention:12 个 q head 共享 4 个 kv head(kv_rep = 3),
每 kv head 服务 3 个 q head。softmax 前先减去 max(数值稳定)。
SwiGLU:act = silu(gate) * up,silu(x) = x / (1 + exp(-x))。
4. scratch 布局(内存预算的关键)
decode 的工作区(不含权重)是一个scratch数组,布局严格对齐:
[float 区] x[dim_p] h[dim_p] q[dim_p] kk[kvw] vv[kvw] ao[dim_p] g[ffn] u[ffn] act[ffn_p] [int16 区] xq[align32(max(dim,ffn))] ← PIE GEMV 的激活量化缓冲dim_p = align32(dim),ffn_p = align32(ffn)。对齐 padding 区在km_state_init里
清零后不再被写,保证 GEMV 尾部整块读取数值正确。
KV cache 另算:
2 × n_layers × n_ctx × n_kv_heads × head_dim个 float。
本例KV_CTX=128时 KV cache = 2×520 KB。
5. M1 能力探针(硬件能力基准)
接入模型前先摸清硬件能力(这些数据不随量化对齐改变,是稳定的硬件边界):
| 项 | 实测值 |
|---|---|
| 芯片 | ESP32-P4 rev v3.1,双核 + LP 核,400 MHz |
| PSRAM 容量 | 32768 KB(32 MB,hex 模式 @200 MHz) |
| PSRAM 写带宽 | 83–84 MB/s(16 MB,u32 顺序写) |
| PSRAM 读带宽 | 84 MB/s(u32 顺序)/90 MB/s(×4 展开)/106–107 MB/s(-O2下) |
| Flash | 16 MB NOR,DIO 80 MHz |
关键判读:4 路展开只让读带宽 +7%(84→90),说明 PSRAM带宽受限而非延迟受限。
这直接决定了后面「纯计算 21× 的 PIE 被带宽墙压到 2.11×」。
6. M2 实测结果(未行对齐时点,序列已作废)
| 项 | 实测 |
|---|---|
| decode | 1.63 s/token(0.61 tok/s,19 步 31.0 s) |
| Flash→PSRAM 装载 | 12.26 MB / 1.61 s = 7.8 MB/s |
| PSRAM 占用 | 权重 12.26 MB + KV 2×520 KB + 目录 |
| 算力利用率 | 22.8M MAC / 1.63 s ≈ 14 MMAC/s(相对 PIE 潜力 ~100× 余量) |
M2 时点(未行对齐)的对拍数据:
single-token[1] top1: id=684 logit=5.229595 (PC: 5.229599, 差 4e-6) 4-token prompt top1: id=23624 logit=5.184734 (PC: 5.184731, 差 3e-6) 20-token 序列: 1,450,2217,4996,23624,1199,8752,23624,1199,20925,3161,4865,442,5102,2672,3161,13040,17574,28394,1628⚠️ 这条 20-token 序列是未行对齐的 q4 布局下的结果;M3-2 引入行对齐后量化块边界改变,
序列已变。当前基准序列见 02 篇 的完整输出。
瓶颈:标量逐元素 q4 反量化(每权重一次半字节提取 + fp16 转换)+ 输出头32002×312 = 10M元素的流式 argmax(占 44%)。
7. TF 卡诊断(结论被推翻的一次)
本板 TF 卡(SDIO)引脚(main.c实测定义):
SD_PWR_GPIO = 45 ← 负载开关(低有效) SD_PIN_CLK = 43 SD_PIN_CMD = 44 SD_PIN_D0 = 39 D1 = 40 D2 = 41 D3 = 42第一次:6 种组合(供电极性 × 总线宽度 × 时钟)全报ESP_ERR_TIMEOUT @ send_op_cond,
怀疑卡坏或电气层问题。
第二次复测:错误变为ESP_FAIL / failed to mount card (13)= FatFSFR_NO_FILESYSTEM。
卡电气链路其实正常(有效组合GPIO45=0,负载开关低有效),只差 FAT 分区表。
结论:0.032B 常驻不依赖 TF 卡(权重直接放 PSRAM/Flash);TF 卡只在后续 0.1B 分层驻留才需要。
TF 卡两个坑:
esp_vfs_fat_sdmmc_mount失败时内部已自行 deinit,调用方再sdmmc_host_deinit()
会双重 deinit → Instruction access fault 崩溃。- 默认
SDMMC_SLOT_CONFIG_DEFAULT()的 width 允许 8-bit,会把GPIO45 当 D4 抢走
(它正是 TF 卡电源开关),必须显式slot.width = 4。
8. M2 阶段的踩坑
- 测试脚手架 bug(非模型 bug):贪心循环曾把生成结果写回
seq[0..PROMPT_LEN-1],
覆盖 prompt,导致 MCU 从未处理完整 prompt。修正为「先预处理整条 prompt 再自回归」后 20/20 一致。 - 计算型任务(单步 1.6 s 不喂狗)需关任务看门狗
CONFIG_ESP_TASK_WDT_EN=n,否则每步打寄存器转储。 - riscv32 上
uint32_t是unsigned long,printf 必须用PRIu32或显式转unsigned。
对应源码
| 文件 | 关键符号 / 位置 | 支撑本文哪部分 |
|---|---|---|
main/kmcu.c | km_open、km_fast_build_ex、km_decode_step、gemv_q8、rms_norm、rope_apply、q8_row_get | 第 1–4 节内核架构、两条权重路径与 decode 单步流程 |
main/kmcu.h | km_ft_t、km_read_fn、km_state_init | 第 2–4 节快路径结构、分块读回调与 scratch 对齐 |
main/main.c | shs_selftest、probe_psram、probe_sdcard、model_test | 第 5、7 节 M1 能力探针与 TF 卡诊断 |
host_verify.c | km_open、km_decode_step、pie_gemv_row(标量 stub) | 第 1 节 PC 端对拍框架 |
仓库:https://gitee.com/pei-xiaoguang/kestrel-llm-mcu