34 · 两行 GEMV 共享激活,只换来 0.6%:一个反向证明
本篇对应源码:
main/pie_dotprod.S·main/kmcu.c
目标:单核 GEMV 只跑到 PSRAM 上限的 66–78%,怀疑是「每行重装激活」的开销。
结论:省掉 12.5% 的指令,端到端只快 0.6%——这不是指令受限,是字节带宽受限。
关键词:PIE 汇编 · 两行 GEMV · 带宽受限
封面:省 12.5% 指令,只快 0.6%
一、结论先行
| 阶段 | 单行内核 | 两行内核 |
|---|---|---|
| ffn | 25.5 ms | 25.1 ms |
| qkv | 18.6 ms | 18.4 ms |
| o | 10.0 ms | 9.8 ms |
| ple | 9.6 ms | 9.6 ms |
| total(19 token 均值) | 159.9 / 160.0 ms | 159.0 / 159.0 ms |
位级:20 token 序列完全一致,sd fnv = 817147c7、kv fnv = 739dd3ef两侧相同。
二、动机:每块 64 B 激活 vs 32 B 权重
单行内核处理一个 32 元素块时要做的事:
- 装 4 个 128-bit 的
xq(32 个 int16 = 64 B) - 装 2 个
vldext的权重(32 个 int8 = 32 B) - 4 条
vmulas+ 1 条srs+ 约 5 条标量
激活的字节数是权重的两倍。对 gate/up 这种「同一份激活、几十行权重」的
形状,看起来明显浪费。
三、内核:一次装载,两条点积链
pie_gemv_row2: /* a0=qa a1=qb a2=x a3=da a4=db a5=nblk a6=ya a7=yb */ esp.vld.128.ip q2, a2, 16 /* 激活块 32 个 int16,两行共用 */ esp.vld.128.ip q3, a2, 16 esp.vld.128.ip q4, a2, 16 esp.vld.128.ip q5, a2, 16 /* 行 A */ esp.zero.xacc esp.vldext.s8.ip q0, q1, a0, 16 esp.vmulas.s16.xacc q0, q2 esp.vmulas.s16.xacc q1, q3 esp.vldext.s8.ip q0, q1, a0, 16 esp.vmulas.s16.xacc q0, q4 esp.vmulas.s16.xacc q1, q5 esp.srs.s.xacc t3, t4 /* 行 B 同样一遍,只是权重指针换成 a1 */PIE 只有一个xacc累加器,所以「共享激活」只能在寄存器层做:先把 32 个
激活装进 q2–q5,然后两行依次把权重流过去。每块从 32 条指令降到 28 条。
两个硬件约束值得记下来(踩过一次):
srs/vldext的 GPR 只能编码 x8–x15 / x24–x31,所以右移量放t4(x25)、
结果暂存用t3(x24)/t5(x26),指针留在 a0–a5;esp.vldext.s8.ip一次只能出 2 个寄存器(16 个 int8 符号扩展成两个
8×int16),所以 32 个 int8 要两条。
objdump复核:函数体 0x1a0–0x22d,没有栈帧、没有寄存器溢出。
四、C 侧:成对调用,奇数回退
intr=0;for(;r+1<n_rows;r+=2){pie_gemv_row2(q+r*align,q+(r+1)*align,xq,d+r*nblk,d+(r+1)*nblk,nblk,&y[r],&y[r+1]);y[r]*=coeff;y[r+1]*=coeff;}for(;r<n_rows;r++)y[r]=pie_gemv_row(q+r*align,xq,d+r*nblk,nblk,0)*coeff;行数为奇数时最后一行回退单行内核,所以任何形状都能用。
五、为什么只快 0.6%(这篇的真正价值)
- 每块指令数:32 → 28(−12.5%)
- 端到端:159.9 → 159.0 ms(−0.6%)
两者差了一个数量级,只能说明一件事:这些阶段的时间不是被指令吞吐决定的,
而是被从 PSRAM 搬权重决定的。逐阶段算有效带宽(单核上限 121 MB/s):
| 阶段 | 字节量 | 耗时 | 有效带宽 | 占上限 |
|---|---|---|---|---|
| ffn | 2.4 MB | 25.5 ms | 94 MB/s | 78% |
| qkv | 1.62 MB | 18.6 ms | 87 MB/s | 72% |
| o | 0.8 MB | 10.0 ms | 80 MB/s | 66% |
省指令不会让 PSRAM 变快,所以两行内核只能把「剩下的那点非带宽开销」挤掉一点。
顺带得到一个可用的预算式:每 token 必须过 15.4 MB(head 10.24 + ffn 2.4 +
qkv 1.6 + o 0.8 + 其余),按双核 157 MB/s 是 98 ms,加 PLE 行读 14.4 ms,
理论上限约 113–117 ms。当前 159–170 ms,剩余空间全在「让这几段也吃满双核」。
六、诚实边界
- 0.9 ms 的收益在任何单次测量里都不显眼,它是两次开、两次关交替得到的
稳定差值(159.0/159.0 vs 159.9/160.0),不是一次跑出来的。 - 收益虽小但位级一致,所以保留默认开启(
KMCU_GEMV2=1),并留了运行期g键与构建期-DKMCU_GEMV2=0两条退路。 - 这个内核只对「同一 tensor 的相邻两行」有效;输出头本身是带宽打满的,
用不上它(也没有收益)。
七、一句话
这是一次「反向证明」:花力气省掉 12.5% 的指令只换来 0.6%,反而确认了
瓶颈是 PSRAM 字节带宽——从此不必再在 GEMV 指令级上找空间。
对应源码
| 文件 | 关键符号 / 位置 | 支撑本文哪部分 |
|---|---|---|
main/pie_dotprod.S | pie_gemv_row2 | 两行共享激活的内核 |
main/kmcu.c | gemv_q8_pre()的成对循环 | C 侧接入与奇数回退 |
main/kmcu.c | km_gemv2_selftest() | 与单行内核的逐位比较 |
main/main.c | case 'g' | 运行期逃生舱 |
《Kestrel-MCU 手记》· 全系列 37 篇:在 ESP32-P4 上从零训练并部署 64M 参数 MoE 大模型(5.8 tok/s),源码、权重、训练脚本与全部文章开源可复现。
仓库:https://gitee.com/pei-xiaoguang/kestrel-llm-mcu · 觉得有用欢迎 Star