36 · FASTSEEK 快 10.6 ms,但同一 token 每次开机读到不同的字节
本篇对应源码:
main/main.c·sdkconfig.defaults·docs/optimization_plan.zh-CN.md
目标:PLE 行读每 token 花 14.35 ms(其中 13.47 ms 是
fseek),用 CLMT 干掉它。
结论:性能拿到了(−6%),但数据错了——同一 token 的行跨冷启动读出不同字节。已回退。
关键词:FATFS · FASTSEEK · 冷启动一致性 · 指纹
封面:seek 快 10.6 ms,但同一行每次读到不同字节
一、结论先行
| 项 | 关闭 FASTSEEK | 打开 FASTSEEK |
|---|---|---|
PLE 行读fseek | 13.47 ms/token | 2.90 ms |
| PLE 行读合计 | 14.35 ms | 3.70 ms |
total(19 token 均值) | 160.3 ms | 149.2 ms |
| TTFT | 445 ms | 392 ms |
| 跨冷启动可复现 | ✅ 三次一致 | ❌ 每次不同 |
收益是真的,代价是「同一个输入不再给同一个输出」。按项目规则:判否。
二、为什么 243 B 要 14 ms
pleb阶段里有一次「按 token id 取 PLE table 的一行」:256 字节。
它占 14.35 ms,其中fread只占 0.88 ms,fseek占13.47 ms。
原因不是数据量,是位置:
- PLE table 在文件靠前的位置;
- 每 token 的权重读取已经把文件指针推到 20 MB 之后;
- 所以这是一次跨 cluster 的反向 seek。
FatFs 在没有 CLMT(cluster link map table)时,反向 seek 必须从文件首 cluster
沿 FAT 链逐跳走到目标,每一跳都要访问 FAT。加上这张卡只能以4 MHz / 1-bit
挂载(20 MHz 4-bit 初始化直接 0x107),单扇区读的成本被放大,链式遍历就成了毫秒级。
三、打开 CLMT 之后
CONFIG_FATFS_USE_FASTSEEK=y CONFIG_FATFS_FAST_SEEK_BUFFER_SIZE=1024ESP-IDF 的vfs_fat_open对只读文件会自动建表(f_lseek(file, CREATE_LINKMAP)),
之后fseek变成 O(1) 查表。模型 20.77 MB / 64 KB cluster ≈ 325 个 cluster,
1024 个 DWORD 的缓冲区能覆盖 511 个碎片段;建表失败会释放并回退原路径。
实测就是第一张表的右列:−10.6 ms/token,decode 从 170–173 掉到 160–165 ms。
四、异常:同一条固件,两次开机两个答案
第一次发现是在对比生成序列时:同一条固件(ELF SHA25669cfdc129…)、
同一个 prompt{415,1628,5255,2495},续写的第一个 token 分别是1190、24368、356。
同一次开机内重复跑模式 0,序列完全一致;换一次冷启动,序列就变。
这不是随机噪声,是「整个开机周期内固定、跨开机变化」的初始状态差异。
五、定位:两个指纹
在main.c里加了两个 FNV-1a:
/* 1) 展开后的权重:只覆盖真正写入的区域,跳过对齐空洞 */printf("[SD-RESIDENT] weights fnv1a = %08x\n",fh);/* 2) 模式 0 期间 SD 读到的全部字节(常驻 decode 下就是 PLE 行)+ 最终 KV */printf("[GEN] in fnv: sd=%08x kv=%08x\n",g_rd_fnv,kh);七次冷启动的结果:
| 配置 | 启动 | weights fnv | sd fnv | 续写第 1 token |
|---|---|---|---|---|
| FASTSEEK 开 | A | 4d0adf33 | e981dfaf | 24370 |
| FASTSEEK 开 | B | 4d0adf33 | 2b4c3620 | 27777 |
| FASTSEEK 开 + 512B 对齐读 | C | — | 817575ea | 28713 |
| FASTSEEK 开 + 512B 对齐读 | D | — | 2d70b011 | 28713 |
| FASTSEEK 关 | E/F/G | 4d0adf33 | 817147c7 | 356 |
三行结论:
weights fnv恒定 → 20 MB 的权重展开逐字节一致,不是 SD 把权重读坏了,
也不是展开流程读了非确定内容;- 唯一跨启动变化的是PLE 行读的内容(
sd fnv每次都不同),KV 与 token 跟着变; - 关掉 FASTSEEK 后三次冷启动的
sd fnv与序列完全一致,而且356,272,3500,…与更早(还没引入 FASTSEEK 时)的记录一致。
六、做过但无效的补救
第一反应是 FatFs 的FIL偏扇区缓存:fread在非扇区对齐时会从fp->buf
取数据,而fseek的 fast path 只在dsc != fp->sect时才重填。于是把所有
小读都扩成512 B 扇区对齐读,强制走f_read的直接disk_read分支。
结果(C/D 两行):sd fnv依然每次都不同。
这说明缺陷不在扇区缓存,而在fast-seek 的 CLMT 定位路径。这一层在
ESP-IDF 内部,继续深挖的性价比不高,且属于「改了别人的文件系统」。
七、处置与一条新规则
处置:
CONFIG_FATFS_USE_FASTSEEK恢复默认关闭,sdkconfig.defaults里保留
「为什么不能用」的说明;- 代价是每 token 多约 13.5 ms(8%),换取确定性输出;
- 如果将来一定要拿回这 13.5 ms,只能绕开 FatFs:自己建 offset→物理扇区映射
并先证明映射正确,再用sdmmc_read_sectors直读。
新规则(写进方案书):
跨冷启动比较任何性能或质量结论之前,必须先把
weights fnv与sd fnv对齐。
否则「A 比 B 快/好」里可能混着「A 这次读到的字不一样」。
E-6 的单核/双核对拍是在同一次开机内完成的,不受这条影响——这也是为什么
当时的结论仍然成立。
八、一句话
13.47 ms 的 seek 值得修,但如果修完读数变了,那修的不是性能而是正确性;weights fnv/sd fnv两个指纹把「快了多少」和「读到了什么」彻底分开了。
对应源码
| 文件 | 关键符号 / 位置 | 支撑本文哪部分 |
|---|---|---|
main/main.c | sdcard_read_cb()的 seek/read 计时 | 13.47 ms 归因 |
main/main.c | [SD-RESIDENT] weights fnv1a | 展开权重指纹 |
main/main.c | [GEN] in fnv: sd/kv | PLE 行与 KV 指纹 |
sdkconfig.defaults | CONFIG_FATFS_USE_FASTSEEK注释 | 为什么保持关闭 |
《Kestrel-MCU 手记》· 全系列 37 篇:在 ESP32-P4 上从零训练并部署 64M 参数 MoE 大模型(5.8 tok/s),源码、权重、训练脚本与全部文章开源可复现。
仓库:https://gitee.com/pei-xiaoguang/kestrel-llm-mcu · 觉得有用欢迎 Star