系列:《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-1《为什么换掉 OpenMP:线程唤醒与亲和》 | 下一篇:4-3《核绑定实验:为什么"勿设 8 线程"》
真机实测通过:本文实验已在 RK3588 板端实测完成(2026-09;方法学与原始记录见仓库 docs 与《实验脚本》目录)
一句话导读:推理引擎并行切块与调用者参与机制:进入 vllm_tp_parfor 内部读静态均分、发布任务、调用线程领取最后一块与分级收尾等待的完整流程,落点在按线程号累加的计数器实验,直接验证调用线程真在干活。
关键词:手搓 Qwen 推理引擎、千问大模型推理、线程池、vllm_tp_parfor、静态切块、调用线程、Qwen3-VL、零依赖纯 C
导语:发起并行的线程,自己要不要也领一份活?本篇走进引擎的 vllm_tp_parfor:静态均分怎么切、任务怎么发布、调用线程为何取走最后一块,并用一个按线程号累加的计数器实验证明——它确实在下场干活,而不是在边上空等。
4-1 讲的是线程池"为什么这样设计",今天进 vllm_tp_parfor 内部,看一个最容易被忽略、却最体现工程味道的细节:发起并行的调用线程,自己也领了一份活。
1. 知识点:静态切块与"调用者不傻等"
vllm_tp_parfor(start, end, body, ctx) 的语义和 #pragma omp parallel for schedule(static) 一致:把区间 [start, end) 切成 nthreads 段连续块(static = 编译期式均分,每行代价均匀时最省心),每段给一个执行者。关键在"执行者"的定义——包括调用线程自己:
nthreads = nworkers + 1:nworkers个常驻 worker 线程 + 1 个调用线程;- 调用线程不发布完任务就干等,而是领最后一块去干活,干完再 join 收尾。
这意味着最常见的并行区(比如矩阵的一批行)里,没有一颗核闲着:worker 数 +1,等于把一个纯开销位变成了算力。而 join 阶段(tp_join_workers)也不是死等:先自旋一小段(VLLM_TP_JOIN_SPIN),自旋耗尽就 yield 把 CPU 让给正在醒来的 worker,再不行才 1ms 睡、2 秒兜底重发信号——收尾的等待也分三档,绝不空转烧核。
2. 对应代码:parfor 本体(vllm_tp.c 第 334–371 行)
void vllm_tp_parfor(int start, int end,
void (*body)(void *ctx, int idx), void *ctx) {
vllm_tp *g = g_tp;
if (!g) {
vllm_tp_init(0); g = g_tp; }
if (!g || g->nthreads <= 1 || end <= start || t_tid >= 0) {
if (body) for (int i = start; i < end; i++) body(ctx, i);
return; /* 退化:串行直跑 */
}
int nt = g->nthreads;
int n = end - start;
int base = n / nt, rem = n % nt; /* (1) 静态均分 */
int acc = 0;
for (int t = 0; t < nt; t++) {
int len = base + (t < rem); /* 前 rem 块各多 1 项 */
g->chunk_lo[t] = start + acc;
g->chunk_hi[t] = start + acc + len;
acc += len;
}
g->body = body; g->ctx = ctx; g->kind = 1;
tp_barrier(); /* (2) 发布任务 */
tp_atomic_inc(&g->job_gen); /* 序号 +1,release */
tp_wake_workers(g); /* 只叫醒睡着的 */
/* caller takes the last chunk (slot nt-1) ... */
int old = t_tid;
t_tid = nt - 1; /* (3) 调用线程 = 最后一块 */
tp_bind_caller();
for (int i = g->chunk_lo[nt - 1]; i < g->chunk_hi[nt - 1]; i++)
body(ctx, i);
tp_join_workers(g); /* (4) 收尾等待 */
tp_unbind_caller();
t_tid = old;
}
四个步骤:(1) 静态切块(前 rem 块多分一项,总数一个不多一个不少);(2) 发布任务(写字段 → 内存屏障 → 任务序号 job_gen +1;醒着的 worker 自旋时直接看到新序号,零 syscall);(3) 调用线程取最后一块执行;(4) join 收尾。注意 t_tid 的用法:调用线程在并行区内把自己标记为 nt-1 号,所以 body 里问 vllm_tp_worker_id(),worker 回答自己的槽位、调用线程回答 nt-1——每个执行者都知道自己是谁。
3. 改动后果:用计数器证明"调用线程真的在干活"
写一个 8,000,000 项的 parfor,body 里按线程号累加计数(程序直接链接 vllm_tp.c,4 线程)。板端实测(RK3588 / 2026-09):
pool threads=4 (workers=3, caller slot=3)
thread[0] executed 2000000 items
thread[1] executed 2000000 items
thread[2] executed 2000000 items
thread[3] executed 2000000 items
total=8000000 (expect 8000000) | caller is thread 3, executed 2000000
四个执行者各 2,000,000,一分不差;其中 thread[3] 就是调用线程——它没有在旁边等,而是真刀真枪做了 1/4 的工作。
现在想象把"调用线程参与"去掉(调用线程只发布 + join):同样 8M 项,只剩 3 个 worker 在干 → 计算时间涨 33%;更糟的是收尾的 join 全程是"等别人干完",而自己这颗核空转——在背靠背的小并行区里,每一区都白扔一个核。tp_join_workers 里那句注释说得直白:"caller must not share a logical CPU with a spinning worker"——调用线程要么干活、要么把 CPU 让出去,就是不傻等。
想亲眼看"调用线程在干活":用 gdb 在
vllm_tp_parfor的t_tid = nt - 1;后打断点,next进 for 循环,p t_tid会看到它是nt-1;或用上面的计数器 demo 直接看分布(我们就是这么测的)。
4. 学员调试任务
- A 档(板端动手):复现上面的计数器 demo(源码就是第 3 节的
body累加),把区间改成n=10,000,000、线程数设为 3(VLLM_THREADS=3),写出预期分布(提示:10M / 3 = 3,333,333 余 1)再运行验证——你会直观看到"前 rem 块多 1 项"的均分规则。 - B 档(纯读源码):读
tp_join_workers(第 240–268 行),说出收尾等待的"自旋 → yield → 睡 1ms → 2 秒兜底"四档各自防什么(提示:空转烧核 / 抢占刚醒的 worker / 丢唤醒死锁)。
预期输出:你能解释"nthreads 含调用线程"的含义、静态切块"前 rem 块多 1 项"的规则,以及调用线程领最后一块的设计动机。
收尾
- 本篇源码点名:vllm_tp.c(
vllm_tp_parfor第 334–371 行、tp_join_workers第 240–268 行)。 - 开源仓库:Kestrel-LLM (Gitee)(AGPL-3.0-or-later 或商业许可,二选一)
- 下篇预告:线程池把活分下去了,可"分给谁"还有个核的学问:RK3588 的大小核不对称。下一篇 4-3 做核绑定实验——为什么工程文档要叮嘱"勿设 8 线程"。