平时做嵌入式语音助手,常见方案是设备采集输入,再把问题发给服务器,等模型生成回答。这样做方便,但设备得联网,推理也发生在服务器上。
不过这个项目纯属整活,博主就是想试试看:把一亿多参数的模型权重放进 SD 卡,让 STM32 自己完成推理,再接上屏幕和触摸键盘,能不能做成一个离线聊天终端?
这里使用的是 STM32N6570-DK,模型选择 SmolLM2-135M-Instruct,约 1.35 亿参数。当前已经实现英文输入、多轮对话和流式显示,触屏、串口都能操作,运行时不需要电脑或网络。
1、模型选择
模型体积小,并不意味着它适合聊天。TinyStories 适合验证模型加载和文本生成,但训练目标偏向故事续写。输入一个问题,它也可能顺着问题继续编故事。想做问答终端,需要选择经过指令微调的模型,并配上对应的对话模板。
这个项目使用 SmolLM2-135M-Instruct。它有 30 层 Transformer,隐藏维度为 576,前馈层中间维度为 1536。与电脑上常见的模型相比,它的规模不大;放到单片机上,存储和计算仍然需要认真安排。
电脑端先将矩阵权重导出为逐行 INT8 格式,每行保存一个 FP32 缩放系数,再跟上整数权重。分词器也单独导出,运行时由 STM32 完成输入编码与输出解码。
| 文件 | 用途 | 大小 |
| smol135m.q8.bin | 量化模型权重 | 约 129.2 MiB |
| smol135m.bpe.bin | 分词器 | 约 1.07 MiB |
分词器会将一句话拆成模型使用的 token。token 可能是一个单词,也可能是单词片段、标点或特殊标记,因此“每秒生成几个 token”不能直接理解成“每秒几个单词”。
2、存储安排
完整权重有 129.2 MiB,而当前工程使用的板载 PSRAM 为 32 MiB,无法一次装下整个模型。因此,运行方式是把完整文件留在 SD 卡里,推理时按需读取,再用 RAM 缓存反复访问的数据。
SD 卡采用 FAT32,通过 FileX 文件系统访问。PSRAM 下半区划出 16 MiB 作为权重缓存,按需填充,缓存内容可以跨 token、跨对话保留;重置聊天历史不会清掉这块权重缓存。
另一部分 PSRAM 用于运行数据和显示。其中静态 arena 预留 12 MiB,保存 KV Cache、分词器数据等;当前常驻分配约 7.71 MiB,批量预填充需要时会临时借用剩余空间。
KV Cache 保存历史 token 在各层产生的 Key 和 Value,后续生成可以复用。它和权重缓存用途不同:前者随着对话变化,后者缓存固定的模型参数。
片内 SRAM 容量更紧张,主要放计算热点数据,包括 64 KiB 活跃权重缓冲、量化激活、DMA 缓冲和预填充工作区。这样可以减少热循环反复访问外部 PSRAM 的开销。
没有把完整权重写入外部 Flash。量化模型已超过板载 128 MiB Flash 的总容量,而 Flash 还要存放启动程序和应用。
3、推理加速
速度要从两头处理:一头是减少重复读取,另一头是让 CPU 更快地完成矩阵计算。只改其中一边,另一边仍可能占据大部分等待时间。
输入一句话后,模型先处理输入并建立 KV Cache,这个阶段叫预填充。随后才进入生成阶段,每次选出下一个 token,再送回模型继续计算。
如果逐个输入 token 遍历模型,同一批权重会被反复读取。当前实现改为逐层批量计算,读入一块权重后供多个输入 token 复用,最终词表投影只计算需要输出的位置。
例如 What is your name? 连同系统提示和对话模板共 30 个 token。主机验证中,这段预填充的逻辑权重读取量由原逐 token 完整前向方案约 3.78 GiB 降至约 129.2 MiB。
这里减少的是逻辑读取量和无用的词表投影,不能当成板上提速 30 倍。各个输入位置的 Transformer 计算仍然要执行,实际 SD 物理读取量还受权重缓存和预读影响。
矩阵权重已经是 INT8,当前进一步将输入激活动态量化为 INT8,每 32 个值使用一组缩放系数,再通过 Helium 指令执行整数点积。
下面是代码中的实际内核,保留了没有 MVE 时的标量路径,以及不足 16 个值的尾部处理:
staticfloatdot_q8(constint8_t *w,constint8_t *x,constfloat *scales,int n){float result=0.0f;for (int start=0;start<n;start+=TLLM_QGROUP) {int end=start+TLLM_QGROUP,i=start;if (end>n) end=n;int32_t sum=0;#if TLLM_MVEfor (;i+15<end;i+=16) sum=vmladavaq_s8(sum,vld1q_s8(w+i),vld1q_s8(x+i));#endiffor (;i<end;i++) sum+=(int32_t)w[i]*x[i];result+=(float)sum*scales[start/TLLM_QGROUP];}return result;}
vld1q_s8 一次加载 16 个有符号字节,vmladavaq_s8 将 16 对乘积累加到 INT32。每组结束后乘上激活缩放系数,外层矩阵计算还会乘上该行权重的缩放系数。
注意力、归一化、残差和 KV Cache 仍保留 FP32。动态量化会改变计算数值,因此回答也可能与 FP32 激活路径不同,工程保留了切换命令用于比较。
此外,RoPE 旋转参数在加载时预计算;SiLU 和 Softmax 使用快速指数近似,超出适用范围时回到 expf,没有启用全局 fast-math。
SDMMC 采用 4 线模式,模型读取设置两个 128 KiB 预读窗口。CPU 使用当前数据计算时,DMA 读取下一段权重,尽量把读卡时间与计算时间重叠。
下面是代码中发起预取的实际片段,位于当前请求数据复制完成之后,依赖所在函数中的窗口状态和文件对象:
uint64_t next=(uint64_t)window_offset+SD_WINDOW_BYTES;if (prefetch_enabled && window_file==file && !pending_file &&next+SD_WINDOW_BYTES<=file->fx_file_current_file_size) {int result=SD_File_PrefetchBegin(file,(uint32_t)next,sd_window[window_index^1U],SD_WINDOW_BYTES);if (result<0) return FX_IO_ERROR;if (result==1) { pending_file=file; pending_offset=(uint32_t)next; prefetch_starts++; }}
window_index ^ 1U 选择另一个缓冲区。只有没有在途请求、文件还剩完整窗口等条件成立时,才会尝试启动异步读取;不适合预取的请求继续使用原读取路径。
DMA 完成前,CPU 不能把目标缓冲当作有效权重使用。工程同时处理缓存维护、完成确认和异常回退,减少“传输完成了,但 CPU 读到旧数据”这类问题。
4、对话交互
界面使用 LTDC 显示,上方是聊天内容,下方是英文触屏键盘,支持大小写、数字、符号、空格和退格。摄像头功能已关闭,LCD 和触摸独立运行。
回复以 AI 标识,生成一个 token 就立即显示其可输出字节,不等整句话完成。下面是实际发送到串口和屏幕的代码片段:
constunsignedchar *piece=chat_bpe_piece(&tokenizer,next,&bytes);for (uint32_t i=0;i<bytes;i++) {unsignedchar c=piece[i];if (c>=32U || c=='n' || c=='t') {if (!first_output) {last_first=HAL_GetTick()-turn_start;first_output=1;}char out[2]={(char)c,0}; serial(out); ChatUI_ReplyByte(c);}}
chat_bpe_piece 取得当前 token 对应的文本,随后逐字节调用串口输出和 ChatUI_ReplyByte。显示时可能一次出现几个字母,也可能出现一个完整单词,取决于当前 token 的内容。
上下文共 128 tokens,系统提示、历史和当前问答共同占用;每轮最多生成 32 tokens。因此,问题尽量简短,较长回答可能被截断。上下文不足时,程序会提示并清空旧对话。
实际效果
目前已经跑通英文输入、离线推理和流式回复。前面的 INT8 与读卡优化后,实机使用时能感觉到速度明显改善,大概速度是10 s/tokens,速率在优化前提高大概10倍左右。过程中也尝试过四行并行矩阵内核,希望复用输入、减少重复加载。数值验证通过了,板上却比之前慢,这个原因还要探究一下。
做到这里,STM32 已经可以作为一个本地离线英文聊天终端使用。它的回答能力仍然有限,但从 SD 卡读取一亿多参数的模型,再由单片机自己算出回复、显示到屏幕上,这条完整链路已经跑通了。也算是小小的完成一次尝试。
15