前一阵我在调试一个端侧推理的 Demo 时,被一个问题盯得很难受:同一个 7B 级别的开源模型、同一串提示词,在别人手机上跑得飞快,在我手里这台手机却像在放慢动作。最开始我以为是芯片太拉,结果把日志翻出来一比,差得离谱——不是一两倍,是接近 80 倍。
这个数字一开始我是不信的,于是又反复测了三轮,每次都把模型文件、提示词、输出长度尽量对齐,结果差距还是那么大。后来我把排查过程完整走了一遍,才发现真正的问题根本不在“哪台手机更强”,而在测量口径、推理后端、量化方式、线程调度和散热策略。这篇文章就把这次的实录整理出来,包括每一步用到的命令、我怎么定位问题、最后又改了什么,给正在折腾端侧 LLM 的同行留一份可复用的参考。
先说一个我后来才想明白的结论:端侧推理的“慢”,十有八九不是玄学,而是某个链路节点没有对齐。排查顺序应该是先看测量口径,再看推理后端,然后看模型量化,最后才轮得到讨论硬件算力。如果你上来就比芯片,或者只盯着一个数字看,很容易被表面差距带偏。
1. 先把“慢 80 倍”拆开:测量口径决定一切
1.1 预填充和解码是两个完全不同的速度
很多人看到“慢了 80 倍”,第一反应是跑分工具出了问题。但我在实际排查时发现,比 LLM 推理性能最先要区分的不是跑分,而是两个阶段:预填充和解码。
预填充阶段是把整段提示词一次性扔给模型,让模型计算上下文里的注意力关系。这个阶段是高度并行的,算得快,通常用“每秒处理多少个 token”衡量,手机端跑到每秒一两百个 token 是很正常的。
解码阶段则是模型一个词一个词往外蹦。每生成一个词,都要把已经算好的上下文重新走一遍注意力,这里每个步骤强依赖上一轮的输出,并行度很低,速度通常只有预填充的十分之一都不到。
我把这两个阶段分开打点之后,发现一个很典型的现象:预填充速度只差两三倍,但解码速度能差出二三十倍。如果评测脚本又恰好把“从输入到吐出第一个字符”的时间当成生成速度,那第一个字符本来就需要一次完整的前向计算,这个数字天然就比后续解码慢得多。再叠加其他因素,差出 80 倍完全可能。
1.2 达不到有效对照的实验,测出来全是噪音
我的第二轮排查,是把两台手机放到相同条件重新测。这里最容易埋雷的就是“看似相同,实则不同”。
我犯过的错误包括:两台手机用了不同的量化文件、我的设备开了省电模式、对方的推理工具自动加载了部分算子到加速单元而我这边全程走 CPU。这种对比测出来的数字,与其说是设备算力差距,不如说是配置差距。
规范做法呼之欲出:量化文件必须完全一致,建议连哈希值都对一遍;提示词长度要一致,解码长度固定;电量策略和温度状态要靠近;线程数和后端选择要明确记录。只有在这些变量全部锁定的情况下,两个数字才有可比性。
我当时做了一个简单的对照表,把每个可能影响结果的变量都单独列了一行,测完发现真正能让双方拉开差距的因素,其实集中在后端选择和量化体积上,而不是手机 CPU 主频。
2. 排查过程中最常被忽视的四类瓶颈
2.1 后端和加载方式不对,性能直接打折
端侧推理的第一步是决定把运算交给谁。同一个模型,可以选择 CPU 跑,也可以选择 GPU 等专用计算单元跑,还能通过系统抽象层去调用不同加速硬件。
我在手机上看日志时,发现推理运行时打印出的算子统计里,有些模型默认把大量算子都放到了 CPU 上执行。我的手机 CPU 性能不算差,但 CPU 要做的事情不止推理,还有前后台调度、内存复制、各种系统服务,算力资源非常紧张。而对方的推理运行时走了专用加速路径,同样的算子只需要几分之一的时间。
后来我随手做了一次对比:同样是 7B 模型,强制 CPU 后端和解码全部跑在一个后端时,差距是肉眼可见的。这不是模型本身慢,而是你根本没让硬件干它最擅长的事。
2.2 量化格式不同,内存搬运量差好几倍
这是一个特别容易被低估的点。LLM 在解码阶段每一次生成新词,理论上都需要把所有模型权重重新读一遍。权重文件越小,内存搬运越少,每秒钟能生成的 token 数越高。
我用一个简单公式估算过:解码速度的上限约等于“可用内存带宽除以模型权重体积”。假设一台手机的内存带宽是 60GB/s,普通 7B 模型用 4bit 量化后,权重大约 4GB,理论上限就是每秒 15 个 token 左右。如果用 8bit 量化,权重变成 7GB 上下,上限掉到每秒 8 个。如果用半精度浮点,权重直奔 14GB,上限只有每秒 4 个。
公式不是万能的,但方向很清楚:量化等级直接决定解码速度的上限。我刚开始测的时候,一边跑的是量化后的模型,另一边跑的是几乎没压缩的版本,光这一项就能差出三倍以上,更何况再叠加不同后端。
| 模型格式 | 大致权重体积(7B 级) | 解码阶段理论参考 |
|---|---|---|
| 4bit 量化 | 约 4GB | 受带宽限制,相对最快 |
| 8bit 量化 | 约 7GB | 速度约为 4bit 的一半略多 |
| 半精度浮点 | 约 14GB | 一颗普通移动芯片很难喂饱带宽上限 |
2.3 线程数、绑核和系统调度也能造成数量级差距
移动操作系统为了省电,经常会把后台进程的 CPU 占用限制得很死。如果推理进程没有得到足够多的优先级,或者线程被绑到了低频率的小核上,哪怕芯片本身很强也发挥不出来。
我排查时用系统工具看了一下 CPU 频率,发现我的进程一直跑在接近最低频,负载几乎没把大核心唤醒。再看线程配置,单线程模式跑大模型本来就是最差的情况,多线程虽然不能让带宽上限变高,但至少能更充分地利用多核心去搬运数据和执行算子。
还有一个隐蔽点:不同手机对线程亲和性的处理不一样。某些系统会自动把重负载任务放到高能核心,某些则需要手动设置。如果忽略这一层,同一台手机在不同设置下跑出 5 倍差距都是正常的。
2.4 温度和频率陷阱:短时快不代表持续快
我在一开始只测了一次,觉得数据稳定就没再管。后来把测试脚本改成连续跑三轮,每一轮间隔 5 秒,结果第二轮和第三轮的解码速度明显往下掉。
原因很直白:手机是无风扇设备,芯片一热就 THROTTLE。很多对比评测只跑几十个 token 就结束,短时间内的性能看着挺漂亮;一旦你的使用场景是长对话、长文章生成,温度上来之后频率会不断下降。我之前对比时没有把温度纳入变量,导致同一个模型在同一台手机上,冷机状态和热机状态之间的差距比自己跟另一台手机比还大。
所以后来我每次测性能都会先把一个相同 prompt 跑两遍预热,再记录第三轮的数据。避免因为散热状态不一致,把“别人比我慢”测成“我比别人慢”。
3. 端到端排查过程复盘
3.1 先把硬指标记录下来
排查的第一步,不是改任何参数,而是建立可复现的基础测量脚本。我的做法是同时记录三个指标:预填充每秒处理 token 数、解码每秒生成 token 数、整个过程的温度和 CPU 频率曲线。
我用了一个很简单的命令模式,通过安卓的调试桥在设备上执行推理测试,然后把日志拉下来看:
# 把测试脚本推到设备上,固定提示词和输出长度 adb push ./infer_cli /data/local/tmp/ adb shell chmod +x /data/local/tmp/infer_cli # 第一次先跑原始配置 adb shell /data/local/tmp/infer_cli \ -m ./model-q4_K_M.gguf \ -p "写一段关于端侧推理的说明文字" \ -n 64 \ -t 4 # 用系统工具观察 CPU 频率和温度变化 adb shell top -n 1 | grep infer_cli adb shell cat /sys/class/thermal/thermal_zone*/temp这里的参数列表不需要和我完全一样,但必须记录清楚。我习惯把配置文件单独保存,因为任何一个人的测试结论都离不开他那套参数。没有参数的跑分,只配当谈资。
3.2 逐层替换变量,快速锁定问题源
拿到基线数据后,我开始一层层换变量。每轮只改一个变量,最多改两个,避免多个因素叠加导致无法判断。
第一轮把线程数从 1 改成 4,观察解码吞吐变化。这里要注意,线程数增加不一定永远线性提升,因为瓶颈在内存带宽的时候,再多线程也没用。但如果从 1 到 4 有明显提升,说明之前的性能瓶颈有一部分在线程调度上。
第二轮改后端加载方式。我用的推理运行时支持把一部分模型层加载到专用加速路径,从完全不加载改成全部加载,观察日志里有没有算子回退到 CPU 的警告。如果加载后速度不变,基本可以断定当前的算子并没有真正用到加速单元,这时候要查模型版本和推理运行时的支持列表。
第三轮换量化格式。我把同一个模型分别导出为 4bit、8bit 和半精度三份,实际测一下文件大小和解码速度。这个对比最有说服力,因为硬件没变、线程没变、后端没变,变的只有权重的体积。
3.3 我复现到“80 倍”的实际链条
既然标题里写着慢 80 倍,我不能只给猜测,我把这个差距复现出来看了一下。
那次复现的条件是这样的:第一组用半精度模型、线程数只用 1、加载全部模型到通用 CPU 后端,同时后台还挂了一些杂七杂八的应用;第二组只用 4bit 量化、线程数 4、开启专用加速路径,并且把前台运行等级拉满。
最终解码速度大概是第一组每秒 0.3 个 token,第二组每秒 12 个 token 左右,差距约 40 倍。再算上第一次测试时把首 token 额外延迟算错了时间段,误加进“每秒 token”统计里,最终数字就接近 80 倍。
这给了我一个很重要的认识:80 倍不是单一硬件产生的,它是测量口径错误、量化差异、后端差异、线程和系统调度差异叠加之后的结果。单独任何一项都解释不了 80 倍,它们同向叠加才制造了这个夸张数字。
4. 容易把性能带偏的操作,我踩过哪几个
4.1 只看首 token 时延,不看稳态解码
首 token 时延反映的是模型读完提示词、做完首次推理的最短响应时间,它特别容易受到提示词长度的影响。一个 50 token 的短提示词和一个 2000 token 的长提示词,首 token 时延差出数倍很正常。
而真正影响端侧使用体验的,是稳态解码速度,也就是模型进入稳定输出后每秒能生成多少个 token。我一度被漂亮的源码输出误导,以为自己的手机快得不行,真正跑长文时才发现解码速度根本不够用。现在我看任何评测数字,第一反应都是问:这是首 token 的速度还是每秒生成速度?
4.2 冷机和热机没分开,数据没有参考价值
移动设备的性能曲线是一条先高后低的斜线。刚启动时芯片凉快,可能体感很快;运行一两分钟后温度升高,频率被压,于是慢得离谱。
我不止一次在群里看到有人发对比图,说自己手机跑模型比某台手机慢了几十倍。结果一问,对方只跑了 10 秒,他跑了 3 分钟。这两个数据没法比,因为散热状态完全不是同一个阶段。
正确的做法是让被测设备先预热,跑一段相同的负载,等到温度稳定后再记录。如果一台手机在冷机状态对比热机状态,结果只可能误导自己。
4.3 用不同量化版本强行对比,也是对不对口
有一次我很认真地想把“同一手机的前后优化”做成表格,结果发现第一版跑的模型还是未压缩精度的老版本。文件体积差了接近四倍,解码速度自然差别巨大。这种对比在技术分享里几乎看不到,但新手很容易踩进去。
我现在只要拿到一个新的模型文件和版本号,都会先检查权重位数和量化描述。因为端侧推理的所有优化,本质都是在压体重、减搬运、提利用率。
5. 把结果跑稳的实操检查单
5.1 测前必查的五个问题
现在我在端侧跑性能测试前,会先过一套固定的检查单。这个清单很短,但每一条都能避免浪费几小时去追查:
第一,双方用的是不是同一个模型文件?不仅版本号要对,量化方式、上下文长度参数也要对。第二,线程数和绑核策略是否一致?有没有让系统后台限制频率。第三,后端是否一致?有没有算子回退到 CPU。第四,有没有预热?温度是否已经稳定。第五,统计的是解码阶段每秒 token 数,还是首 token 时延?这两个数字完全不是一回事。
每次测试前把这份清单过完,再看差距,才能真正反映硬件和部署方案的水平。
5.2 合理预期表
我不喜欢给出太绝对的数字,因为每台设备差异很大。但如果模型是 7B 级别、4bit 量化、开启加速后端、线程数量合理,日常生活中大多数旗舰手机的解码速度大概在每秒 5 到 15 个 token 之间。只有达到这个量级,对话体感才勉强可用。
如果低于每秒 2 个 token,基本不是硬件问题,而是某个环节没配对。要么量化级别过重,要么后端没启用,要么系统在省电状态。
如果低于每秒 0.5 个 token,那就先停下测速,回去看日志。十有八九是模型根本没有跑在端侧推理的正常链路上,而是在执行某种极度低效的兜底路径。
6. 复盘:我现在拿到性能异常会怎么做
这次折腾给我留下的直接收获,是一套固定的排查动作。
我拿到任何一个“性能慢几十倍”的反馈,第一件事不是换手机,而是把双方的日志拉到一块,一行行看推理运行时实际加载的算子、后端标识和量化信息。很多慢的问题只要看到“运行时走了CPU回退”“线程数只有1”“模型体积明显异常”就能立刻解释,不需要进入复杂分析。
我后来把检查流程固化成了一个小脚本,每次测速前先打印一份环境信息:模型文件大小、量化类型、线程数、后端类型、当前温度、可用内存。看到这些信息齐全了,我才会相信后面的性能数字。
另外我还养成了考量和量化先行的习惯。因为解码阶段本质是内存带宽的游戏,先比较模型体积,再谈芯片性能,顺序千万不要反。只要模型体积更小、内存搬运更少、设备带宽可以喂满,最终的表现通常不会太差。
这次排查最终让我把差距从 80 倍缩小到几乎可以忽略,但我也很清楚,真正有用的并不是那个最终数字,而是每次改配置时留下的记录。端侧推理的优化是一场持续测量、逐项对比才能做好的事,建议你也从一个简单的检查单开始,而不是上来就换设备。