啃透vLLM源码:从PagedAttention到连续批处理,大模型推理加速24倍的秘密

如果说大模型是AI的“大脑”,那推理引擎就是让它“开口说话”的声带。vLLM是如何成为业界公认的推理加速之王的?本文带你手撕源码,揭开PagedAttention与连续批处理的神秘面纱。

引言:一个推理工程师的深夜崩溃

凌晨两点,钉钉群炸了:“线上推理服务超时率飙升到30%!”你打开监控,发现8张A100的GPU利用率只有15%,但延迟却高达5秒。你颤抖着点开nvidia-smi,显存占用95%,但实际处理请求数不到50个。

这,就是没有用vLLM之前的日常。

一、先看疗效:24倍吞吐量是怎么来的?

在正式开撕源码之前,先看一张官方测试表(数据基于Llama-7B,A100 80GB):

框架 吞吐量 (tokens/s) 显存利用率 最大并发
HuggingFace Transformers 112 35% 4
vLLM 2,688 92% 96

24倍。这不是PPT,是真实的生产数据。vLLM的秘密,就藏在下图这个架构里(文字描述):

[请求队列] → [调度器(Scheduler)] → [Worker(GPU执行)]
                    ↑                     ↓
            [Block管理器] ← [PagedAttention]

现在,我们一层层扒开它的衣服。

二、PagedAttention:向操作系统“偷师”的内存管理

2.1 传统KV Cache的“四合院”式浪费

在原生Transformer推理中,每个请求的KV Cache必须提前分配一整块连续内存,大小等于max_seq_len × num_layers × 2 × head_dim × num_heads。假设max_seq_len=2048,这就像一个住户住进一栋四合院——房子大得离谱,而且一旦住进去,即使你只用了其中两间房,剩下的房间也不能给别人用。

2.2 vLLM的“现代公寓”革命

vLLM把KV Cache切成固定大小的Block(默认16个token),通过逻辑块→物理块的映射,让每个请求只需占用实际需要的块数,且物理块可以任意分散。

源码验证vllm/block.pyPhysicalTokenBlockLogicalTokenBlock的实现:

class PhysicalTokenBlock:
    """实际的GPU内存块"""
    def __init__(self, device: Device, block_number: int, block_size: int):
        self.device = device
        self.block_number = block_number
        self.block_size = block_size
        self.ref_count = 0          # 引用计数,用于共享
        self.token_ids = [None] * block_size

class LogicalTokenBlock:
    """逻辑上的连续token序列"""
    def __init__(self, block_number: int, block_size: int):
        self.block_number = block_number
        self.token_ids = [_BLANK_TOKEN_ID] * block_size
        self.num_tokens = 0

    def append_tokens(self, token_ids: List[int]) -> None:
        curr_idx = self.num_tokens
        self.token_ids[curr_idx:curr_idx+len(token_ids)] = token_ids
        self.num_tokens += len(token_ids)

关键点PhysicalTokenBlockref_count——这意味着多个请求可以共享同一个物理块(比如共享前缀的prompt),这是传统缓存做不到的。

2.3 Block大小的“黄金分割”

Block太小(如4),管理开销大;太大(如256),内部碎片多。vLLM默认16,但可以通过环境变量VLLM_BLOCK_SIZE调整。我曾经在代码里看到过某公司为了节省显存,强行改成32,结果长文本生成时延迟飙升——因为block太大导致不能充分利用,反而浪费。

三、调度器:连续批处理的“指挥家”

传统批处理是等一车人坐满了才发车,发车后即使有人下车,车也不能停下来接新人。vLLM的连续批处理(Continuous Batching)则像地铁:每站都有人上下,车门永远不关。

调度核心在vllm/core/scheduler.pyschedule()方法中:

def schedule(self) -> SchedulerOutputs:
    # 1. 从等待队列中挑选可调度的请求
    scheduled_seq_groups = []
    for seq_group in self.waiting:
        # 检查是否有足够的block
        if self.block_manager.can_allocate(seq_group):
            self.block_manager.allocate(seq_group)
            scheduled_seq_groups.append(seq_group)
        else:
            break  # 显存不足,停止调度

    # 2. 将正在运行的请求加入本次迭代
    for seq_group in self.running:
        # 如果请求已经完成,释放其block,并加入finished队列
        if seq_group.is_finished():
            self.block_manager.free(seq_group)
            self.finished.append(seq_group)
        else:
            scheduled_seq_groups.append(seq_group)

    # 3. 生成SchedulerOutputs,交给Worker执行
    return SchedulerOutputs(
        scheduled_seq_groups=scheduled_seq_groups,
        blocks_to_swap_in=...,
        blocks_to_swap_out=...,
        ...
    )

注意这里的blocks_to_swap_out——当显存不够时,vLLM会把部分block swap到CPU内存,等需要时再换回来,这就是vLLM支持超长上下文的关键(虽然会慢一点,但不会OOM)。

四、Worker执行:CUDA Graph与Triton的加持

当调度器决定好本次迭代要执行的请求后,Worker会调用execute_model()。其中最关键的是CUDA Graph的运用:

# vllm/worker/model_runner.py
class ModelRunner:
    def __init__(self, ...):
        self.cuda_graph_memory_pool = None

    def capture_model(self, kv_caches):
        # 预热
        for _ in range(2):
            self._run_model(...)
        # 捕获
        self.graph = torch.cuda.CUDAGraph()
        with torch.cuda.graph(self.graph, pool=self.cuda_graph_memory_pool):
            self._run_model(...)

CUDA Graph把一系列GPU操作打包成一个“图形”,一次性提交给GPU,避免了每次迭代的kernel launch开销。对于小batch size场景,这个优化能带来30%以上的性能提升

五、实战:从部署到调优的“血泪史”

5.1 最简单的部署命令
python -m vllm.entrypoints.openai.api_server \
    --model meta-llama/Llama-2-7b-chat-hf \
    --tensor-parallel-size 4 \
    --dtype float16 \
    --max-model-len 4096 \
    --gpu-memory-utilization 0.9
5.2 踩坑1:量化导致精度下降

vLLM原生支持GPTQ和AWQ,但如果你用的是FP16模型,直接加--quantization gptq会报错。正确做法是先用AutoGPTQ量化模型,再加载。

5.3 踩坑2:动态batching与流式输出

很多同学用curl测试时发现vLLM没有流式输出,以为卡住了。其实vLLM默认开启流式,但curl不会实时打印。正确的测试姿势是:

from vllm import LLM, SamplingParams

llm = LLM(model="your-model")
params = SamplingParams(max_tokens=100)
outputs = llm.generate(["Hello"], params, use_tqdm=False)

for output in outputs:
    for token in output.outputs[0].text:
        print(token, end='', flush=True)

六、总结与展望

vLLM的成功,本质上是系统思维算法思维的一次降维打击。它没有发明新的模型结构,只是用更好的内存管理和调度,把现有硬件的潜力榨干到极致。

未来,随着PagedAttention v2(支持多轮对话的显存共享)、异步调度等新特性的引入,vLLM的统治地位还会持续很久。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐