系列:《0.8MB 跑通 Qwen:从零实现 ARM 零依赖纯 C 推理引擎》(30 天 × 90 篇) | 适配模型:Qwen3-VL-8B-Instruct(千问3_VL_8B_Instruct)· Qwen3-VL-2B-Instruct · Qwen3-30B-A3B | 测试设备:RK3588(4×Cortex-A76 + 4×Cortex-A55,aarch64)
系列总纲:《0.8MB 跑通 Qwen》30 天 90 篇 · 总纲(阿里云社区)
上一篇:4-2《vllm_tp_parfor:调用线程也干活》 | 下一篇:第 5 天《量化地基:Q8/Q4 从哪来》
真机实测通过:本文实验已在 RK3588 板端实测完成(2026-09;方法学与原始记录见仓库 docs 与《实验脚本》目录)
一句话导读:推理引擎的核绑定实验:从 RK3588 四颗 A76 大核加四颗 A55 小核的不对称架构出发,解释绑核目标只写大核簇的取舍,落点在纯计算、真实 prefill 与开绑核三组线程数对照实验,验证勿设 8 线程。
关键词:手搓 Qwen 推理引擎、千问大模型推理、核绑定、大小核、线程数、A76、RK3588、Qwen3-VL、零依赖纯 C
导语:RK3588 有 8 个核,默认却只用 4 个——因为它是 4 颗 A76 大核加 4 颗 A55 小核的大小核。本篇用三组实验钉死「核多≠快」:纯算下 8 线程最快、真实 prefill 打平、一开绑核反而慢约 12%,说清工程为何叮嘱「勿设 8 线程」。
4-1 里那行 nthreads = (nc > 4) ? 4 : nc 留下了悬念:RK3588 明明有 8 个逻辑核,为什么默认只用 4?因为它是 4×Cortex-A76(大核)+ 4×Cortex-A55(小核)的大小核——"核多"不等于"算得快"。这一篇我们用三组实验把这句话钉死。
1. 知识点:大小核不对称与"绑核"
A76 与 A55 是两种完全不同的核心:A76 主频高、乱序执行,是干活主力;A55 面积小、省电,性能约为 A76 的 1/3–1/2。推理引擎的 GEMM 是 A76 大核的菜;把线程扔到 A55 上,等于让主力在快车道、干活的却在小路。
所以线程池有"绑核"(affinity)机制:把每个 worker 钉到指定物理核,避免调度器乱丢。看 vllm_tp.c 第 115–125 行:
static void tp_bind_worker(int slot, int nthreads) {
(void)nthreads;
if (tp_bind_disabled()) return;
long n_cpus = sysconf(_SC_NPROCESSORS_ONLN);
if (n_cpus <= 0) n_cpus = 8;
int core = (n_cpus > 4) ? (4 + (slot % 4)) : (slot % n_cpus); /* A76 cluster */
cpu_set_t cs;
CPU_ZERO(&cs);
CPU_SET(core, &cs);
sched_setaffinity(0, sizeof(cs), &cs);
}
注意两件事:
- 绑核目标只写 A76 集群(核 4–7),slot 对 4 取模——4 个 worker 各占一个大核;
- 但绑核默认是关的(
VLLM_TP_BIND=1才开启,第 105–113 行注释:x86 基准机实测不绑更快)。也就是说:默认情况下线程靠 Linux 调度器自由飘,8 线程就可能飘到 A55 上。
于是问题变成:8 线程在这块板上到底是赚是亏? 文档里有过明确警告(技术文档.md 环境变量表与 §11.4):VLLM_THREADS 默认 4、"绑定 4×A76 大核;勿设 8(A55 拖累,见 §11.4)",§11.4 记录同板实测 8 线程反而慢约 2 倍(图片编码 21.7→47s,文本 prefill 也变慢)。下面我们用当前代码在板端亲手验证,顺便看清"绑核开与关"两种世界。
2. 对应代码:线程数从哪来
回顾 4-1:线程数取 OMP_NUM_THREADS,否则取别名 VLLM_THREADS,再默认 nc > 4 ? 4 : nc。真实推理走 VLLM_THREADS=4(技术文档环境变量表 + 部署脚本 start.sh 均写死 4)。所以工程给这台板的出厂建议是 4 线程——本节的实验就是验证这个建议。
3. 改动后果:三组实验,看"线程更多"到底带来什么
实验 1:纯计算缩放(微基准,说明"更多核在纯算下有收益")
一个只做浮点运算的 parfor(4M 项),分别用 1/2/4/6/8 线程。板端实测(RK3588 / gcc 11.4 / 2026-09):
VLLM_THREADS=1 : 0.0784s
VLLM_THREADS=2 : 0.0704s
VLLM_THREADS=4 : 0.0428s
VLLM_THREADS=6 : 0.0280s
VLLM_THREADS=8 : 0.0255s
单调改善,8 线程最快——纯计算、无内存瓶颈的负载,核越多越好。这说明"A55 拖累"不是无条件的,它只出现在真实推理负载(GEMM/带宽受限)里。所以别用玩具程序下结论,要跑真负载。
实验 2:真实引擎 prefill(2B 模型,同一 prompt),默认不绑核
起服务 → 发一次约 5 秒的文本 prefill 请求,4 线程 vs 8 线程各两轮:
VLLM_THREADS=4 : 5.06s / 5.25s
VLLM_THREADS=8 : 4.97s / 4.97s
8 线程没有变慢,还略快 ~2–4%——为什么没复现文档的"慢 2 倍"?因为当前代码默认不绑核,调度器把多余线程放到 A55 上,但 4 个 A76 大核仍被核心工作占满,A55 分担了少量杂活,净效果接近打平。
实验 3:开绑核(VLLM_TP_BIND=1),同一请求
VLLM_THREADS=4 VLLM_TP_BIND=1 : 4.64s / 4.73s
VLLM_THREADS=8 VLLM_TP_BIND=1 : 5.24s / 5.33s
绑核后 8 线程比 4 线程慢约 12%(5.3s vs 4.7s)。为什么?绑核代码 4 + (slot % 4) 把 7 个 worker 全塞进 4 个大核——后 4 个 worker 与先 4 个双绑在同一物理核上互抢执行槽,反而添乱。这就复现了"勿设 8 线程"的机制:在这台板上,4 就是 A76 集群的容量上限,再多线程只是在同一个核上排队。
三组实验拼起来的结论:线程数的最优值取决于"负载是否饱和 A76"。玩具纯算饱和 8 核 → 8 线程好;真实推理的 GEMM 在 4 个大核上就接近饱和 → 8 线程无益;一旦绑核,超过 4 线程更是直接负优化。文档里那个"慢 2 倍"来自更重、更吃小核的负载与当时的测量口径——量级随负载浮动,方向一致:不要盲目设 8。
工程启示:为什么默认绑核是"关"的?因为绑核在 x86 基准机上是负优化(调度器比你懂),而在这块 ARM 板上又可能想要。所以做成开关(
VLLM_TP_BIND),把"要不要绑"留给实测——这是"配置即诚实"的又一例:代码不替你赌。
4. 学员调试任务
- A 档(板端动手):找你的板子(或有模型资产的环境)跑实验 2/3 的姿势:
nproc看核数,lscpu或/sys/devices/system/cpu/确认是否大小核;- 同一 prompt 下,对比
VLLM_THREADS=4/8与VLLM_TP_BIND=1开/关四组合,各自记录耗时; - 说出你的板子"最优线程数"(不必等于 4,可能是 6/8——关键是你测出来的)。
- B 档(纯读源码):读
vllm_tp.c第 101–148 行(亲和三函数)与第 281–293 行(线程数策略),画一张"RK3588 8 核 → 4 worker + 调用线程"的分配图,标注绑核时 slot 4–7 会落在哪。
预期输出:你能解释"核多≠快"的大小核原因、"绑核默认关"的取舍,以及为什么实验必须跑真负载而不是玩具程序。
收尾
- 本篇源码点名:vllm_tp.c(
tp_bind_worker/tp_bind_caller/线程数策略)、技术文档.md(环境变量表与 §11.4 线程数)。 - 开源仓库:Kestrel-LLM (Gitee)(AGPL-3.0-or-later 或商业许可,二选一)
- 下篇预告:并行把"活"分下去了,可"活"本身是浮点数——4 字节存一个权重,2B 模型就要 8GB,板上根本放不下。第 5 天进入量化地基:Q4/Q8 是怎么把内存和带宽需求砍到 1/2、1/4 的。