19 · PLE 上板实战:三值查表格式、内核实现与堆溢出 Bug 修复
English version:en/19-ple-deployment.md
本篇对应源码:
tools/convert_minueza.py·main/kmcu.c·main/main.c·host_verify.c
目标:把训练侧已验证的完整配方(MoE core + PLE 三值查表 + tied head)真正送进 kmcu.c。
这是 13 篇(MoE 部署衔接)的姊妹篇——13 篇打通了 MoE 上板,
本篇打通 PLE 三值查表 + tied head 上板。训练侧验证见 14/16/17。
一、部署侧要补什么
训练侧产出的模型有四个东西是旧部署链路(MoE)没有的:
| 新增 | 旧链路状态 | 要补 |
|---|---|---|
PLE 查表(ple_table,三值 1.6bit) | 无此张量 | KMCU 新 dtype + base-3 解码 |
PLE 分支(ple_model_proj/ple_gate/ple_proj/norm) | 无 | convert 导出 + 内核前向 |
tied head(tie_word_embeddings=true) | MoE 是独立 lm_head | 展开策略修正 |
三值 scale(ple_gamma) | 无 | header 存一个 f32 |
二、KMCU 格式扩展
在 KMCU v1 上做了最小扩展,不破坏旧格式(旧稠密/MoE 模型ple_dim=0走原路径):
- 新增 dtype
DT_TERNARY=3:目录项n_rows=vocab、n_cols=table_width,nbytes = vocab × ceil(table_width/5),行对齐(每行独立打包,flash 逐行随机读)。 - header 追加两个字段(offset 84/88):
ple_dim(0=无 PLE)、ple_gamma(三值 scale,f32)。
三值编码(base-3):code = clip(round(w/gamma), -1, 1),trit = code+1 ∈ {0,1,2},
每字节装 5 个 trit:byte = t0 + t1·3 + t2·9 + t3·27 + t4·81。
三、convert 的三值打包
convert_minueza.py 新增ternary_pack:
gamma=float(np.abs(w).mean())# 与训练 STE 口径一致codes=np.clip(np.rint(w/gamma),-1,1)# {-1,0,1}trit=(codes+1).astype(np.uint8)# {0,1,2}# 每行补齐到 ceil(width/5)*5,5 trit 打包 1 字节ple_table尺寸:32002 × 1280 = 41M 参数,fp32 163.8MB → 三值 base-38.2MB(20×)。
四、kmcu.c 的 PLE 内核
两条 decode 路径(常驻km_decode_step/ 流式km_decode_step_stream)都加了 PLE。
1. PLE 底部(每 token 一次,在 embed 之后)
ple = ple_model_proj(x) · dim^-0.5 (GEMV,w8a16) ple = RMSNorm(ple 按 ple_dim 切片) (ple_proj_norm) 读 ple_table 第 token 行(flash,base-3 逐 trit 解码) ple = (ple + table · pd^0.5) · 2^-0.52. 每层 PLE 分支(attention + ffn 之后)
g = gelu(ple_gate(x)) (GEMV + 精确 erf GELU) x = x + RMSNorm(ple_proj(g · ple[:, L, :]))3. tied head 的展开策略(关键修正)
MoE 模型原本「tok_embed 流式跳过」(每 token 只读一行,省 PSRAM)。但 tied=1 时
tok_embed 同时是输出头(全 vocab 扫描),不能再跳过——否则输出头oh->q == NULL崩。
修正:skip 条件 = n_expert>0 && !tied_embed。tied=1 时 embed 照常展开进 PSRAM,
输入用q8_row_get(resident)、输出头用展开的oh->q。
五、PC 对拍:host_verify
板端pie_gemv_row是 ESP32-P4 PIE 汇编,PC 无法直接跑。新增
host_verify.c:用标量版pie_gemv_rowstub
编译 kmcu.c,加载 KMCU 镜像,跑与 ref_forward.py 完全相同的贪心 decode。
gcc -O2 -std=c11 -I main -o host_verify host_verify.c main/kmcu.c -lm ./host_verify model.kmcu真实尺寸(dim312/vocab32002/64M)对拍结果:
ref : 1,450,2217,4996,3156,28723,415,28705,28740,28774,28774,28734,28713,28725,272,28705,28740,28774,28774,28734 host : 1,450,2217,4996,3156,28723,415,28705,28740,28774,28774,28734,28713,28725,272,28705,28740,28774,28774,2873420/20 token 一致;top8@first-gen 一致(3156,522,323,28723,282,28725,753,1907),
logit 差 ~9e-4(w8a16 vs fp32,符合既有口径)。
六、过程中的 bug:PLE 三值行解码堆溢出
症状:host_verify 退出码0xC0000374(堆损坏),但输出 seq 仍正确。
根因:PLE 底部三值表解码时,用pleo(scratch 里dim_p=320个 float)暂存整行nl×pd=1280个 trit。真实尺寸下越界写堆。
为什么微型模型没暴露:微型冒烟模型table_width=32 < dim_p=64,暂存不越界。
而 PLE 的table_width = nl·pd与dim_p无任何关联——真实配置(10层×128)下 1280 ≫ 320。
修复:改成逐 trit 解码就地合并,不暂存整行:
for(intb=0,oi=0;b<row_bytes&&oi<ple_rows;b++){uint32_tv=stage[b];for(intk=0;k<5&&oi<ple_rows;k++,v/=3u){floattv=(float)((int)(v%3u)-1)*a->ple_gamma;ple[oi]=(ple[oi]+tv*spd)*inv2;oi++;}}教训:微型冒烟模型会掩盖缓冲区尺寸类 bug。PLE 的 table_width 与 dim 解耦,
必须用真实尺寸对拍才能暴露。这与 13 篇 的「16B gap 未暴露」
是同一类教训——缩小的测试件测不出「尺寸相关」的越界。
七、结论
- PLE 三值查表 + tied head 的部署链路(convert → KMCU → kmcu.c → host_verify)已打通;
- 真实尺寸 20/20 对拍通过,三值表 20× 压缩(163.8MB→8.2MB)不损质量;
- 完整配方「MoE core + PLE 三值 table + head tied」至此训练 + 部署 + 对拍全链走通。
对应源码
| 文件 | 关键符号 / 位置 | 支撑本文哪部分 |
|---|---|---|
tools/convert_minueza.py | DT_TERNARY | 二、KMCU 格式扩展(新增三值 dtype) |
tools/convert_minueza.py | ternary_pack() | 三、convert 的三值 PLE 表打包 |
main/kmcu.c | km_decode_step_stream() | 四、kmcu.c 的 PLE 内核(流式路径) |
main/kmcu.c | km_fast_build_ex() | 四.3、tied head 展开策略修正 |
main/main.c | SD_RESIDENT | 部署落地:大 PLE 模型走 SD 卡常驻 |
host_verify.c | main() | 五、PC 对拍 host_verify |
《Kestrel-MCU 手记》· 全系列 28 篇:在 ESP32-P4 上从零训练并部署 32M~64M 参数 MoE 大模型(5.7~6.4 tok/s),源码、权重、训练脚本与全部文章开源可复现。
仓库:https://gitee.com/pei-xiaoguang/kestrel-llm-mcu · 觉得有用欢迎 Star