大模型推理引擎移植到 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=arm64npu=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.jsonvocab.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

三点值得注意:

  1. 启动不做「读权重 → 量化 → 重排」。VQF v2 是 mmap 直挂,2.33 GB 模型的装载耗时只有 860 ms
  2. 逐层推理把权重驻留与会话彻底解耦keep=1 表示只有 1 层常驻,每层算完立即 MADV_DONTNEED——引擎自报 per-layer=27.0MB
  3. 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 构造对照实验

我们没有停在假设上,而是做了一个单变量对照

  1. 给转换器补上 VLLM_DISABLE_Q4_REPACK / VLLM_DISABLE_Q8_REPACK 两个开关,转出一份 legacy 逐块布局的权重;
  2. --wmode q4 与原文件对齐,其余参数完全相同
  3. 结果:
原文件 关 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。

两个结论:

  1. 第 0 层输出就已经全是 NaN,污染点在输入侧(embedding)或第 0 层内部,不是深层累积出来的;
  2. [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)是可以直接搬的。


本文数字均来自设备端与主机端的原始日志,未经平滑处理。口径定义见项目《术语与数据口径》

posted @ 2026-09-17 10:46  haliu  阅读(4)  评论(0)    收藏  举报