大模型推理引擎移植到 Android 老手机
骁龙 660(ARMv8-A / 无 NEON dotprod)的交叉编译、NaN 根因定位与能力边界量化
项目仓库
Gitee 主仓:https://gitee.com/pei-xiaoguang/kestrel-llm
GitHub 镜像:https://github.com/m13253246268-ship-it/kestrel-llm
0. 一句话结论
我们把 Kestrel 交叉编译成了一台 2018 年骁龙 660 手机上的 Android aarch64 二进制。
成功了的部分:引擎能启动、能 mmap 直挂 2.33 GB 的 VQF 权重、能在设备侧起一个 OpenAI 兼容 HTTP 服务、能跑完 prefill + decode 全链路,分层驻留下进程 RSS 只有 8 MB。
没成功的部分:这颗 CPU 是 ARMv8-A,不含 NEON dotprod / fp16 —— 正好落在 Kestrel 声明的平台口径(ARMv8.2-A + dotprod/fp16)之外。在这个配置下logits 输出为 NaN,推理结果不可用,且目前尚未修复。
本文完整记录这个过程,包括一个被实验证伪的错误假设,以及把故障范围从「整个引擎」收敛到「第 0 层」所用的方法与证据。
本文所有数字都在 §2「观测口径」交代的前提下成立。不漂亮的结论照实写——这是本项目《术语与数据口径》§2.5 定下的纪律,本文照办。
1. 缘起:为什么要往一台老手机上推
Kestrel 的平台口径写得很直白:
- 本质是面向 ARM 架构 CPU 的推理引擎(ARMv8.2-A + NEON dotprod/fp16),不依赖 GPU/NPU 与特定开发板;
- RK3588 是当前开发、优化与基准测试平台,并非唯一可运行设备——同类 aarch64 Linux 设备可尝试编译运行。
那"同类"到底有多同类?如果一颗只有 ARMv8-A、没有 dotprod 的 2018 年 CPU 也能跑,Kestrel 的可用面会宽很多;如果跑不动,我们也应该知道它从哪一步开始跑不动。
所以这一步是故意去试探声明边界,不是挑一台肯定能跑的机器。设备是一台已 root 的骁龙 660 手机,adb 直连。
2. 观测口径
按项目纪律,先交代前提再上数字。
2.1 设备
| 项 | 值 |
|---|---|
| SoC | Qualcomm SDM660(骁龙 660)—— 引擎自报 model=Qualcomm Technologies, Inc SDM660 |
| CPU 微架构 | Kryo 260(Cortex-A73 + A53 派生),ARMv8-A |
| SIMD 扩展 | 无 dotprod、无 fp16(dotprod 是 ARMv8.2 随 A76/A55 引入) |
| 内核数 | 8 |
| 内存 | MemTotal ≈ 5.6 GiB |
| 架构 / NPU | arch=arm64,npu=none |
| 系统 | Android,已 root(Magisk) |
⚠ 未知项(本文未记录,引用时请勿假设):机型具体型号、Android 版本与内核版本未逐项复核;权重所在存储介质(eMMC 或 UFS)与顺序读带宽未测——在板端这是影响逐层推理速度的关键变量(见《术语与数据口径》§2.4)。
2.2 构建
| 项 | 值 |
|---|---|
| 工具链 | Android NDK r27c,--target=aarch64-linux-android29 |
| 关键编译选项 | -march=armv8-a(通用,不带 dotprod)→ 引擎的 ST_NEON_DOTPROD 判定为 0 |
| 产物 | 单文件 890 KB(strip 后),零第三方运行时依赖 |
2.3 模型
| 项 | 值 |
|---|---|
| 模型 | Qwen3-VL-2B-Instruct |
| 结构(引擎自报) | dim=2048 layers=28 heads=16 ff=6144 vocab=151936 |
| 格式 | VQF v2 单文件,mmap 直挂 |
| 量化档 | --wmode q4 |
| 体积 | 2,331,316,232 B(2.33 GB) |
2.4 其它口径声明
- 线程数:未显式指定
--threads(用引擎缺省)。按项目纪律线程数会改变结论,本文全部数字同此口径,待补。 - 页缓存:§5 的 prefill 数字分「冷(首次前向)」与「热(同进程内续跑)」两档标注,两者不可混比。
- 内存口径:§5 报的是纯权重侧的进程 RSS(引擎自报
rss=),与「serve 峰值(含 KV)」不是同一把尺子。 - x86-64 数字:按项目纪律,只作功能自检与位级一致性对照,不作性能基准。本文只把它当正确性 oracle 使用(§8),并明确标注。
3. 第一步:交叉编译 —— 先让它成为合法的 Android 程序
引擎是纯 C11、零第三方运行时依赖,交叉编译本身没什么阻力。真正需要处理的只有两处平台兼容缺口,且都是「纯新增、不影响 dotprod 构建」的改动:
缺口 1:OpenMP 原语在 bionic 下缺符号。
引擎个别文件(vllm_npu_direct.c)用了 OpenMP 原语,但 Android clang 在未启用 -fopenmp 时不提供这些符号。补一个最小 omp 桩即可。这个文件只在 RK3588 NPU 直通路径上使用,而本机 npu=none,根本不会走到。
缺口 2:一个 dotprod-only 的内核符号在 ST_NEON_DOTPROD=0 时未定义。
引擎里 Q4 的 4×4 tiled 点积内核是 dotprod 专属的,在通用 ARMv8-A 构建下被条件编译排除。补一个"永不被调用"的兜底定义,让链接通过。
这两处说明一件事:引擎对非 dotprod 目标是"能编过"的,但那条路径此前显然没有被真正验证过——这一点在 §7 变成了关键线索。
4. 第二步:模型上机与服务启动
把 VQF 权重和 config.json、vocab.bin 推到 /data/local/tmp,然后打开逐层推理启动服务:
VLLM_VQF_STREAM=1 ./vllm_mi8lite \
--model Modl/Qwen3-VL-2B-q4 \
--serve --port 8080 --auto-load --min-free-mb 96
设备侧日志:
[VQF-STREAM] enabled keep=1 nl=28 segs=11 per-layer=27.0MB
[M-A] VQF loaded: Modl/Qwen3-VL-2B-q4/model.vqf
[SERVE] model=Modl/Qwen3-VL-2B-q4 dim=2048 layers=28 heads=16 ff=6144 vocab=151936
[M-T] model load took 860 ms
[SERVE] OpenAI-compatible API listening on http://0.0.0.0:8080
三点值得注意:
- 启动不做「读权重 → 量化 → 重排」。VQF v2 是 mmap 直挂,2.33 GB 模型的装载耗时只有 860 ms。
- 逐层推理把权重驻留与会话彻底解耦。
keep=1表示只有 1 层常驻,每层算完立即MADV_DONTNEED——引擎自报per-layer=27.0MB。 - HTTP 服务直接跑在手机上。主机侧用
adb forward转发端口,就能用标准 OpenAI 客户端打过去。
5. 第三步:第一次真实冒烟 —— 全链路通了,但输出是退化的
发一个 1-token 的对话请求(prompt 14 token)。引擎返回 HTTP 200,prefill / decode 都真实执行:
[PREFILL] 14/14 tokens done
[PREFILL-TIMING] n=14 total=4560.7ms | GEMM=4362.3ms(95.7%) ATTN=36.8ms(0.8%) OTHER=161.6ms(3.5%)
[PREFILL-KERNELS] QKV=973.8ms(22.3%) O=473.0ms(10.8%) GATEUP=1886.7ms(43.3%) DOWN=1028.9ms(23.6%)
分层驻留的内存效果很好:
[M-S] pending×0 admit 638ms gen=12 keep=0 rss=8MB resident~1466.8MB
进程 RSS 8 MB(权重经 mmap 按层进出,不计入 RSS;引擎自估常驻 ~1.47 GB)。
但输出不对:
- 1 token 请求返回
"content":"!"; - 加到 12 token,返回
!!!!!!!!!!!!—— 同一个 token id 被反复生成。
这不是"生成质量差",而是贪婪解码在退化分布上恒取同一个索引的典型特征。
顺带说明:此时我们验证了
history_tokens里的 prompt 侧 14 个 token id 完全正确(含 chat template 的<|im_start|>/<|im_end|>/assistant等特殊 token)。也就是说 tokenizer 与模板是对的,问题在模型前向。
同时确认过:关掉逐层推理重跑,输出一模一样。所以不是分层驻留的锅。
6. 第四步:一个错误假设,以及它怎么被证伪
6.1 假设
VQF 权重文件头部带一组布局标志位:
flags = 0x00000063
SET Q8_8X8 ← dotprod 专属 8×8 tiled 布局
SET Q4_4X4 ← dotprod 专属 4×4 tiled 布局
也就是说,这份权重是按 dotprod 专属的 repack 布局固化的。而本机没有 dotprod → 引擎走回退内核。于是有了一个很自然的假设:
假设 A:非 dotprod 的回退路径按 legacy 逐块布局解释权重,与文件里的 4×4/8×8 布局不匹配,读出来的权重是错的。
这个假设在静态代码层面是站得住脚的:引擎加载时会严格校验文件布局与运行时配置是否一致,不一致就直接拒载——说明这个不匹配是被引擎视为致命错误的。而它恰好没触发,因为两边都认为自己是 repack 布局,只是内核走的是另一条路。
6.2 构造对照实验
我们没有停在假设上,而是做了一个单变量对照:
- 给转换器补上
VLLM_DISABLE_Q4_REPACK/VLLM_DISABLE_Q8_REPACK两个开关,转出一份 legacy 逐块布局的权重; --wmode q4与原文件对齐,其余参数完全相同;- 结果:
| 原文件 | 关 repack 重转 | |
|---|---|---|
| 字节数 | 2,331,316,232 | 2,331,316,232(完全相同) |
| 张量数 | 41 | 41 |
| 布局 flags | 0x63(Q8_8X8 + Q4_4X4) |
0x60(两者皆清) |
体积逐字节相同,张量集合逐项相同,唯一变量就是布局。
让引擎同样关掉 repack 后加载新文件(否则会被布局校验拒载),服务正常起来。
6.3 结果:假设 A 被证伪
输出仍然是 "!"。
假设 A 不成立。布局不是根因。
这条被证伪的假设值回票价:它一次性排除了「权重布局 / repack / 回退内核读错步长」这一整族可能,而且是在单一变量下排除的。
7. 第五步:改用引擎自带的探针定位 —— logits 是 NaN
Kestrel 内置了两把诊断尺子(本文只读、不触碰任何计算路径),此时派上用场:
VLLM_DUMP_LAYERS=1:逐层打印 hidden 的xmax / xrms、norm 输出、QKV 缓冲、注意力结果;VLLM_DUMP_LOGITS=1:打印本步 logits 的指纹(FNV hash + top1/top2/margin)。
打开后重跑,拿到了决定性的一行:
[LOGITS-DBG] seq=14 gen=0 fnv=04f236128a753f83 top1=0 top2=1 margin=nan
margin=nan —— logits 是 NaN。
这一下把"输出退化"翻译成了明确的数值事实:NaN 参与 > 比较恒为假,所以 argmax 永远停在索引 0,于是每步都吐 token 0,于是看到 !!!!...。不是算得不准,是数值直接崩了。
再往上看层级:
[LAYER] l=0 ... x[0:4]=nan nan nan nan xmax=0.0000 xrms=nan
[LAYER] l=1 ... xrms=nan
...
[LHID] x[0:6]=-nan nan -nan -nan -nan -nan
[LTOP] 全部 -1(哨兵值)
注:xmax=0.0000 是 NaN 的副产物——fabsf(NaN) > xm 恒假,最大值统计自然停在 0。
两个结论:
- 第 0 层输出就已经全是 NaN,污染点在输入侧(embedding)或第 0 层内部,不是深层累积出来的;
[LHID]显示 final norm 的输入输出全 NaN,[LTOP]全哨兵值(没有任何 logit 大于-1e30)——lm_head 拿到的已经是 NaN。
8. 第六步:跨平台参照 —— 先证明模型文件无罪
定位到这里,必须回答一个前置问题:会不会是这份权重文件本身有问题?
Kestrel 恰好提供了完美的对照件:同源代码的 x86-64 移植层(build_x64.ps1)。按项目纪律它不作性能基准,但作正确性 oracle 完全合格。
用 x86-64 构建加载同一个 model.vqf,发同一个 prompt:
"content": "好", "history_tokens": [..., 77091, 198, 52801]
x86 给出了完全正确的回答(52801 = "好",正是"请只回复一个字"的合理答案)。
于是三件事同时被钉死:
| 结论 | 证据 |
|---|---|
| 模型文件是好的 | 同一文件在 x86 上输出正确 token |
| 故障在设备侧 | 同一文件在手机上 logits 为 NaN |
| 故障在"非 dotprod 的 ARM 路径" | 已排除布局(§6)、逐层推理(§5)、模型文件(§8) |
9. 现状与边界(诚实声明)
这个问题目前没有解决。 准确的状态是:
| 层次 | 状态 |
|---|---|
| 二进制兼容 | ✅ 能编译、能启动、能加载、能服务、能跑完 prefill+decode |
| 数值正确性 | ❌ 非 dotprod 的 ARMv8-A 路径在前向中产生 NaN,第 0 层即已污染 |
| 定位进展 | 已知「第 0 层输出全 NaN」;尚未区分是 embedding 取数、RMSNorm、Q4 QKV GEMM、注意力还是 FFN 中的哪一段 |
这是能力边界,不是玄学问题。 判断依据是三条确定性事实:模型文件已证清白(§8)、故障是确定性的 NaN 而非随机漂移(§7)、已有同源可跑通的参照实现(§8)。这也意味着它是可定位、可验证的工程问题——下一步只需在单 token 前向里按段插桩(embedding 之后 / l0.attn_norm / l0.qkv / l0.post_attn_x / l0.ffn_norm / FFN 的 gate-up-silu 之后),一次运行即可把范围收敛到具体某一段。
10. 性能基线(骁龙 660 / Qwen3-VL-2B q4 / 逐层推理 keep=1)
| 指标 | 实测 |
|---|---|
| 模型装载(mmap 直挂) | 860 ms |
| 进程 RSS(纯权重侧,引擎自报) | 8 MB(引擎自估常驻 ~1.47 GB) |
| prefill 14 token(冷,首次前向) | 4560.7 ms |
| prefill 14 token(热,同进程续跑) | 2432 / 2360 / 2294 ms |
| prefill 构成 | GEMM 96.4%(GATEUP 39.4%、DOWN 31.1%、QKV 17.4%、O 10.9%) |
| decode 稳态 | 547.8 ms/token ≈ 1.8 tok/s |
| TTFT(14 token 短 prompt) | 2.3 – 5.6 s |
两点口径提醒:
- 这组数字是在已知 NaN 的构建上测的。但 GEMM 耗时由访存与计算量决定、与数值内容无关,所以性能数字仍具代表性。
- x86-64 的对照耗时(同 prompt、同模型)属于「非性能基准」范畴,本文按纪律不列作对比。
直白结论:即便 NaN 修好,这台设备的能力上限大致也是 1.8 token/s 解码、短 prompt TTFT 2–5 s。做离线批处理或演示可以,交互式对话体验会很难受。
11. 这次适配反过来证明了 Kestrel 的什么
一次"没跑通"的适配,实际上是比一次顺利的 demo 更硬的验证——因为它把引擎的可移植性、可观测性、可验证性分别逼到了极限。
1)可移植性不是口号。
同一份源码,在 x86-64 AVX2、RK3588(ARMv8.2 + dotprod)之外,又能编成 Android aarch64 单文件跑起来——零第三方运行时依赖这点在这时体现得很实在:推送一个 890 KB 的文件就能在一台手机上起服务。
2)权重格式自带"布局版本",且引擎拒绝静默错误。
VQF 的布局标志位(Q8_8X8 / Q4_4X4 / X8 / Q8BUF_Q4 / G256 …)在装载时被强校验,不一致直接拒载而不是"猜着读"。这次我们正是靠它构造出了字节等长的单变量对照(§6),才把一整族假设干净利落地排除。能被推翻的假设,才是节约时间的假设。
3)诊断能力是内建的,不是临时糊上去的。
VLLM_DUMP_LAYERS / VLLM_DUMP_LOGITS 这类探针本来就在引擎里。它们把"输出不对"这种模糊症状,在一次运行内翻译成「margin=nan、第 0 层即 NaN」这种可执行的事实。没有可观测性的引擎,平台上出问题只能靠玄学。
4)能力边界是显式写出来的,而且照实写。
README 把平台口径写成 ARMv8.2-A + dotprod/fp16,"同类设备可尝试编译运行"里的"同类"是有实质含义的。这次实验恰好量化了这条边界:通用 ARMv8-A 能编、能跑、但数值不正确。我们选择把它写进文档,而不是含糊过去——因为知道自己边界的引擎,才是能被依赖的引擎。
12. 结语
把 Kestrel 推到 ARMv8.2 边界之外,我们收获的不是一个"成功移植"的战报,而是三样更耐久的东西:一条被量化过的平台边界、一套可复用的定位方法(控制变量 → 单变量对照 → 内建探针 → 跨平台 oracle),以及一个待修的、位置明确的引擎缺陷。
下一台老设备再出现类似问题,我们不用从零开始。
复现条件(供参考):Android NDK r27c /
aarch64-linux-android29/-march=armv8-a;设备侧VLLM_VQF_STREAM=1,VQF 权重经vqf_convert/产出。注意 §2.4 中「线程数」与「存储介质」两项本文未记录,换机复现时请补齐后再比数字。
想自己试:仓库在 Gitee / GitHub;上手路径见 快速上手,从源码构建与自检见 构建与复现。如果你手上也有非 dotprod 的 aarch64 设备,欢迎一起来把这个边界推出去——本文的定位方法(控制变量 → 单变量对照 → 内建探针 → 跨平台 oracle)是可以直接搬的。
本文数字均来自设备端与主机端的原始日志,未经平滑处理。口径定义见项目《术语与数据口径》。
浙公网安备 33010602011771号