2025-10-26 12:03 北京
大量示例,大量代码,大量架构图。
选自Aleksa Gordic博客
机器之心编译
编辑:冷猫
- 博客标题:Inside vLLM: Anatomy of a High-Throughput LLM Inference System博客链接:https://www.aleksagordic.com/blog/vllm
- 推理引擎流程基础:包括输入 / 输出请求处理、调度(scheduling)、分页注意力(paged attention)、连续批处理(continuous batching)。「高级」功能:分块预填充(chunked prefill)、前缀缓存(prefix caching)、引导式解码(guided decoding,基于语法约束的有限状态机 FSM)、推测解码(speculative decoding)、解耦的 P/D(prefill/decoding)。扩展能力:从可以在单 GPU 上托管的小型模型,到参数量超过万亿的超大模型(通过张量并行 TP、流水线并行 PP、序列并行 SP 实现),最终扩展为多 GPU、多节点的部署方案。Web 端部署与服务:从离线部署,到多个 API 服务器的在线服务;再到负载均衡(load balancing)、数据并行(DP)协调器,以及多引擎(multiple engines)部署架构。推理系统性能测量:包括延迟(latency,涵盖首 token 时间 TTFT、迭代延迟 ITL、端到端 e2e、吞吐时间 TPOT)、吞吐量(throughput),以及 GPU 性能屋顶线模型(roofline model)。
from vllm import LLM, SamplingParamsprompts ="Hello, my name is","The president of the United States is",]sampling_params = SamplingParams(temperature=0.8, top_p=0.95)def main():llm = LLM(model="TinyLlama/TinyLlama-1.1B-Chat-v1.0")outputs = llm.generate(prompts, sampling_params)if __name__ == "__main__":main()
这个配置的特点是:环境变量:
VLLM_USE_V1="1" # we're using engine V1VLLM_ENABLE_V1_MULTIPROCESSING="0" # we're running in a single process
- 离线:没有 Web 或分布式系统的支撑。同步:所有执行都发生在一个单一的阻塞进程中。单 GPU:没有数据并行、模型并行、流水线并行或专家并行(DP/TP/PP/EP = 1)。使用标准 Transformer:如果要支持像 Jamba 这样的混合模型,就需要一个更复杂的 混合 KV-Cache 内存分配器。
- vLLM 配置(vLLM config):包含模型、缓存、并行等所有可调参数。处理器(processor):将原始输入转化为 EngineCoreRequests,过程包括校验、分词(tokenization)和预处理。引擎核心客户端(engine core client):在我们的示例中使用的是 InprocClient,它基本上等同于 EngineCore;后面我们会逐步扩展到 DPLBAsyncMPClient,它能支持大规模推理服务。输出处理器(output processor):将原始 EngineCoreOutputs 转化为用户最终可见的 RequestOutput。
- 模型执行器(Model Executor):负责驱动模型的前向计算。当前我们使用的是 UniProcExecutor,它只在单 GPU 上运行一个工作进程(Worker)。后面我们会逐步扩展到 MultiProcExecutor,以支持多 GPU。结构化输出管理器(Structured Output Manager):用于 引导式解码(guided decoding),后面章节会详细讲解。调度器(Scheduler):决定哪些请求进入下一步的引擎执行。它内部进一步包含:
在 模型执行器(Model Executor) 的构造过程中,会创建一个 Worker 对象,并执行三个关键步骤。(后续使用 MultiProcExecutor 时,这些步骤会在不同 GPU 上的每个工作进程中独立执行。)1. 初始化设备(Init device)标准 Transformer 层(非 MLA)的块大小(block size)计算公式如下:
2 * block_size (default=16) * num_kv_heads * head_size * dtype_num_bytes (2 for bf16)
- 分配一个 CUDA 设备(例如 "cuda:0")给 Worker,并检查模型的数据类型(dtype)是否受支持(例如 bf16)。根据设定的 gpu_memory_utilization(例如 0.8 → 使用 80% 的显存),验证是否有足够的显存可用。设置分布式参数(数据并行 DP、张量并行 TP、流水线并行 PP、专家并行 EP 等)。实例化一个 model_runner(包含采样器、KV 缓存、以及前向计算所需的 buffer,如 input_ids、positions 等)。实例化一个 InputBatch 对象(包含 CPU 侧的前向计算 buffer、用于 KV 缓存索引的块表 block tables、采样元数据等)。
- 实例化模型结构。加载模型权重。调用 model.eval ()(PyTorch 推理模式)。(可选)对模型调用 torch.compile ()。
- 获取每一层的 KV 缓存规格。历史上通常是 FullAttentionSpec(同质 Transformer),但在混合模型(如滑动窗口、Transformer/SSM 混合模型 Jamba)中会更复杂(参考 Jenga [5])。运行一次虚拟 / 性能分析前向计算,并截取显存快照,用于计算可容纳的 KV 缓存块数量。分配、重塑并将 KV 缓存张量绑定到注意力层。准备注意力元数据(例如指定后端为 FlashAttention),在前向计算时由内核调用。如果没有传入 --enforce-eager,则会对每个预热(warmup)批大小运行一次虚拟推理,并捕获 CUDA 图(CUDA graphs)。CUDA 图会记录整个 GPU 工作流程形成一个有向无环图(DAG)。在后续的前向计算中,系统会直接复用这些「预编译」的图,避免重复的内核启动开销,从而降低延迟。
- 若为 FCFS(先来先服务) 策略,则采用追加(append);
- 若为 优先级调度,则采用堆插入(heap-push)。
之后,只要仍有请求需要处理,引擎就会反复调用 step () 函数。每个 step 包含三个阶段:1. 调度(Schedule):选择本步骤要执行的请求(可能是解码 decode,或 / 和分块预填充 chunked prefill)。2. 前向传播(Forward pass):运行模型并采样新 token。3. 后处理(Postprocess):由于前向传播(forward pass)会将批次展平成一个单一序列,并且自定义内核可以高效处理,所以即便是同步引擎,连续批处理 也是在底层得到支持的。
- 将采样得到的 token IDs 附加到对应的请求上;进行去分词(detokenize);检查停止条件(stop conditions)。如果请求完成,执行清理操作(例如将其 KV 缓存块归还到 free_block_queue),并提前返回输出。
停止条件(Stop conditions)
- 请求超出了长度限制(max_model_length 或该请求的 max_tokens)。- 采样到的 token 是 EOS ID(除非启用了 ignore_eos —— 在基准测试时强制生成指定数量的输出 token 时很有用)。- 采样到的 token 匹配了采样参数中指定的 stop_token_ids。- 输出文本中出现了 stop string:此时我们会将输出截断到首次出现 stop string 的位置,并在引擎中终止该请求。注意:stop_token_ids 会出现在最终输出中,而 stop string 不会。
- 从 input_batch 中剪枝已完成的请求。更新与前向传播相关的元数据(例如:每个请求对应的 KV-cache 块数量,用于索引到分块的 KV-cache 内存)。
- 将输入缓冲区从 CPU 拷贝到 GPU。计算位置索引。构建 slot_mapping(后续示例会讲)。构造注意力元数据(attention metadata)。
- 使用自定义 paged attention 内核运行模型。所有序列会被 展平并拼接成一个长的 「超级序列」 (super sequence)。通过位置索引和注意力 mask 确保每个序列 只关注自己的 token,这样就能在 不使用右填充 (right-padding) 的情况下实现 连续批处理 (continuous batching)。
- 提取每个序列在其 最后位置 的隐藏状态 (hidden states)。计算 logits。
- 根据采样配置(greedy、temperature、top-p、top-k 等)从 logits 中采样出下一个 token。
前缀缓存的核心思想是:避免重复计算多个提示词(prompt)在开头部分共享的 token —— 这就是「前缀」的由来。关键点在于 long_prefix:它被定义为长度超过一个 KV-cache 块的前缀(默认一个块是 16 个 token)。为了简化例子,我们假设 long_prefix 的长度刚好等于 n × block_size(其中 n ≥ 1)。也就是说,前缀和块的边界完全对齐。否则,系统就需要重新计算 long_prefix_len % block_size 这些 token,因为我们无法缓存不完整的块。如果没有前缀缓存,每次处理一个拥有相同 long_prefix 的新请求时,系统都会重复计算全部 n × block_size 个 token。而启用前缀缓存后,这些 token 只需计算一次(它们的 KV 会被存储在 KV 缓存的分页内存里),之后就能直接复用。这样,系统只需要处理新的提示词 token,从而显著加快预填充请求(prefill request)的速度(不过对解码请求没有帮助)。在 vLLM 中,它是如何工作的?在第一次调用 generate 时,在调度阶段(scheduling stage),kv_cache_manager.get_computed_blocks 会调用 hash_request_tokens:1. 分块:该函数会把 long_prefix + prompts [0] 拆分为若干个 16-token 的块。2. 计算哈希:from vllm import LLM, SamplingParamslong_prefix = "<a piece of text that is encoded into more than block_size tokens>"prompts ="Hello, my name is","The president of the United States is",]sampling_params = SamplingParams(temperature=0.8, top_p=0.95)def main():llm = LLM(model="TinyLlama/TinyLlama-1.1B-Chat-v1.0")outputs = llm.generate(long_prefix + prompts[0], sampling_params)outputs = llm.generate(long_prefix + prompts[1], sampling_params)if __name__ == "__main__":main()
- 对每个完整的块计算一个哈希值(可使用内置哈希,或者更慢但碰撞率更低的 SHA-256)。这个哈希由以下部分组合而成:前一个块的哈希值、当前块的 token 以及可选元数据。可选元数据包括:多模态哈希(MM hash)、LoRA ID、缓存盐值(cache salt,注入到第一个块的哈希中,用来确保只有带相同 cache salt 的请求才能复用该块)。
- 这个函数会把新的 BlockHash 条目 与分配到的 KV 块(KV blocks)关联起来。同时,它会将这些映射记录在 cached_block_hash_to_block 中。
在第二次调用 generate 时,如果使用相同的前缀(prefix):经过多次引擎步骤之后,系统可能会分配更多的 KV 缓存块,但这对我们当前的例子没有影响,因为在 long_prefix 之后,提示词立即发生了分支(diverge),不再复用之前的前缀块。
- 步骤 1 到 3 会重复执行。但是此时,find_longest_cache_hit 会在 n 个块 中找到全部匹配(通过线性搜索)。引擎可以直接复用这些 KV 块,无需重新计算前缀部分的 token。
总结一下前缀缓存(Prefix Caching) 的核心思想:不要重复计算已经出现过的前缀 token —— 直接复用它们在 KV 缓存中的值即可!如果你理解了这个例子,也就理解了分页注意力(paged attention)的工作原理。引导式解码( FSM)引导式解码是一种技术:在每一步解码(decoding step)时,logits 会受到基于语法的有限状态机(finite state machine, FSM)约束。这确保了只有符合语法规则的 token 才能被采样。这个机制非常强大:高级说明:
KV-cache 块只有在即将 从 free_block_queue 重新分配 时才会被标记为无效(free_block_queue 是从左侧 pop 块)。如果此时发现块仍然有相关的哈希值,并存在于 cached_block_hash_to_block 中,那么我们会:清除该块的哈希值;从 cached_block_hash_to_block 中移除对应条目。这样可以确保该块无法通过前缀缓存再次复用(至少不能用于旧前缀)。
- 可以强制遵循 正规文法(Chomsky Type-3,例如任意正则表达式模式)。也可以支持 上下文无关文法(Context-Free Grammar, Type-2,覆盖大多数编程语言)。
在我给出的示例(假设使用字符级 tokenization)中:在 prefill 阶段,FSM 会对 logits 进行掩码(mask),确保只有 "P" 或 "N" 是可选的。如果采样到了 "P",FSM 就会切换到 「Positive」 分支;在下一步,FSM 只允许 "o" 被采样,以此类推。FSM 示例在 vLLM 中的实现方式:1. 在 LLM 引擎构建阶段,会创建一个 StructuredOutputManager:它可以访问 tokenizer,并维护一个 _grammar_bitmask 张量(tensor)。2. 当添加一个请求时:请求状态会被设置为 WAITING_FOR_FSM。grammar_init 会选择后端编译器(例如 xgrammar;注意,这些后端是第三方代码)。3. 该请求的语法会 异步编译。4. 在调度阶段(scheduling):from vllm import LLM, SamplingParamsfrom vllm.sampling_params import GuidedDecodingParamsprompts = ["This sucks","The weather is beautiful",]guided_decoding_params = GuidedDecodingParams(choice=["Positive", "Negative"])sampling_params = SamplingParams(guided_decoding=guided_decoding_params)def main():llm = LLM(model="TinyLlama/TinyLlama-1.1B-Chat-v1.0")outputs = llm.generate(prompts, sampling_params)if __name__ == "__main__":main()
- 如果异步编译完成,状态切换为 WAITING,并将 request_id 添加到 structured_output_request_ids;如果尚未完成,则将请求放入 skipped_waiting_requests,在下一次引擎步骤重试。
- xgr_torch_compile 的函数会将 _grammar_bitmask 扩展到词表大小(使用 32 位整数时,扩展比例为 32 倍)。不允许的 token 的 logits 会被设置为 –∞。
为了直观展示,这里给出一个更简单的示例:vocab_size = 8;使用 8 位整数,适合喜欢可视化表示的读者。步骤 6 的进一步说明:
如果 vocab_size = 32,则 _grammar_bitmask 是一个整数。其二进制表示用来编码哪些 token 允许(1),哪些 禁止(0)。例如 "101…001" 会扩展成长度为 32 的数组 [1, 0, 1, …, 0, 0, 1],位置为 0 的 token logits 被设置为 –∞。对于更大的词表,会使用多个 32 位整数,并进行扩展和拼接。后端(例如 xgrammar)负责根据当前 FSM 状态生成这些位模式。
- 草稿模型负责快速生成 k 个候选 token。但我们并不最终从小模型中采样 token —— 它只是用来猜测可能的续写。大模型仍然决定哪些 token 是有效的。
- 如果大模型给该 token 的概率 ≥ 草稿模型概率,则接受;否则以概率 p_large (token) /p_draft (token) 接受;遇到第一个拒绝就停止,或者接受全部 k 个草稿 token。如果全部 k 个 token 都被接受,还可以「免费」采样第 k+1 个 token(因为我们已经计算过大模型分布)。如果有拒绝,则在该位置重新生成一个再平衡的概率分布(p_large - p_draft,最小值限制为 0,归一化到和为 1),并从中采样最后一个 token。
- 取最后 prompt_lookup_max 个 token,在序列中寻找先前匹配;若找到,则提出该匹配后面的 k 个 token;否则缩小窗口并重复,直到 prompt_lookup_min。当前实现返回第一个匹配后的 k 个 token,可以考虑引入近期偏置(recency bias),反向搜索更自然(即寻找最后一次匹配)。
- 对大模型进行「模型手术」 —— 保留 embeddings 和 LM head,将 transformer 堆栈替换为轻量 MLP;微调该 MLP 作为廉价草稿模型。
- 在大模型的 embeddings(LM head 前)上训练辅助线性头(linear heads),并行预测接下来的 k 个 token;使用这些线性头比单独运行小模型更高效地提出 token。
在 vLLM 中是怎么实现的呢?引擎构建阶段(Setup)1. 初始化设备(Init device):创建 drafter(草稿模型,例如 NgramProposer) 和 rejection_sampler(拒绝采样器)。其中部分组件是用 Triton 实现的。2. 加载模型(Load model):加载草稿模型的权重(对于 n-gram 方法,这一步是无操作)。在 generate 函数中处理新请求(假设是全新请求)1. 使用 大模型 执行常规的 prefill 步骤。2. 前向传播(forward pass)和标准采样完成后,调用 propose_draft_token_ids (k) 从草稿模型采样 k 个 draft token。3. 将这些 token 存入 request.spec_token_ids(更新请求元数据)。4. 在下一次引擎步骤,当请求进入 运行队列(running queue) 时,将 len (request.spec_token_ids) 加入「新 token」计数,以便 allocate_slots 为前向传播保留足够的 KV 缓存块(KV blocks)。5. 将 spec_token_ids 复制到 input_batch.token_ids_cpu,形成 上下文 + 草稿 token。6. 通过 _calc_spec_decode_metadata 计算元数据,将 token 从 input_batch.token_ids_cpu 拷贝过来,准备 logits 等信息,然后对草稿 token 执行大模型前向传播。7. 不是直接从 logits 采样,而是使用 rejection_sampler 从左到右进行接受 / 拒绝操作,生成最终的 output_token_ids。8. 重复步骤 2–7,直到满足停止条件(stop condition)。理解这一流程的最佳方式是启动调试器,逐步跟踪代码执行。不过这一节已经让你对 推测解码在 vLLM 中的执行流程有了基本了解。分离式 Prefill/Decode我之前已经提到过 分离式 Prefill/Decode 的设计动机。Prefill 和 Decode 的性能特性非常不同:Prefill 主要受计算能力(compute-bound)限制;Decode 主要受内存带宽(memory-bandwidth-bound)限制。因此,将两者分离执行是一种合理的设计。这种设计可以更精细地控制延迟:TFTT(Time-To-First-Token,首个 token 时间);ITL(Inter-Token Latency,token 间延迟)。实际上,我们会启动 N 个 vLLM prefill 实例 和 M 个 vLLM decode 实例,并根据实时请求负载自动伸缩。Prefill Worker 会将 KV 写入 专用 KV-cache 服务;Decode Worker 从该服务读取 KV。这样可以将 长且突发的 prefill 请求 与 延迟敏感的 decode 请求 隔离开来,保证系统稳定性和低延迟。在 vLLM 中的实现方式是怎么样的?为了说明原理,下列示例使用 SharedStorageConnector,这是一个用于调试的 Connector 实现,用来演示内部机制。Connector 是 vLLM 用于处理实例间 KV 交换的抽象接口。目前该接口尚未稳定,短期内会有一些改进,其中一些可能会引入破坏性变更。我们启动 2 个 vLLM 实例(GPU 0:用于 prefill,GPU 1:用于 decode),然后在这两个实例之间传输 KV 缓存。from vllm import LLM, SamplingParamsprompts = ["Hello, my name is","The president of the United States is",]sampling_params = SamplingParams(temperature=0.8, top_p=0.95)speculative_config={"method": "ngram","prompt_lookup_max": 5,"prompt_lookup_min": 3,"num_speculative_tokens": 3,}def main():llm = LLM(model="TinyLlama/TinyLlama-1.1B-Chat-v1.0", speculative_config=speculative_config)outputs = llm.generate(prompts, sampling_params)if __name__ == "__main__":main()
注意:我也尝试过 LMCache,这是最快、可用于生产环境的 Connector(以 NVIDIA 的 NIXL 作为后端),但它仍处于前沿技术阶段,我在使用中遇到了一些 bug。由于其大部分复杂逻辑存在于外部仓库,SharedStorageConnector 更适合作为讲解示例。在 vLLM 中的步骤如下:1. 实例化(Instantiation) — 在引擎构建期间,Connector 会在两个地方创建:在 Worker 的 init device 过程中(位于 init worker distributed environment 函数下),角色为「worker」。在 Scheduler 构造函数中,角色为 「scheduler」。2. 缓存查询(Cache lookup) — 当 Scheduler 处理来自等待队列的 prefill 请求(经过本地 prefix-cache 检查后),会调用 Connector 的 get_num_new_matched_tokens:该函数检查 KV-cache 服务器中是否有外部缓存的 token。Prefill 在这里始终返回 0;decode 可能会命中缓存。结果会在调用 allocate_slots 之前加入本地计数。3. 状态更新(State update) — Scheduler 然后调用 connector.update_state_after_alloc,记录已经命中缓存的请求(prefill 为无操作)。4. 元信息构建(Meta build) — 在调度结束时,Scheduler 调用 meta = connector.build_connector_meta:Prefill 会将 is_store=True 的请求加入,用于上传 KV。Decode 会将 is_store=False 的请求加入,用于获取 KV。5. 上下文管理器(Context manager) — 在前向传播之前,引擎会进入 KV-Connector 上下文管理器:进入(enter):调用 kv_connector.start_load_kv。对于 decode,这会从外部服务器加载 KV 并注入到分页内存;对于 prefill,则无操作。退出(exit):调用 kv_connector.wait_for_save。对于 prefill,这会阻塞直到 KV 上传至外部服务器;对于 decode,则无操作。下面是一个可视化示例:附加说明:对于 SharedStorageConnector 来说,「外部服务器」只是本地文件系统。根据配置,KV 传输也可以按层进行(在每个注意力层前或后)。Decode 仅在请求的第一步加载外部 KV;之后的步骤在本地计算和存储。拓展系统:从UniProcExecutor到MultiProcExecutor在掌握核心技术后,我们可以讨论如何扩展系统。假设你的模型权重已经无法完全放入单个 GPU 的显存中:import osimport timefrom multiprocessing import Event, Processimport multiprocessing as mpfrom vllm import LLM, SamplingParamsfrom vllm.config import KVTransferConfigprompts = ["Hello, my name is","The president of the United States is",]def run_prefill(prefill_done):os.environ["CUDA_VISIBLE_DEVICES"] = "0"sampling_params = SamplingParams(temperature=0, top_p=0.95, max_tokens=1)ktc=KVTransferConfig(kv_connector="SharedStorageConnector",kv_role="kv_both",kv_connector_extra_config={"shared_storage_path": "local_storage"},)llm = LLM(model="TinyLlama/TinyLlama-1.1B-Chat-v1.0", kv_transfer_config=ktc)llm.generate(prompts, sampling_params)prefill_done.set() # notify decode instance that KV cache is ready# To keep the prefill node running in case the decode node is not done;# otherwise, the script might exit prematurely, causing incomplete decoding.try:while True:time.sleep(1)except KeyboardInterrupt:print("Script stopped by user.")def run_decode(prefill_done):os.environ["CUDA_VISIBLE_DEVICES"] = "1"sampling_params = SamplingParams(temperature=0, top_p=0.95)ktc=KVTransferConfig(kv_connector="SharedStorageConnector",kv_role="kv_both",kv_connector_extra_config={"shared_storage_path": "local_storage"},)llm = LLM(model="TinyLlama/TinyLlama-1.1B-Chat-v1.0", kv_transfer_config=ktc)prefill_done.wait() # block waiting for KV cache from prefill instance# Internally it'll first fetch KV cache before starting the decoding loopoutputs = llm.generate(prompts, sampling_params)if __name__ == "__main__":prefill_done = Event()prefill_process = Process(target=run_prefill, args=(prefill_done,))decode_process = Process(target=run_decode, args=(prefill_done,))prefill_process.start()decode_process.start()decode_process.join()prefill_process.terminate()
- 第一个选项是使用 张量并行(Tensor Parallelism, TP),将模型分片到同一节点的多张 GPU 上(例如 TP=8)。如果模型仍然无法容纳,则下一步是在跨节点使用 流水线并行(Pipeline Parallelism, PP)。
- 节点内带宽(intranode bandwidth)显著高于节点间带宽(internode bandwidth),这也是为什么通常更偏好 张量并行(TP) 而非流水线并行(PP)。(同时,PP 通信的数据量通常比 TP 少。)我们不讨论 Expert Parallelism(EP),因为这里关注的是标准 Transformer 而非 MoE 模型,也不讨论序列并行(Sequence Parallelism),TP 和 PP 在实际中是最常用的。
- UniProcExecutor 情况:execute_model 直接调用 worker 的 execute_model。MultiProcExecutor 情况:execute_model 间接通过 rpc_broadcast_mq 调用每个 worker 的 execute_model。
在另一台节点上运行同样的命令,做如下调整:vllm serve <model-name>--tensor-parallel-size 4--data-parallel-size 4--data-parallel-size-local 2--data-parallel-start-rank 0--data-parallel-address <master-ip>--data-parallel-rpc-port 13345--headless
- 去掉 --headless修改 DP start rank
注意:这假设网络已经配置好,所有节点都能访问指定的 IP 和端口。vLLM 中的工作原理:在 headless 服务器节点上,CoreEngineProcManager 会启动 2 个进程(根据 --data-parallel-size-local),每个进程运行 EngineCoreProc.run_engine_core。每个函数会创建一个 DPEngineCoreProc(引擎核心),然后进入其忙循环。DPEngineCoreProc 会初始化其父类 EngineCoreProc(EngineCore 的子类),其主要步骤如下:1. 创建 input_queue 和 output_queue(queue.Queue)。2. 使用 DEALER ZMQ 套接字(异步消息库)与另一节点的前端进行初始握手,并接收协调地址信息。3. 初始化 DP(数据并行)组(例如使用 NCCL 后端)。4. 使用 MultiProcExecutor 初始化 EngineCore(如前所述,TP=4、4 个 GPU)。5. 创建一个 ready_event(threading.Event)。6. 启动一个输入守护线程(threading.Thread)运行 process_input_sockets (..., ready_event)。同样启动输出线程。7. 主线程仍在等待 ready_event,直到跨 4 个进程(覆盖 2 个节点)的所有输入线程完成协调握手,然后执行 ready_event.set ()。8. 一旦解除阻塞,向前端发送 "ready" 消息,并附带元数据(例如分页 KV 缓存中可用的 GPU 块数量)。9. 主线程、输入线程和输出线程进入各自的忙循环。总结:最终我们得到 4 个子进程(每个 DP 副本一个),每个进程运行主线程、输入线程和输出线程。它们完成与 DP 协调器和前端的协调握手,然后每个进程的三个线程进入稳定的忙循环状态。vllm serve <model-name>--tensor-parallel-size 4--data-parallel-size 4--data-parallel-size-local 2--data-parallel-start-rank 2--data-parallel-address <master-ip>--data-parallel-rpc-port 13345
- 输入线程(Input thread) — 阻塞在输入套接字上,直到 API 服务器路由一个请求过来;收到请求后,它会解码负载,通过 input_queue.put_nowait (...) 将工作项入队,然后回到套接字阻塞状态。主线程(Main thread) — 当 input_queue.get (...) 被唤醒时,将请求传入引擎;MultiProcExecutor 执行前向推理,并将结果入队到 output_queue。输出线程(Output thread) — 当 output_queue.get (...) 被唤醒时,将结果返回给 API 服务器,然后恢复阻塞。
- DP 波计数器(DP wave counter) — 系统跟踪「波次」;当所有引擎空闲时,它们进入静止状态,当新工作到达时计数器递增(有助于协调与指标统计)。控制消息(Control messages) — API 服务器不仅可以发送推理请求,还可发送中止请求或其他控制 RPC。锁步虚拟步骤(Dummy steps for lockstep) — 如果某个 DP 副本有工作,所有副本都会执行前向步骤;没有请求的副本执行虚拟步骤以参与必要的同步点(避免阻塞活跃副本)。
- 定期向前端的 run_engine_stats_update_task 发送负载均衡信息(队列大小、等待 / 运行的请求数)。处理前端发送的 SCALE_ELASTIC_EP 命令,通过动态改变引擎数量进行扩缩(仅适用于 Ray 后端)。发送 START_DP_WAVE 事件给后端(由前端触发),并回报波状态更新。
- 一类任务通过 generate 路径处理输入请求(每个新客户端请求会生成一个新的 asyncio 任务)。两个任务 (process_outputs_socket, output_handler) 处理底层引擎的输出消息。 一个任务 (run_engine_stats_update_task) 与 DP 协调器保持通信:发送波触发、轮询负载均衡状态、处理动态扩缩请求。
接下来会发生什么:1. 请求到达 API 服务器上的 OpenAIServingCompletion 的 create_completion 路由。2. 该函数异步对提示词进行分词(tokenize),并准备元数据(请求 ID、采样参数、时间戳等)。3. 然后调用 AsyncLLM.generate,其流程与同步引擎相同,最终会触发 DPAsyncMPClient.add_request_async。4. 接着调用 get_core_engine_for_request,根据 DP 协调器的状态在各个引擎间做负载均衡(选择分数最低 / 负载最小的引擎:score = len (waiting) * 4 + len (running))。5. 将 ADD 请求发送到选定引擎的 input_socket。6. 在该引擎中:curl -X POST http://localhost:8000/v1/completions -H "Content-Type: application/json" -d '{"model": "TinyLlama/TinyLlama-1.1B-Chat-v1.0","prompt": "The capital of France is","max_tokens": 50,"temperature": 0.7}'
- Input 线程 — 解除阻塞,从输入套接字解码数据,并将工作项放入主线程的 input_queue。Main 线程 — 在 input_queue 上解除阻塞,将请求添加到引擎中,并反复调用 engine_core.step (),将中间结果加入 output_queue,直到满足停止条件。
提醒:step () 会调用调度器(scheduler)、模型执行器(model executor,可为 MultiProcExecutor)等,我们之前已经讲过了。
- Output 线程 — 在 output_queue 上解除阻塞,将结果通过输出套接字发送回去。
- 添加更多 API 服务器时,负载均衡在 OS / 套接字层处理。应用层感知不到复杂性,仍然是一条请求 - 响应流程。使用 Ray 作为 DP 后端时,可以暴露 /scale_elastic_ep URL 接口,实现引擎副本数量的自动上下扩缩。
- 延迟对交互式应用最为重要,因为用户在等待响应。吞吐量对离线工作负载更为关键,例如用于训练前 / 训练后的合成数据生成、数据清洗 / 处理,以及任何离线批量推理任务。
- 当 B↓趋近 1 时,每个 token 的间隔延迟(ITL, inter-token latency)下降:每步处理的工作量更少,token 之间不会互相「竞争」。当 B ↑趋近无穷时,ITL 上升,因为每步要做更多 FLOPs,但吞吐量提高(直到达到峰值性能),原因是权重 I/O 被更多 token 分摊。
- 在饱和批大小 B_sat 以下,步骤时间主要受 HBM 带宽限制(权重逐层流入片上内存),因此步骤延迟几乎保持不变 —— 计算 1 个 token 和 10 个 token 所需时间相近。超过 B_sat 后,kernel 开始受计算能力限制,步骤时间大约随 B 增长,每增加一个 token 都会增加 ITL。
- latency — 使用短输入(默认 32 tokens)并生成 128 个输出 token,使用小批量(默认 8)。运行多次迭代,并报告整个 batch 的端到端延迟。throughput — 一次性提交固定的 prompt 集(默认 1000 个 ShareGPT 样本,等价于 QPS=∞ 模式),并报告输入 / 输出 / 总 token 数以及每秒请求数。serve — 启动 vLLM 服务器,模拟真实工作负载:请求间隔时间从 Poisson 或 Gamma 分布采样。它会在时间窗口内发送请求,测量前面讨论的所有指标,并可选择在服务器端设置最大并发量(例如通过 semaphore 限制为 64 个并发请求)。
CI 中使用的基准测试配置存放在 .buildkite/nightly-benchmarks/tests 目录下。还有一个自动调优脚本,它会驱动 serve 基准测试以寻找满足目标 SLO 的参数设置(例如,「在保持 p99 端到端延迟 < 500 ms 的前提下最大化吞吐量」),并返回推荐配置。尾声我们从基础的引擎核心(UniprocExecutor)开始,添加了诸如投机解码(speculative decoding)和前缀缓存(prefix caching)等高级功能,随后扩展到 MultiProcExecutor(TP/PP > 1),最终实现分布式扩展,将所有内容封装在异步引擎和分布式服务栈中 —— 最后介绍了如何衡量系统性能。vLLM 还包含一些本文未详述的专门处理,例如:vllm bench latency--model <model-name>--input-tokens 32--output-tokens 128--batch-size 8}'
- 自定义硬件后端:TPU、AWS Neuron(Trainium/Inferentia)等架构 / 技术:MLA、MoE、编码器 - 解码器(例如 Whisper)、池化 / 嵌入模型、EPLB、m-RoPE、LoRA、ALiBi、无注意力变体、滑动窗口注意力、多模态大语言模型,以及状态空间模型(例如 Mamba/Mamba-2、Jamba)
- TP/PP/SP混合 KV-cache 逻辑(Jenga)、更复杂的采样方法如 beam sampling 等实验性功能:异步调度
© THE END
转载请联系本公众号获得授权
投稿或寻求报道:liyazhou@jiqizhixin.com
