从部署一个embedding模型说起。我有天在容器里跑vllm/vllm-openai:v0.27.1加载qwen3-embedding-0.6b,进去ps -ef发现了run_engine_core这个进程,顺手ps -T -p <pid>看了一眼,发现里面整整齐齐躺着三个线程。群里也有不少人问过:run_engine_core进程里的三个线程到底是干嘛的?为什么不能一个线程全干完?这几个线程之间是各干各的还是有联动?这篇文章就把我实际排查、看源码、压测过程中对这三个线程的理解完整写一遍,能帮你看懂线程状态,也能帮你定位部署大模型时常见的性能问题。
1. 先搞清楚:run_engine_core进程里到底能看到什么
1.1 一次真实部署里的线程观察
vLLM部署起来之后,进程结构其实比想象中分层更明显。最顶层是API Server进程,往下是run_engine_core,再往下往往会看到一组GPU Worker。用ps -T -p <pid>观察run_engine_core进程,典型状态下能看到三个业务线程,对应关系可以简化成下面这样:
| 线程 | 内部对应组件 | 核心职责 |
|---|---|---|
| 线程一 | Scheduler(调度器) | 决定每轮推理放哪些请求进batch,管理KV Cache |
| 线程二 | ModelExecutor(模型执行器) | 真正跑Transformer forward,调GPU |
| 线程三 | OutputProcessor(输出处理器) | 处理采样结果,更新序列状态并生成响应 |
在较新版本的vLLM里面,这三个组件已经完全拆开,各自跑在独立线程或者说独立异步循环里。这也解释了为什么你用py-spy dump --pid <pid>的时候,经常能看到三个线程各自卡在不同的调用栈上:一个卡在Scheduler._schedule,一个卡在ModelRunner.execute_model,还有一个卡在OutputProcessor.process_outputs。
线程名的可读性不要抱太大期望。vLLM的线程并没有设置非常具体的业务名,你直接ps -T看到的很可能就是python之类的一串名字。要确认线程到底在干什么,最靠谱的手段是py-spy dump,后面第4章我会专门演示。
1.2 为什么偏要拆成三个线程
这是很多人第一个问题:调度、执行、输出处理,串行在一轮循环里做掉不行吗?当然行,但GPU利用率会很难看。一个典型的单线程驱动循环长这样:先调度(CPU算几十毫秒),再执行模型(GPU算几百毫秒),最后处理输出(CPU再算十几毫秒)。问题在于整个循环里,GPU真正干活的时间占比只有T_execute / (T_schedule + T_execute + T_process),中间CPU忙的时候GPU全在空转。
拆成三个线程后,调度线程在GPU跑当前batch时就能提前准备下一个batch,输出处理线程也能在处理上一轮结果的同时让执行线程立刻接续下一批输入,GPU的空闲窗口被压到很小。这就是业界常说的延迟掩盖(latency hiding),也是vLLM高吞吐表现背后的核心原因之一。
2. 三个线程各自在忙什么
2.1 Scheduler线程:真正的“排班系统”
调度线程负责的是整个推理引擎的“排班表”。vLLM里每个请求进来后会被包装成SequenceGroup,一个请求如果开了beam search,就会对应多条序列(Sequence)。调度线程维护着这三类状态:
- waiting:刚进来还没轮到执行的请求
- running:当前正在占用GPU执行前向的请求
- swapped:因为显存不够被暂时“换出”的请求
每轮迭代,调度线程要回答一个核心问题:本轮把哪些序列放进batch?答案取决于当前还有多少KV Cache余量、每个序列还要生成多少token、最大允许的batch token数是多少。
vLLM的continuous batching就体现在这里:running里的序列一旦结束,waiting里的新请求会立刻补上,而不是等整个batch全部跑完。调度线程还要处理一个昂贵操作叫preemption。当KV Cache不够用的时候,调度线程必须选中部分序列,把这部分序列的KV Cache释放掉,把已经算出的token固化下来,这会导致这些序列从running挪到swapped。preemption非常耗CPU,一旦发生频繁,你的GPU利用率反而会下降,这个我后面在问题排查部分细说。
从源码实现来看,_schedule阶段做的事情包括:检查可用显存slot、处理swapped序列的重新调度、将waiting里的请求塞进running、对超长序列做preemption决策,最终产出一个SchedulerOutputs。这个输出会被投递给模型执行线程。
2.2 ModelExecutor线程:唯一碰GPU的那只手
模型执行线程拿到SchedulerOutputs后,开始组装本轮真正要计算的内容。它需要做这些事:
- 把调度结果里的token ID拼成batch输入张量
- 跑Embedding层
- 跑Transformer的各层:Self-Attention、FeedForward、Normalization
- 跑最终的采样(Sampling)
- 如果开了Tensor Parallel(TP)或Pipeline Parallel(PP),还要负责和多个GPU Worker通信协调
这个线程是整个引擎里唯一直接发起CUDA Kernel的线程,因此它的耗时基本决定了单次step的“地板时间”。你如果开nvidia-smi看到GPU利用率上不去,又确认请求队列里明明有请求,那大概率要顺着执行线程的调用栈去找原因。
执行线程跑完前向之后会产出ModelOutput,里面包含每个序列生成的下一个token的id、logprobs、隐藏状态等。这些结果不会直接返回给用户,而是交给输出处理线程去消化。
2.3 OutputProcessor线程:负责“收拾残局”
输出处理线程的工作容易被低估,但实际上它才是决定响应能不能按时返回的那一环。它拿到模型输出后要做以下处理:
- 把新生成的token追加回对应的序列
- 检查是否生成了EOS(结束符),或者是否超过了长度限制
- 处理beam search里需要保留的候选序列
- 更新序列组的分数、长度、是否完成等状态
- 对完成的序列,生成最终的响应内容,返回给上层API
- 释放掉已完成序列占用的KV Cache slot,让调度线程下一轮可以复用
可以这么理解:调度线程决定“谁进来”,执行线程负责“跑一遍”,输出线程决定“跑完的结果怎么收走”。输出处理线程如果处理慢了,会出现一个很有趣的现场:GPU早就跑完了,但下一批batch迟迟没有提交,因为上一批输出的KV Cache还没释放,序列状态还没更新完。这种情况下GPU利用率会掉,但你用nvidia-smi看显存用量却是满的。
3. 三个线程怎么协作:不是无脑并行,而是流水线步进
3.1 数据流与同步机制
三个线程之间是典型的生产者-消费者模型,数据靠内部队列或帧缓冲传递。流程大致如下:
新请求 -> Scheduler线程 -> SchedulerOutputs -> ModelExecutor线程 -> ModelOutput -> OutputProcessor线程 -> 更新序列状态/KV Cache -> 下一轮Scheduler每一轮step,三个线程各自处理一个阶段的产物,但它们之间存在严格的依赖关系:下一轮调度的前提是上一轮输出已经被处理完,因为KV Cache分配表必须在调度前保证是最新的。vLLM内部通过类似step barrier的机制来对齐这一轮三线程各自完成的时间点,保证三个阶段的边界一致。
实际到代码层面,你会看到last_outputs、cache_events这类变量在协调推进。旧版本里直接用Condition、Lock,新版本引入了更多异步条件变量和asyncio.Queue。这里的要点不在于具体实现细节,而在于你理解它是一种“依赖步进”的流水线,不是三个线程各跑各的map-reduce。
3.2 三线程带来的吞吐收益怎么算
假设一轮step里:
- 调度耗时:T_schedule = 20ms
- 模型执行耗时:T_execute = 120ms
- 输出处理耗时:T_process = 15ms
单线程串行方案下一轮耗时约155ms,GPU利用率约120 / 155 = 77%。三线程流水线方案下,理想稳态每轮耗时约等于max(20, 120, 15) = 120ms,GPU利用率理论上接近100%。虽然实际因为有同步损耗、GIL争抢,达不到理论值,但吞吐提升是实打实的。这也是为什么同样一块A10,不改模型、不换显存,单纯把vLLM从老版本升到新版本,decode吞吐能提升一截的原因之一。
需要说明的是,三线程内的Python代码部分仍受CPython GIL约束,不可能真正同时跑Python指令。vLLM之所以能绕开这个限制,是因为模型执行线程把重计算都下沉到了PyTorch/CUDA Kernel里,Kernel执行时GIL会释放,所以GPU真正长时间跑的时候,调度线程和输出处理线程在CPU上依然可以穿插执行。
3.3 一轮step的完整时序实例
按时间线看一轮step大概是这样的:
- t0时刻:调度线程完成
schedule(),把本次batch的SchedulerOutputs写入缓冲,通知执行线程。 - t1时刻:执行线程被唤醒,发现GPU空闲,立刻提交CUDA Kernel,开始跑batch N的前向。
- 执行线程跑batch N的同时,调度线程没有被阻塞,可以回去准备下一轮请求的排班;输出线程同时也在处理batch N-1的结果,两者在CPU上争夺GIL,但因为执行线程在CUDA里释放了GIL,这一轮整体错开了。
- t2时刻:执行线程完成batch N,把ModelOutput交给输出处理线程,自己拿到下一轮的SchedulerOutputs(假设调度线程已经准备好了),继续跑batch N+1。
- 输出线程处理batch N的同时,batch N+1已经在GPU上跑了,流水线建立完成。
这套模型在压测高并发时表现最明显:请求背后基本不断流,某个请求的响应延迟略有增加,但整体吞吐接近“GPU不空转”的极限。
4. 实操观察:如何确认三个线程在干什么
4.1 用系统命令先看线程数量和PID
进入容器或宿主机后,找到run_engine_core进程ID,用以下命令看线程:
ps -ef | grep run_engine_core ps -T -p <pid> top -H -p <pid>如果你用的是容器部署,注意容器内可能没有完整的ps,ps命令依赖procps包,缺少时可以先apt-get update && apt-get install -y procps。top -H可以实时看每个线程的CPU占用,这对定位某个线程是不是在空转或忙等非常有用。
4.2 用py-spy看Python线程调用栈
ps -T只能告诉你线程数量,不能告诉你线程在干什么。最直接的办法是py-spy。先安装:
pip install py-spy然后对目标进程做线程栈dump:
py-spy dump --pid <pid>输出里会列出每个Python线程当前正在执行的函数调用栈。正常稳定运行状态下,你大概率能看到某个线程停在Scheduler._schedule或Scheduler.schedule相关调用栈,一个线程停在ModelRunner.execute_model或AdapterModelRunner相关调用栈,还有一个线程停在output_processor相关的调用栈。这样三个线程的对应关系就一目了然了。
py-spy在生成环境下会短暂暂停进程,生产中如果业务敏感,建议先用小流量测试或者直接靠metrics判断,不到万不得已不要生产环境乱dump。
4.3 用日志和指标佐证线程状态
vLLM一些版本里可以开启更详细的日志:
export VLLM_LOG_LEVEL=DEBUG这样run_engine_core的step推进、调度决策时机都会被打印出来。另一个更稳的手段是让vLLM暴露Prometheus metrics。mmap指标里有几个跟调度和执行高度相关的计数,比如:
vllm:engine_generation_tokens_total:生成token总量,判断执行线程是否有产出vllm:engine_num_preemptions_total:抢占次数,调度线程压力的直接信号vllm:engine_time_to_first_token_seconds:首token延迟,能侧面反映调度线程排队情况
如果num_preemptions_total持续快速上涨,而生成token总量增长缓慢,那基本可以断定调度线程陷入了preemption风暴。
4.4 一个容易被忽略的前提:先确认镜像版本
三个线程的观察结果和vLLM版本强相关。老版本vLLM(比如0.3、0.4早期)的驱动循环还比较接近单线程串行风格,你去看run_engine_core进程里的线程,不一定能清清楚楚看到三个业务线程各自卡在三个调用栈上。如果你用的镜像是vllm/vllm-openai:v0.27.1这一代新镜像,三组件拆分的特征已经很明显,线程关系基本就是我上面描述的这套。部署大模型之前先确认镜像版本,可以避免拿着老版本的线程现象套用新版本逻辑,越查越懵。
5. 常见问题与排查实录
5.1 调度线程卡住:GPU利用率低但请求一大堆
现象:请求并发很高,但nvidia-smi里GPU利用率一直上不了60%,ps -T看线程耗时发现某个线程CPU占用极高。
这种场景我遇到过两次,一次是负载里混了大量超长prompt,另一次是KV Cache规划明显偏保守导致频繁preemption。preemption会让调度线程做大量额外计算:要找到被抢占的序列、把对应的KV Cache写回CPU、更新调度表。这一套动作走完,执行线程拿不到新batch,GPU只能空等。
排查思路:先看vllm:engine_num_preemptions_total指标有没有快速上涨,然后用py-spy dump看调度线程的调用栈是否长时间停在schedule相关函数里。
缓解手段:
- 给KV Cache预留更多空间,比如调大
gpu_memory_utilization(例如从0.85调到0.92),降低触发preemption的概率 - 检查
max_num_batched_tokens和max_num_seqs是否和实际负载匹配 - 如果长prompt是常态,考虑把
--preemption-mode配置调整好,或者减少max_model_len,让KV Cache能容纳更多并发序列
调度线程的设计目标本来就是轻量快进快出。一旦它变成瓶颈,GPU利用率必然下滑,这不是模型算力不够,是“排班”追不上“干活”。
5.2 输出处理线程堆积:GPU空转,显存却一直占满
现象:nvidia-smi里显存占用很高,但GPU计算利用率却在往下掉,响应延迟明显变大。输出处理线程的调用栈卡在process_outputs或者是采样结果解析相关的函数上。
输出处理线程堆积常见于高n值、高best_of的生成请求。一次请求背后挂着一堆候选序列,每个序列都要更新状态、维护堆结构、做beam search筛选,这些全在Python层跑,非常消耗CPU时间。如果这时候调度线程也在忙于preemption,两个线程互相抢GIL,情况会更糟:GPU跑完一个batch后迟迟拿不到下一个batch的输入。
缓解手段:
- 不要盲目调大单请求的
best_of和n,能用一个候选序列解决的不要开beam search - 降低batch token上限,给输出处理线程留出更多CPU时间片
- 如果确实是Python层输出处理太重,考虑换用带优化输出处理的新版本vLLM镜像,后续版本对这块有持续优化
5.3 GIL导致的三线程“假并行”问题
三个线程是拆了,但CPython的GIL仍在。调度线程和输出处理线程如果都是纯Python计算密集代码,它们实际上无法真正做到CPU并行,只能靠GIL按时间片切换。vLLM能把吞吐做上去,核心原因是模型执行线程在CUDA Kernel里释放了GIL,GPU真正在跑的时候,另外两个线程有机会在CPU上跑。但如果你把GPU换成一个极小模型,比如qwen3-embedding-0.6b这种小embedding模型,模型执行时间很短,GIL的切换反而可能成为开销大头,这时候三个线程的“排队感”会更明显。
这也是为什么小模型部署时,有时候加大并发反而收益不明显的潜在原因之一。模型太小,GPU还没跑几毫秒就跑完了,大量时间花在调度、输出处理这些Python操作和线程同步上。
5.4 部署场景的镜像选择和模型加载阶段线程表现
vllm/vllm-openai:v0.27.1加载qwen3-embedding-0.6b这类embedding模型时,这三个业务线程在后端推理阶段的表现会比纯生成模型更轻:因为embedding任务基本不需要逐token生成,采样和输出处理逻辑简单许多。真正重的线程压力往往出现在模型加载阶段,那时候跑模型加载的worker线程会并行工作,和后面的三个业务线程是两回事。
如果部署的是DeepSeek、GLM这类大模型,情况反过来:scheduler和output processor的线程开销会被模型decode时间掩盖掉,但KV Cache管理、preemption、prefill的调度策略会直接决定你能承载多少并发。GLM系列用户我在实践里给的建议是:优先用官方镜像版本,不要盲目追小版本号,因为不同小版本对模型结构的算子支持有差异。
5.5 线程卡死与死锁判断:怎么从容恢复
如果py-spy dump看到三个线程长时间各自卡着不动,且top -H显示各自CPU占用都很低,这是典型的死锁特征。vLLM本身的线程死锁在正常使用中不常见,但当你同时启用多个CUDA context、跑多进程数据并行(比如multiprocessing加载多个模型),线程之间可能因为CUDA context锁互相等待。
遇到这种现场,先别急着kill -9,可以按以下顺序处理:
- 抓
py-spy dump留证据,再看nvidia-smi确认是否有CUDA kernel疑似卡死 - 抓
pstack看原生线程栈,确认是不是卡在CUDA runtime的锁上 - 如果确认锁死,最稳的是直接重启推理容器,然后缩小并发重现范围,逐项排除是哪个自定义配置触发的
我自己的习惯是每次部署前固定录制一份正常状态下的py-spy dump存档,出问题时可对比调用栈差异,能省大量排查时间。
6. 最后分享一点实践经验
这几个线程的关系理清楚之后,再去看vLLM的部署和调参逻辑会顺很多。我自己调试时最常用的判断流程就是:先看GPU利用率,再确认平滑指标,最后用py-spy dump定位线程卡点。三者互相印证,基本能把性能问题归类到调度、执行、输出处理三个阶段中的某一环。
还有一个小技巧:压测时别只盯着首token延迟和总吞吐,顺手记一下每轮step的token产出速率和preemption计数。调度线程哪怕慢1ms,放大到成千上万次step里,GPU的空转时间都会被拉得很明显。反过来,输出处理线程的优化空间往往被你忽略,因为GPU利用率好看并不代表响应延迟合理。
最后,镜像版本升级这件事真的别偷懒。旧版本vLLM的单线程驱动在低并发场景下问题不明显,但一上压测、一上线多用户调用,线程拆分带来的吞吐差异立刻拉开。遇到性能问题,先看版本,再谈调参。按我上面的思路把三个线程的职责和协作关系吃透,再去看官方源码的效率会比以前高很多。