ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

VLLM 0.7.3源码精读:PagedAttention与连续批处理如何提升吞吐

VLLM 0.7.3源码精读:PagedAttention与连续批处理如何提升吞吐

简介:VLLM-0.7.3源码完整工程包,面向大模型推理引擎开发者、算法工程师与进阶研究者,旨在解决超大规模语言模型推理阶段的高并发响应与显存瓶颈问题。资源共1896个文件,压缩包约46.33MB,以Python源码(1186个)、CUDA/C++核心实现、JSON/YAML配置和Markdown文档为主,另含构建脚本、Docker部署文件等;从底层算子到上层接口均有覆盖,目录结构清晰,便于按模块阅读与二次编译。当前已有342人学习下载,属于热度持续上升的优质源码资源。通过这份源码,可系统查看VLLM的高速令牌生成、高效内存管理等关键实现,理解从模型加载到推理输出的完整链路;同时工程内附带的测试脚本、工具配置和示例文件,也为后续性能调优、框架对比与二次开发提供了扎实基础。对想深入掌握大模型推理加速工程的读者来说,是一份难得的完整参考。

1. 为什么 0.7.3 值得逐行看:VLLM 源码里藏着吞吐量差距的答案

同一张卡上跑同一个 7B 模型,有人能做到每秒四千个 token,有人只能卡在一千出头。差距往往不在硬件,而在推理框架怎么管显存和调度。VLLM 0.7.3 源码就是把这件事讲清楚的一条路径:它用 PagedAttention 把 KV Cache 拆成物理块,用连续批处理填满每一轮生成间隙,再用张量并行把大模型拆到多卡上。这一代版本处于相对稳定的中期,保留了早期架构的核心机制,又还没背上太多实验性开关,适合逐行精读。目标读者是做推理服务优化、想把手头的生成延迟和吞吐瓶颈真正解决的从业者。这篇文章从源码地图一路拆到调度器,再给出复现和排错路径,照着读就能少走弯路。

2. 从入口类到 Worker 进程:VLLM 0.7.3 的源码地图与启动链路

2.1 源码目录怎么读:先分清引擎层、执行层和模型层

VLLM 0.7.3 的源码体积不大,但分层非常规整。我第一次读的时候按模型推理的直觉去找 forward 函数,结果被目录结构绕了一圈。后来摸出的顺序是:从入口往内层走,依次看引擎层、执行层、模型层。引擎层负责请求生命周期和调度,执行层负责把任务分发到单卡或多卡,模型层才真正管算子和权重布局。

引擎层里,对外接口和内部循环是分开实现的。对外接口提供同步的 LLM 类和异步的客户端封装,内部核心则维护调度器、缓存配置和 Worker 池。执行层会区分单卡和多卡两套实现,多卡时额外做通信组初始化和张量切分。模型层按模型类型和算子类型组织文件,PagedAttention 的实现、量化实现都在这一层,找算子时直接往这里走。

我一般建议按问题驱动读代码:你现在被什么问题卡住,就定位到对应层。看不懂调度就只读调度器类,不碰模型算子;看不懂显存就只读缓存引擎和块管理器。这套读法比从头到尾通读省时间,而且每层读完都能直接对应到一个线上问题。读的时候保持一个习惯:把每个类里的init先读一遍,它决定了这个类的全部状态从哪来。

2.2 启动一条推理请求:LLM 到 EngineCore 的完整调用链

# vllm 0.7.3 客户端最小启动脚本 from vllm import LLM, SamplingParams llm = LLM( model="/data/models/qwen2.5-7b-instruct", # 换成你本地可访问的模型路径 tensor_parallel_size=1, # 单卡时保持 1,多卡时改成卡数 gpu_memory_utilization=0.85, # 分配给 KV Cache 和中间张量的显存比例 max_model_len=8192, # 与模型 config 里的最大位置编码对齐 ) params = SamplingParams( temperature=0.7, top_p=0.9, max_tokens=256, ) out = llm.generate(["请解释什么是 KV Cache"], params) print(out[0].outputs[0].text)

这段代码是 0.7.3 对外接口的标准用法。gpu_memory_utilization 不是越高越好,我习惯留出 0.1 到 0.15 给驱动、通信库和临时张量,否则跑长序列时容易直接 OOM。max_model_len 如果超过模型原生长度,启动时日志会给出警告,但不同版本处理方式不一样,有的直接截断,有的报错,读源码时值得注意。

从 generate 方法进去,调用路径大致是:LLM.generate 把请求交给引擎核心,引擎核心先做 tokenizer 编码,再进入调度循环。调度器根据显存余量决定哪些序列可以进入本次迭代,然后交给 Worker 线程完成真正的 forward 计算。输出再沿着同一条路径返回,每一层都有对应的日志关键字。

把日志级别调到 DEBUG 再跑一次,看到的日志顺序就是这条调用链的最直观证明。从接收到请求、分配 KV Cache 块、执行 prefill、逐个 decode,到返回结果,每一步都有打印点。以后排查性能问题,先看日志里的调度阶段在哪一步耗时最长,而不是直接怀疑算子。

2.3 VLLM 0.7.3 里值得精读的 5 个核心类

类名职责读源码时关注点
LLM对外同步入口,负责 tokenizer 和结果组装generate 方法如何拆解批请求
EngineCore引擎核心,持有调度器和缓存配置init里各配置如何固化
Scheduler迭代级调度,决定谁进本轮计算三个序列队列如何流转
Worker实际执行 forward 的进程/线程如何加载权重和调用模型运行器
ModelRunner把输入批次喂给模型的执行器如何构造输入张量、调用模型 forward

这五个类正好对应五个线上问题。生成速度慢就查 Scheduler 和 EngineCore 的调度参数;显存不足就查 EngineCore 里的缓存配置怎么传给 Worker;多卡行为不一致就查 Worker 之间的通信初始化。我读的时候按 LLM 到 EngineCore 到 Scheduler 再到 Worker 的顺序,ModelRunner 放在最后,因为它依赖前面几个类传入的张量布局。

把这五个类理清之后,你会发现自己已经能看懂 0.7.3 大部分日志输出了。比如启动时打印的 KV Cache 分配大小来自缓存配置计算,运行时打印的吞吐数据来自调度器的迭代统计。这些不是黑匣子,只是以前没有按链路拆开看。

3. PagedAttention 与显存调度:0.7.3 的 KV Cache 管理是怎么做的

3.1 从块表分配看 PagedAttention:物理块不连续也不慌

传统推理框架为每个请求预分配一整段连续显存,请求多了就互相挤占,碎片严重。PagedAttention 的思路来自操作系统的分页机制:把 KV Cache 切成固定大小的块,物理块散落在显存各个位置,通过块表把逻辑视图映射到物理块。请求不再需要一整段连续空间,显存利用率因此提升。

VLLM 0.7.3 里,块大小由 block_size 参数控制,默认是 16,也就是每个块能存放 16 个 token 位置的 KV 值。块表的实现逻辑可以用下面这个简化模型理解,实际源码中还要处理空闲块池、引用计数和写时复制:

# 模拟 PagedAttention 的逻辑块表结构 class BlockTable: def __init__(self, block_size: int = 16): self.block_size = block_size self.logical_blocks = [] # 逻辑块编号列表 self.block_cache = {} # 逻辑块编号 -> 物理块编号 def allocate(self, num_tokens: int): needed = (num_tokens + self.block_size - 1) // self.block_size for _ in range(needed): logical_id = len(self.logical_blocks) physical_id = allocator.free_pool.pop() # 从空闲物理块池取 self.logical_blocks.append(logical_id) self.block_cache[logical_id] = physical_id return self.block_cache

这段代码演示了块表的两级映射。allocate 方法先计算需要的块数,再为每个逻辑块分配一个物理块。实际源码里不会直接操作 free_pool,而是通过块分配器统一管理,但核心思想一致。block_size 越大,块表映射开销越小,但长序列尾部浪费的空间也越大;block_size 越小,显存碎片越少,但块表条目更多。

注意:修改 block_size 后必须重启进程,引擎启动时缓存配置已经按旧值完成初始化,运行期修改不生效。

读源码时重点看块分配器如何回收块、如何判断块是否空闲。有个细节值得留意:新请求接入时,调度器会优先复用已有序列组释放的物理块,而不是每次都向 CUDA 申请新显存,这也是 0.7.3 在持续对话场景下延迟表现稳定的原因之一。

3.2 连续批处理调度器:不再等整批结束才释放显存

没有连续批处理的框架里,一批请求必须同时完成才能一起释放显存,慢的请求会拖住快的请求。VLLM 0.7.3 的调度器在每个解码迭代结束后都重新做一次决策:新请求可以插入、完成的请求立刻退出、超预算的请求被暂时换出。这个机制通常被称为 Continuous Batching,是吞吐提升的关键。

调度器内部维护三类序列集合:等待运行的、正在运行的、被换出的。每次迭代开始时,调度器优先把等待队列里的请求塞进运行集合,但要满足显存预算上限。吞吐和延迟的平衡主要通过 max_num_seqs 和 max_num_batched_tokens 控制,后者限制了单次迭代最多能处理的 token 总数。

# 调度决策的简化逻辑 def schedule(self): # 1. 先处理上一轮完成的请求,释放它们的物理块 for seq in self.running: if seq.is_finished(): self.running.remove(seq) self.block_manager.free(seq.block_table) # 2. 尝试从等待队列加入新请求 while self.waiting and len(self.running) < self.max_num_seqs: new_seq = self.waiting.pop(0) if self.block_manager.can_allocate(new_seq): self.running.append(new_seq) self.block_manager.allocate(new_seq) else: self.swapped.append(new_seq) # 显存不足就先放一边

这段逻辑说明调度器不是简单地先来先服务。它先释放已完成请求的资源,再评估等待队列能否接入,接入条件是显存块够不够。注意 can_allocate 和 allocate 是两步,先检查后分配,避免分配失败留下脏状态。我把 max_num_seqs 调大后观察过,等待队列很快排空,但单次迭代计算时间也在上升,因为更多序列挤在同一轮 forward 里。

连续批处理的另一个关键是 prefill 和 decode 拆开。新请求需要先做 prefill 生成首 token,已有请求继续做 decode。0.7.3 的调度器会把这两类请求合并到同一次迭代计算,但内部的注意力掩码不同。这个设计让 GPU 在每一轮都尽量满负荷运转,而不是等整批请求同步推进。

3.3 这代显存分配参数怎么配:四个数的搭配逻辑

参数默认值调大效果调小效果
gpu_memory_utilization0.9KV Cache 更多,并发更高留给临时张量空间更足,不易 OOM
block_size16块表更短,映射开销低尾部浪费少,碎片更少
max_num_seqs256单轮并发高,吞吐上升延迟更稳定,显存压力小
max_num_batched_tokens2048单次 forward 计算量增大调度更频繁,时延更平滑

这四个参数不是独立调优的。max_num_seqs 乘上序列平均长度决定了活跃 token 总量,这个总量必须小于 KV Cache 能容纳的块数。如果 max_num_seqs 设得太大,调度器会频繁把序列换出换入,显存和计算都花在搬运上,吞吐反而下降。我一般先用默认值跑通,再按线上请求长度倒推这几个数。

有一种流量场景值得单独说:长上下文但并发低。此时 max_num_seqs 不需要大,倒是 max_model_len 和 block_size 的组合更关键,因为长序列会持续占用大量块。如果尾部 token 使用量不固定,把 block_size 调小能减少浪费,比如从 16 降到 8。反之,短请求为主的场景就不要动 block_size,默认值已经足够。

4. 把源码跑出一条可观测路径:单卡最小复现与双卡张量并行

4.1 从源码构建可调试环境:依赖组合与最小安装

读源码最怕的就是边读边猜,跑不起来就全白读。我建议先在本机构建一个可调试环境,不用多卡集群,一张卡足够跑通全链路。VLLM 0.7.3 对 Python 版本有要求,我用 3.10 或 3.11 都跑过,配合 CUDA 12.x 的 PyTorch 版本最省事。装完基础环境后,从源码安装而不是直接装发布包,这样能随时跳进源码加打印。

# 从源码构建 vllm 0.7.3 的最小环境 conda create -n vllm-dev python=3.11 -y conda activate vllm-dev # 安装 PyTorch,这里用 cu124 作为示例,实际按你驱动版本选 pip install torch --index-url https://download.pytorch.org/whl/cu124 # 克隆源码并进入目录,安装可编辑模式 git clone <你的 vllm-0.7.3 源码仓库地址> cd vllm pip install -e . --no-build-isolation

安装过程中最容易出问题的是编译算子,核心是 flash-attention 或 xformers 与 CUDA 版本的匹配。如果编译报错,先检查 nvcc 版本和 torch.version.cuda 是否一致,不一致时以 PyTorch 自带的 CUDA runtime 为准。pip install -e . 会先把 Python 包装好,再编译 C++ 和 CUDA 算子,这个过程可能需要十几分钟,属于正常现象。

装完后验证环境是否就绪,直接导入再跑一次帮助命令,然后加载一个很小参数的模型先做 smoke test,比如用一个几百 MB 的对话模型。这一步能过滤掉九成环境问题,后面再调大模型就会顺畅很多。

4.2 单卡跑通最小生成:验证 KV Cache 确实被分配

# 单卡最小生成脚本 from vllm import LLM, SamplingParams llm = LLM( model="/data/models/qwen2.5-7b-instruct", tensor_parallel_size=1, gpu_memory_utilization=0.8, max_model_len=4096, enable_prefix_caching=True, # 开启前缀缓存,方便观察块复用 ) params = SamplingParams(max_tokens=32) out = llm.generate(["给我写一句欢迎语"], params) print(out[0].outputs[0].text)

跑这段脚本时,重点不是输出内容,而是终端里的启动日志。VLLM 0.7.3 启动时会打印模型加载信息、张量并行配置和 KV Cache 的分配结果。你会在日志里看到类似“KV Cache size”的描述,那串数字就是这一轮运行时实际分给缓存块的显存大小,记下来,继续读源码时能对上号。

验证 KV Cache 是否生效的简单办法是对比加载模型前后的显存占用。先跑一次上面的脚本,记录 nvidia-smi 显示的显存占用,再退出进程重新加载模型但不跑 generate,比较两次占用的差值。多出来的部分就是 PagedAttention 为 KV Cache 预留的块池。这个差值应该接近 gpu_memory_utilization 乘以总显存再减去模型权重占用。

如果跑完发现显存占用远高于预期,常见原因是 max_model_len 设得太大,导致块池预分配过大。0.7.3 里 KV Cache 的容量跟 max_model_len 直接相关,最大长度翻倍,块池通常也会翻倍。读代码时注意看容量计算逻辑,它把 max_model_len、max_num_seqs 和 block_size 三者乘在一起,你就明白为什么改一个参数会牵动另外两个了。

4.3 双卡张量并行:观察 TP=2 时显存与算子切分

# 双卡张量并行启动脚本 from vllm import LLM, SamplingParams llm = LLM( model="/data/models/deepseek-vl-7b", # 用一个支持多卡的模型 tensor_parallel_size=2, # 两块卡并行 gpu_memory_utilization=0.85, max_model_len=8192, ) params = SamplingParams(max_tokens=64) out = llm.generate(["用两句话介绍张量并行"], params) print(out[0].outputs[0].text)

tensor_parallel_size=2 时,模型权重会被按张量维度切分到两张卡上。以 QKV 投影矩阵为例,常见切法是把输出维度按 head 数均分,每张卡各持有半条权重,前向计算时需要跨卡做一次通信同步结果。你现在可以在另一终端跑 nvidia-smi 观察两张卡的显存占用,会发现两边几乎相等,这是正常的,不是加载错误。

要确认两层 Worker 各拿到了半条权重,可以看启动日志里的权重加载信息,通常每个 worker 进程都会打印自己加载的模型分片信息。如果两边输入路径和 env 变量一致,它们拿到的模型前缀就相同,差异只在权重数值。继续读源码时,重点看模型并行层如何把 QKV 权重切分后再喂给 PagedAttention 的算子。

注意:双卡并行跑通只是第一步,真正的问题排查往往发生在通信环节。如果出现结果不一致或卡死,先看 NCCL 初始化是否完成,再看是否有自定义算子漏掉了通信调用。

5. 读 VLLM 0.7.3 源码时最容易踩的 5 个坑

5.1 改了调度参数不生效

现象:在脚本里修改 max_num_seqs 或 gpu_memory_utilization 后,启动日志里打印的配置值没有变化,吞吐表现也和默认值一致。

原因:引擎核心在初始化阶段就把这些配置固化成内部状态了。后面对配置对象做修改,不会重新触发缓存池分配和调度器初始化。很多人以为改完参数再调用 generate 就能生效,实际上日志里那行配置在进程启动瞬间就已经定死。

解决:改完任何调度或显存参数,一定要重启进程。想确认参数是否生效,直接看启动日志里引擎配置那一段,不要只看代码里的传参。我习惯把关键配置打到启动日志里,这样每次调参都能对照。

# 启动时确认配置生效 llm = LLM(model=..., max_num_seqs=128) print(llm.llm_engine.scheduler.max_num_seqs) # 通过调度器实例读取实际值

5.2 KV Cache 显存碎片化导致 OOM

现象:nvidia-smi 显示显存只用了七成,但继续发请求却报 CUDA OOM。接着看日志,KV Cache 分配的盒子大小正常,但新请求仍无法分配连续块。

原因:block_size 和 max_num_seqs 搭配不合理,导致物理块池被切割成无法合并的小块。PagedAttention 的块表虽然允许物理块不连续,但块分配器本身需要从空闲块里找到足够数量,碎片化严重时单个请求申请多个块会失败。

解决:把 block_size 调小、把 max_num_seqs 调小。先用默认值跑通,再逐步压缩。我踩过一次坑,把 max_num_seqs 拉到 512,结果短请求把块池拆得七零八落,长请求全被拒绝。后来按长度聚类流量,把并发控制在 128 以内就好了。

5.3 KV Cache 精度下降导致输出退化

现象:同样 prompt 和采样参数,长上下文对话越往后越重复,甚至开始输出循环内容。对比 FP16 与 FP32 缓存的结果,差异明显。

原因:KV Cache 默认按低精度存储来省显存。序列越长,误差累积越明显。0.7.3 里缓存数据类型可以通过配置指定,很多人没设置,就沿用默认策略,精度不符预期。

解决:在配置里显式设置 kv_cache_dtype,比如用 FP8 存储 KV Cache,同时保留输出端的高精度计算。改完对比同一批 prompt 的生成分布,如果循环现象缓解,就是精度问题。此类问题适合量化敏感的模型,纯小模型影响不大。

5.4 多卡并行时状态不同步

现象:tensor_parallel_size=2 跑起来后,生成结果时对时错。同一个 prompt 有时输出正常,有时直接报错或出现乱码。

原因:自定义算子或者较新的模型结构在 forward 里漏掉了跨卡通信。张量并行要求每张卡的计算结果在进入下一层之前做一次全局规约,漏掉这一步,后续张量布局就对不齐。

解决:看报错堆栈是不是卡在模型算子和通信原语之间,重点检查模型代码里是否有 all_reduce 之类的调用。我一般先把 tensor_parallel_size 改回 1 跑同一段代码,确认单卡正常,再逐步排查多卡。如果算子是自己写的,先对比每个 worker 的中间张量形状是否一致。

5.5 换小版本后算子行为漂移

现象:同一份代码,从更早版本升到 0.7.3 后,生成结果发生变化,但配置完全没变。

原因:某些算子的默认实现被切换,比如 attention 从高效注意力实现换到另一套算子,计算顺序变化导致浮点结果有细微差异。这种差异通常在采样时被放大,表现为输出文本不同。

解决:固定镜像版本,记录算子派发日志。0.7.3 的算子选择逻辑里有 fallback 机制,日志会打印实际用了哪个算子。对比两个版本各自的派发日志,就能定位差异源头。别小看这个坑,线上模型如果依赖稳定的采样结果,版本升级前必须做输出一致性校验。

6. 给 VLLM 0.7.3 加一条调度日志:最小可验证的调试习惯

6.1 找到调度器的决策点

读了一个星期源码,你可能已经知道调度器在每次迭代都会做决策,但看不到它实际挑中了谁、跳过了谁。我习惯在调度方法入口挂一条打点,用 monkey patch 的方式在导入后替换方法,不用改动源码本体。

# 在调用 vllm 之前挂载补丁 from vllm.core.scheduler import Scheduler _original_schedule = Scheduler.schedule def patched_schedule(self, *args, **kwargs): result = _original_schedule(self, *args, **kwargs) # result 里包含调度决策后的序列组列表 for seq_group in result: print( f"[schedule] seq_id={seq_group.seq_id} " f"state={seq_group.state} " f"block_num={len(seq_group.block_table)}" ) return result Scheduler.schedule = patched_schedule

补丁的原理很简单:先调用原始调度方法,再遍历调度结果,把每个序列组的核心字段打印出来。state 字段能看到是运行中还是换出,block_num 能看到当前占用的物理块数量。这条日志能直接回答两个问题:请求是不是一直在等待队列里,以及显存是不是实际分到了块。

验证方法:跑一个并发请求的脚本,对比终端日志顺序和最终吞吐数据。如果某个请求一直打不到 state=running,说明调度策略把它卡住了;如果 block_num 频繁变化,说明块表在反复分配释放。这个办法帮我解决过一次高并发下的请求饥饿问题,只靠源码阅读反而容易漏掉。

我自己读源码的习惯是:能加打印就不靠猜,测过的路径才算数。远程环境改了源码不方便重编译,monkey patch 是最后一道后悔药。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进