0.8MB 跑通 Qwen|第 4-2 篇:推理引擎的 vllm_tp_parfor——调用线程也干活

简介: 本系列《0.8MB跑通Qwen》聚焦ARM端零依赖纯C推理引擎,实测RK3588平台高效运行Qwen3-VL多模型。本文深入`vllm_tp_parfor`,揭示调用线程主动领任务、静态切块与分级收尾机制,以计数器实验确证“每核皆算力”,展现极致轻量与工程严谨的统一。(239字)

系列:《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 线程"。
相关文章
|
2天前
|
缓存 C++
0.8MB 跑通 Qwen|第 3-1 篇:推理引擎的 mmap 直挂——为什么权重加载可以只要一秒多
本文实测RK3588板端冷启动仅1.0–1.2秒,核心在于mmap直挂VQF权重文件:不全量读取,仅映射+校验头部,权重页由内核按需缺页加载,配合预量化布局,较safetensors全量读快33倍。(239字)
 0.8MB 跑通 Qwen|第 3-1 篇:推理引擎的 mmap 直挂——为什么权重加载可以只要一秒多
|
3天前
|
编译器 C语言
0.8MB 跑通 Qwen|第 2-1 篇:推理引擎的 C11 `_Static_assert`——让编译器守卫你的内存布局
本文详解C11 `_Static_assert` 在内存布局守卫中的关键作用:针对mmap直挂场景,通过编译期断言钉死VQF格式三结构体(200/432/64字节),杜绝因对齐差异导致的静默错位。真机RK3588实测验证,实现错误前移。
0.8MB 跑通 Qwen|第 2-1 篇:推理引擎的 C11 `_Static_assert`——让编译器守卫你的内存布局
|
1天前
|
编译器 C语言
0.8MB 跑通 Qwen|第 6-1 篇:推理引擎的 ARMv8.2 dotprod——`vdotq_s32` 一条指令做 4 个点积
本系列《0.8MB跑通Qwen》聚焦ARM零依赖纯C推理引擎,实测RK3588平台,适配Qwen3-VL多模型。本文详解ARMv8.2 dotprod指令(vdotq_s32)如何在真实大内核中释放性能,破除微基准误导,揭示寄存器压力下加速本质。(239字)
|
1天前
|
编解码 算法 C++
0.8MB 跑通 Qwen|第 17-3 篇:位级一致性实验——推理引擎两条转换路产物逐字节对拍
本系列《0.8MB跑通Qwen》聚焦ARM端零依赖纯C推理引擎实现,实测RK3588上位级一致性验证:同源重跑确定性、无损源跨格式殊途同归、有损源再量化边界清晰可测。覆盖Qwen3-VL多模型,强调sha256/逐张量哈希/块级比对三位一体验证。(239字)
|
1天前
|
缓存 内存技术
0.8MB 跑通 Qwen|第 9-2 篇:推理引擎的 flash_attn_single_q_q8_neon——单 q 单遍扫全 KV
本系列《0.8MB跑通Qwen》聚焦ARM零依赖纯C推理引擎,精读`flash_attn_single_q_q8_neon`内核:通过Q单次量化、q8 KV单遍扫描、在线softmax与尾部归一,显著降低带宽与内存开销,实现在RK3588等端侧设备高效运行Qwen3-VL系列模型。(239字)
|
1天前
|
调度 C++
0.8MB 跑通 Qwen|第 7-3 篇:推理引擎的预热与测量抖动——为什么报告要"重复 3 次取中位"
本系列《0.8MB跑通Qwen》聚焦ARM端零依赖纯C推理引擎开发,实测适配Qwen3-VL多模型,在RK3588上验证性能抖动——单内核耗时波动达1.7倍,确立“预热+3次取中位”为可信基准口径。(239字)
|
1天前
|
存储 缓存 编解码
0.8MB 跑通 Qwen|第 19-2 篇:推理引擎的 vision_tokens 客户端预编码协议
本系列《0.8MB跑通Qwen》专注手搓零依赖纯C推理引擎,适配Qwen3-VL多模态大模型,在RK3588(aarch64)实测通过。本文详解`encode_media_item`三路协议:`image_url`(板端ViT+128位缓存)、`video_frames`(帧序列)与`vision_tokens`(客户端预编码跳过ViT),支持字节数自校验与缓存命中优化,真机实测ViT耗时295ms→缓存后仅301ms。
|
1天前
|
调度 C++
0.8MB 跑通 Qwen|第 14-2 篇:单 SSE 流与串行队列——推理引擎的"单机守则"
本系列《0.8MB跑通Qwen》聚焦ARM端零依赖纯C推理引擎,适配Qwen3-VL多尺寸模型(2B/8B/30B),基于RK3588实测。本文详解“单机守则”:推理状态串行锁定、SSE流独立输出、HTTP与推理线程职责分离,揭示为何默认串行是稳定前提而非性能缺陷。(239字)
|
1天前
|
调度 C++ 索引
0.8MB 跑通 Qwen|第 12-3 篇:复现 13.1×——推理引擎的跨进程恢复实测怎么做(含口径拆解)
本系列《0.8MB跑通Qwen》聚焦ARM端零依赖纯C推理引擎,实测RK3588平台运行Qwen3-VL多模型;详解跨进程KV恢复机制,揭示prefill/TTFT/wall三口径差异,13.1×加速比可复现、可验证。(239字)
|
1天前
|
缓存 自然语言处理 安全
0.8MB 跑通 Qwen|第 11-1 篇:大模型推理中多轮对话的浪费——上一轮 prefill 算完就被扔了
本系列《0.8MB跑通Qwen》聚焦ARM端零依赖纯C推理引擎,实测RK3588平台高效运行Qwen3-VL多模态模型;核心突破是默认启用前缀KV复用,使多轮对话prefill耗时骤降27倍,显著消除历史重复计算,大幅提升端侧长会话效率。(239字)

热门文章

最新文章