原文链接:Inside vLLM: Anatomy of a High-Throughput LLM Inference System 注意本文中的📝笔记,是原文中原作者的笔记。
本文将从分页注意力机制(paged attention)、连续批处理(continuous batching)、前缀缓存(prefix caching)、投机解码(Specdec),到如何构建大规模多GPU、多节点动态服务逐一展开介绍。
2025年8月29日
在这篇文章中,我将逐步介绍现代高吞吐的LLM推理系统的所有核心系统组件以及高级功能。准确地说,我将详细分析 vLLM [1] 的工作原理。
本文是系列文章的第一篇。它将从宏观入手,然后逐步深入细节(采用倒金字塔式的方法),以便你能够对整个系统形成准确且高层次的思维模型,而不会陷入细节的泥淖之中。
本系列后续的文章将深入探讨具体子系统。
本文分为五个部分:
- LLM引擎(LLM Engine)及引擎核心(Engine Core):vLLM基础知识(调度、分页注意力、连续批处理等)
- 高级功能:分块预填充、前缀缓存、引导式和投机解码、P/D分离
- 扩展:从单GPU到多GPU执行
- 服务层:分布式/并发 Web 脚手架
- 基准测试和自动调优:测量延迟和吞吐量
📝笔记
- 本文基于的代码版本: commit 42172ad(2025 年 8 月 9 日)。
- 目标受众:所有对最先进(SOTA)的 LLM 引擎的工作原理感到好奇的人,以及有兴趣为 vLLM、SGLang 等做出贡献的人。
- 我将重点介绍V1 引擎。我也研究过 V0 版本(现已弃用),这对于了解项目的演变过程很有帮助,而且许多概念仍然沿用至今。
- 关于LLM引擎/引擎核心的第一部分可能有点晦涩难懂/枯燥,但博客的其余部分有很多示例和图示。:)
LLM引擎(LLM Engine)及引擎核心(Engine Core)
LLM引擎是vLLM的基础组成部分。它本身已经能够实现高吞吐量的推理,但仅限于离线环境。仅靠它还无法通过网络向用户提供服务。
我们将使用下面离线推理的代码片段作为我们的运行示例(改编自basic.py)。
from vllm import LLM, SamplingParams
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(prompts, sampling_params)
if __name__ == "__main__":
main()
📝环境变量:
- VLLM_USE_V1="1" # 使用V1版本的引擎
- VLLM_ENABLE_V1_MULTIPROCESSING="0" # 单进程中运行
配置如下:
- 离线环境(无网络/分布式系统框架)
- 同步执行(所有执行都在单个阻塞进程中完成)
- 单GPU(无 数据/模型/流水线/专家并行;即DP/TP/PP/EP = 1)
- 使用标准 transformer [2](支持 Jamba 等混合模型需要更复杂的混合 KV 缓存内存分配器)
从这里开始,我们将逐步构建一个在线、异步、多GPU、多节点推理系统——但仍然用标准的 transformer模型提供服务。
在这个例子中,我们做了两件事:
- 实例化了一个LLM类型的引擎对象
- 调用它的
generate方法来对给定的prompts进行采样
下面我们开始分析LLM类的初始化方法。
LLM类的初始化方法
引擎(LLM类)的主要组件有:
- vLLM配置(vLLM config):包含用于配置模型、缓存、并行度等的所有参数。
- 输入处理器(processor):通过验证(validation)、分词(tokenization)和其他处理(processing)将原始输入转换成
EngineCoreRequests类型。 - 引擎核心客户端(engine core client):在我的示例中,我使用的是
InprocClient类型,它基本等价于EngineCore;我们将逐步构建出DPLBAsyncMPClient类型以实现能支持大规模请求的服务。 - 输出处理器(output processor):将
EngineCoreOutputs的原始数据转换为RequestOutput,即用户最终看到的内容。
📝笔记:
由于 V0版本的引擎已被弃用,类名和细节可能会有所变化。我将重点放在核心概念上,而不是具体的函数签名上。所以我会省略一些细节,但不会全部省略。
引擎核心(Engine Core)由几个子组件组成:
-
模型执行器(Model Executor):负责模型的前向传播,我们目前使用的
UniProcExecutor是在单个GPU上运行的单个Worker进程。我们将逐步构建出支持多个GPU的MultiProcExecutor类型。 -
结构化输出管理器(Structured Output Manager):用于引导解码(guided decoding)——我稍后会介绍。
-
调度器(Scheduler):决定哪些请求进入下一个引擎步骤——它还包含:
- 策略设置(policy setting)——可以是先到先得(FCFS),也可以基于优先级(优先级更高的请求优先处理)
等待(waiting)队列和运行(running)队列- KV Cache管理器(KV cache Manager)——这是分页注意力机制(paged attention)的核心[3]
KV Cache管理器维护着一个free_block_queue,可用KV Cache块(block)的池子(通常有数十万个块,具体取决于显存大小和块大小)。在分页注意力机制下,这些块用作索引结构,将token映射到其对应的KV Cache块中。
标准 Transformer 层(非 MLA [4])的块大小计算如下:
2(key/value)* block_size(默认值=16)* num_kv_heads* head_size* dtype_num_bytes(例如,bf16 为 2)
在模型执行器(Model Executor)构建过程中,Worker会创建一个对象并执行三个关键流程。(之后,借助MultiProcExecutor,这些相同的流程会在不同 GPU 的每个工作进程上独立运行。)
-
初始化设备:
- 为工作进程分配一个 CUDA 设备(例如“cuda:0”),并检查模型数据类型是否受支持(例如 bf16)。
gpu_memory_utilization根据请求的显存大小(例如 0.8 → 总显存的 80%),验证是否有足够的显存可用。- 设置分布式设置(DP / TP / PP / EP 等)
- 实例化一个
model_runner(包含采样器、KV Cache和前向传递缓冲区,例如input_ids,positions等等) - 实例化一个
InputBatch对象(保存 CPU 端前向传播缓冲区、用于 KV Cache索引的块表、采样元数据等)
-
加载模型:
- 实例化模型架构
- 加载模型权重
- 调用 model.eval()(PyTorch 的推理模式)
- 可选:对模型调用 torch.compile()
-
初始化 KV Cache:
- 获取每层 KV Cache的规格。过去一直是
FullAttentionSpec(Transformer的标准结构),但随着混合模型(滑动窗口、Transformer/SSM,如 Jamba)的出现,情况变得更加复杂(参见 Jenga [5])。 - 运行一次虚拟/性能分析(dummy/profiling)的前向传播,并获取 GPU 显存快照,以计算可用显存可以容纳多少个 KV Cache块。
- 分配(allocate)、重塑(reshape)和绑定(bind) KV Cache张量到注意力层。
- 准备注意力元数据(例如,将后端设置为FlashAttention),供CUDA内核在转发过程中使用。
- 除非设置了
--enforce-eager,否则对于每个预热批次大小,都会执行一次虚拟运行并捕获 CUDA graphs。CUDA graphs会将整个 GPU 工作序列记录到一个 DAG 中。在后续的前向传播过程中,我们会启动/重放预先生成的 CUDA graphs,从而减少CUDA内核启动开销,进而降低延迟。
- 获取每层 KV Cache的规格。过去一直是
我在这里省略了很多底层细节——但接下来我会介绍这些核心部分,因为在以下章节中将频繁提到它们。
现在引擎已经初始化完毕,让我们继续执行generate函数。
生成函数(Generate function)
第一步是请求验证并将其输入到引擎。对于每个prompt,我们将:
- 创建唯一的请求 ID 并记录其到达时间。
- 调用输入预处理器(input preprocessor),对prompt进行分词,并返回一个包含
prompt、prompt_token_ids和type(文本、token、embed等类型)的词典。 - 将上述信息打包成
EngineCoreRequest类型的请求,并添加优先级、采样参数和其他元数据。 - 将请求传递给引擎核心(engine core),引擎核心会将其包装在一个
Request对象中,并将其状态设置为WAITING。然后,该请求会被添加到调度器(Scheduler)的waiting队列中(如果是先到先得FCFS的策略,则追加到末尾;如果是基于优先级排序的策略,则将请求压入堆) 。
此时引擎已接收完所有数据,可以开始执行。在同步引擎示例中,我们只会处理这些初始请求——没有机制可以在运行过程中注入新的请求。相比之下,异步引擎支持这种机制(也称为连续批处理(continuous batching) [6]):在每个步骤之后,都会同时考虑新旧请求。
由于前向传播将批次打平成一个序列,并且自定义内核可以高效地处理它,因此即使在同步引擎中,连续批处理也得到了基本支持。
接下来,只要有请求需要处理,引擎就会反复调用其step()函数。每个步骤都包含三个阶段:
- 调度(Schedule):选择在此步骤中要运行的请求(解码和/或(分块)预填充)。
- 前向传播(Forward pass):运行模型并采样token。
- 后处理(Postprocess):将采样到的token ID 追加到每个
Request中,进行分词还原(detokenize),并检查停止条件。如果请求已完成,则进行清理(例如,将其KV cache块返回给free_block_queue),并提前返回输出。
📝停止条件如下:
- 请求长度超过其长度限制(
max_model_length或其自身限制max_tokens)。- 采样的token是 EOS ID(除非启用
ignore_eos- >当我们想要强制生成一定数量的输出token时,这对于基准测试很有用)。- 采样tokrn与采样参数中指定的任何
stop_token_ids标记匹配。- 输出中存在停止字符串 - 我们会截断输出,直到遇到第一个停止字符串,并在引擎中中止请求(请注意,输出中将存在
stop_token_ids,但不会存在停止字符串)。
在流式传输模式下,我们会发送生成的中间token,但目前我们暂且忽略这一点。
接下来,我们将更详细地探讨调度器。
调度器(Scheduler)
推理引擎主要处理两种类型的任务:
- 预填充(Prefill) 请求:对所有prompt token进行前向遍历。这通常是计算密集的(阈值取决于硬件和prompt长度)。最后,我们从最终token位置的概率分布中采样一个token。
- 解码(Decode) 请求:仅对最新的token进行前向传播。所有先前的KV向量都已缓存。由于计算一个token仍然需要加载所有 LLM 权重和 KV Cache,因此这些请求是内存(显存)密集的。
在基准测试章节,我们将分析GPU性能的屋顶线模型。这将更详细地介绍预填充/解码性能背后的原理。
由于采用了更智能的设计,V1调度器可以在同一步骤中混合处理这两种类型的请求。相比之下,V0引擎一次只能处理预填充或解码中的一种。
调度器会优先处理解码请求,也就是已经在running队列中的请求。对于每个解码请求,它会:
- 计算要生成的token的数量(由于推测解码[speculative decoding]和异步调度的存在,每次生成的token数量并不总是1,稍后会详细介绍)。
- 调用 KV Cache管理器的
allocate_slots函数(详情后面会介绍)。 - 更新token预算,即减掉步骤 1 中的token数量。
然后,它会处理waiting队列中的预填充请求,具体来说:
- 检索已计算块的数量(如果禁用前缀缓存[Prefix Cache],则返回 0——我稍后会介绍)。
- 调用 KV Cache管理器的
allocate_slots函数。 - 将请求从
waiting队列取出并移至running队列,并将其状态设置为RUNNING。 - 更新token总配额。
现在我们来看看allocate_slots的功能:
- 计算区块数量:确定需要分配多少(
n)个新的KV Cache块。默认情况下,每个块存储 16 个token。例如,如果预填充请求包含 17 个新token,则需要ceil(17/16) = 2个块。 - 检查可用性:如果管理器的块池中没有足够的块,则提前退出。根据请求是解码请求还是预填充请求,引擎可能会尝试通过驱逐低优先级请求(调用
kv_cache_manager.free函数将KV块归还给块池)来重新计算抢占(V0 版本支持交换抢占),或者可能会跳过调度并继续执行。 - 分配块:通过KV Cache管理器的协调器,从块池中获取前
n个块,(前面提到过free_block_queue是双向链表)。存储到词典req_to_blocks中,该词典将request_id映射到其对应的KV Cache块列表。
我们终于到了前向传播了!
前向传播
我们调用模型执行器(model executor)的execute_model方法,它委托给Worker,Worker又委托给模型运行器(model runner)。
以下是主要步骤:
- 更新状态: 从
input_batch中清理已完成的请求;更新其他前向传播相关元数据(例如,每个请求的 KV Cache块,将用于索引到分页 KV Cache内存中)。 - 准备输入:将缓冲区从 CPU 复制到 GPU;计算位置;构建
slot_mapping(更多内容请参见示例);构建注意力元数据。 - 前向传播:使用自定义分页注意力内核运行模型。所有序列都被打平并连接成一个非常长的“超级序列”。位置索引和注意力掩码确保每个序列只关注自身的token,从而确保连续批处理无需右侧填充(right-padding)。
- 收集最后一个token状态:提取每个序列最终位置的隐藏状态并计算logits。
- 采样: 根据采样配置(贪心算法、温度、top-p、top-k 等)从计算出的 logits 中采样token。
前向传播步骤本身有两种执行模式:
- 急切模式(Eager mode): 运行 PyTorch 标准的前向传播。
- 捕获模式(Captured mode): 当不强制执行急切模式时,执行/重放预先捕获的 CUDA Grpah(记住,我们在初始化 KV Cache过程中引擎构造期间捕获了CUDA Graph)。
以下是一个具体的例子,应该能清楚地说明连续批处理(continuous batching)和分页注意力(paged attention)的概念:
高级功能——扩展核心的引擎逻辑
基本的引擎流程介绍以后,我们现在可以研究高级功能了。
我们已经讨论过抢占、分页注意力以及连续批处理。
接下来,我们将深入探讨:
- 分块预填充(Chunked prefill)
- 前缀缓存(Prefix caching)
- 引导解码(Guided decoding,通过语法约束的有限状态机)
- 推测解码(Speculative decoding)
- P/D分离(P/D即prefill/decoding,也就是预填充/解码)
分块预填充(Chunked prefill)
分块预填充是一种处理长prompt的技术,它将prefill步骤拆分成更小的块。如果没有这项技术,可能会出现一个非常长的请求占用引擎的一个步骤(step),导致其他prefill请求无法运行。导致其他请求的延迟增长。
例如,假设每个块包含n(=8) 个token,token用小写字母表示,字母之间用“-”分隔。一个较长的promptP可能类似于x-y-z,其中z是一个不完整的块(例如,包含 2 个token)。对 P执行完整的prefill操作需要>=3个引擎步骤(如果某个步骤未被调度执行,则可能出现 > ),并且我们只在最后一个分块预填充步骤中采样一个新token。
以下是图示:
实现起来很简单:限制每一步生成的新token数量。如果请求的数量超过限制long_prefill_token_threshold,则将其重置为该值。底层索引逻辑(前面已描述)会处理其余部分。
在 vLLM V1 中,你可以通过将long_prefill_token_threshold的值设置为正整数来启用分块预填充。(从技术上来说,即使不设置,如果prompt长度超过token预算,我们也会截断prompt并运行分块预填充。)
前缀缓存(Prefix Caching)
为了解释前缀缓存的工作原理,我对原始示例代码稍作修改:
from vllm import LLM, SamplingParams
long_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()
前缀缓存避免了在多个prompt开头部分相同的时候重新计算token——因此得名前缀(Prefix。
关键在于long_prefix:它被定义为任何长度超过 KV Cache块大小(默认为 16 个token)的前缀。为了简化示例,假设long_prefix它的长度正好是n x block_size(其中n ≥ 1)。
也就是说,它与区块边界完美对齐——否则我们就必须重新计算long_prefix_len % block_size个token,因为我们无法缓存不完整的区块。
如果没有前缀缓存,每次我们处理具有相同前缀long_prefix的新请求时,我们都会重新计算所有n x block_size个token。
通过前缀缓存,这些token只需计算一次(它们的KV存储在KV Cache的分页内存中),然后即可重复使用,因此只需要处理新的prompt的token。就可以加快prefill阶段的速度(但对decode阶段没有帮助)。
那么在vLLM中是如何实现的呢?
在第一次generate调用期间,在调度阶段,在函数kv_cache_manager.get_computed_blocks内部,引擎会调用函数hash_request_tokens:
- 该函数将数据分割
long_prefix + prompts[0]成 16个token一组的块。 - 对于每个完整的块,它都会计算一个哈希值(使用python内置hash算法或SHA-256算法,SHA-256 速度较慢但冲突较少)。该哈希值的原文是组合了前一个数据块的哈希值、当前数据块的token以及可选的元数据。
可选元数据包括:MM 哈希、LoRA ID、缓存盐(cache salt,注入到第一个块的哈希中,确保只有带有此缓存盐的请求才能复用块)。
- 每个结果都存储为一个
BlockHash对象,其中包含哈希值及其对应的那些token ID。最终我们返回一个块哈希的列表。
这个列表存储在self.req_to_block_hashes[request_id]中。
接下来,引擎会调用函数find_longest_cache_hit检查这些哈希值是否已存在于cached_block_hash_to_block中。第一次请求时,将不会找到任何匹配项。
然后我们调用allocate_slots,它再调用coordinator.cache_blocks,coordinator.cache_blocks将新BlockHash条目与分配的 KV 块关联起来,并将它们记录在cached_block_hash_to_block中。
之后,前向传播会将 KV 填充到 KV Cache 的分页内存中,对应于我们上面分配的 KV Cache块。
经过一系列引擎步骤后,它会分配更多的 KV Cache块,但这对于我们的示例来说无关紧要,因为在
long_prefix之后前缀立即发生了变化。
使用相同前缀的请求第二次调用generate时,步骤 1-3 重复执行,但这次find_longest_cache_hit会找到匹配的n个块(通过线性查找)。引擎可以直接复用这些KV块。
如果原始请求仍然有效,这些数据块的引用计数会递增(例如递增到 2)。在本例中,第一个请求已经完成,因此这些数据块被释放回块池,其引用计数也重置为 0。由于我们能够通过cached_block_hash_to_block函数从块池中检索到它们,因此我们知道它们是有效的(KV Cache管理器的逻辑就是这样设置的),所以我们只需调用free_block_queue再次将它们从块池中移除即可。
📝进阶笔记:
只有当队列
free_block_queue(从左侧弹出)出现重新分配时KV Cache块才会失效,此时我们发现该块在cached_block_hash_to_block中仍然具有关联的哈希值。此时,我们会清除该块的哈希值并从cached_block_hash_to_block中删除其条目,以确保它不能通过前缀缓存被复用(指的是不能被旧前缀复用)。
这就是前缀缓存的要点:不要重新计算已经见过的前缀——只需重用它们的 KV Cache即可!
如果你理解了这个例子,那么你也就理解了分页注意力机制的工作原理。
前缀缓存是默认启用的。可以使用:enable_prefix_caching = False去禁用它。
引导解码(Guided Decoding,FSM)
引导解码是这样一种技术:每个解码步骤中的logits都受到基于语法的有限状态机(FSM)的约束。这确保了只能对语法允许的token进行采样。
这是一个强大的设置:你可以从正则语法(乔姆斯基3型文法[Chomsky type-3],例如任意的正则表达式模式)到上下文无关语法(乔姆斯基2型文法,涵盖大多数编程语言)的任何内容。
为了便于理解,让我们从最简单的例子开始,在我们之前的代码基础上构建:
from vllm import LLM, SamplingParams
from vllm.sampling_params import GuidedDecodingParams
prompts = [
"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()
在我给出的简单示例中(假设采用字符级分词):在预填充阶段,有限状态机对logits做掩码,只允许使用“P”或“N”。如果采样到字母“P”,则有限状态机跳转到“正”分支;下一步只允许使用字母“o”,依此类推。
在vLLM中的工作原理如下:
- 在LLM引擎构造过程中,创建了一个
StructuredOutputManager类型的对象;它可以访问分词器(tokenizer)并维护一个张量_grammar_bitmask。 - 当添加新请求时,其状态设置为
WAITING_FOR_FSM,然后grammar_init选择后端编译器(例如xgrammar[7];请注意,后端是第三方代码)。 - 这个请求的语法是异步编译的。
- 在调度过程中,如果异步编译已完成,则状态切换为
WAITING,并将request_id添加到structured_output_request_ids中;否则,将其放入skipped_waiting_requests以在下一个引擎步骤中重试。 - 在调度循环之后(仍在调度内部),如果有 FSM 请求,则
StructuredOutputManager请求后端进行准备或者更新_grammar_bitmask。 - 在前向传播产生logits之后,xgr_torch_compile 函数将位掩码扩展到词汇表大小(32 倍扩展比,因为我们使用的32位整数),并将不允许的 logits 掩码设置为 -∞。
- 在采样到下一个token后,请求的有限状态机通过调用函数
accept_tokens进行前进。从视觉上看,我们在有限状态机图中移动到下一个状态。
步骤 6 需要进一步说明。
如果vocab_size = 32,则_grammar_bitmask是一个整数。那么它的二进制表示编码了哪些标记是允许的(“1”),哪些标记是不允许的(“0”)。例如,“101…001”会展开成一个长度为 32 的数组[1, 0, 1, …, 0, 0, 1];位置值为 0 的标记的 logits 值会被设置为 -∞。对于更大的词汇表,会使用多个 32 位字,并根据需要进行展开/连接。后端(例如xgrammar)负责使用当前的有限状态机的状态生成这些位的模式。
📝笔记:
这里的大部分复杂性都隐藏在像 xgrammar 这样的第三方库中。
这里有一个更简单的例子,vocab_size = 8,整数为 8 位(献给喜欢我这种可视化方式的朋友们):
您可以通过传入所需的guided_decoding配置在 vLLM 中启用此功能。
推测解码(Speculative Decoding)
在自回归生成过程中,每个新token都需要对大型语言模型进行一次前向传播。这非常耗费资源——每一步都需要重新加载并应用模型的所有权重,仅仅为了计算一个token!(假设批次大小(batch size)为 1,通常情况下是这样的B)
推测性解码[8]通过引入一个较小的草稿语言模型来加速这一过程。该草稿模型可以低成本地提出k个词元。但我们最终并不想从这个小模型中采样——它只是用来猜测候选的后续词形。最终决定哪些词形有效的仍然是大型模型。
步骤如下:
-
草稿(Draft): 在当前环境下运行小型模型并提出
k个token。 -
验证(Verify): 对上下文和
k个草稿token运行一次大型模型。这将生成k+1个位置的概率(以便我们获得k+1候选位置)。 -
接受/拒绝(Accept/reject): 从左到右依次浏览
k个草稿token:- 如果大型模型预测的草稿token的概率≥草稿模型预测的概率,则接受这个token。
- 否则,以
p_large(token)/p_draft(token)的概率接受它。 - 如果第一次出现token拒绝,或者已经接受所有
k个草稿token,就会停止。- 如果所有
k草稿代币都被接受,则从大模型中“免费”抽取额外的第(k+1)个token(我们已经计算了该分布)。 - 如果遭到拒绝,则在该位置创建一个新的重新平衡分布(
p_large - p_draft,最小值设为 0,归一化为总和为 1),并从中抽取最后一个标记。
- 如果所有
其原理在于: 尽管我们使用小模型来提出候选词,但接受/拒绝的规则保证了在预期情况下,序列的分布与我们逐个从大模型中采样词元时完全相同。这意味着推测性解码在统计学上等价于标准自回归解码——但速度可能快得多,因为一次大型模型迭代最多可以产生k+1个token。
📝笔记:
我建议参考gpt-fast来实现一个简单的模型,也可以参考原始论文去了解数学细节,并了解它和从完整模型中采样等效是如何证明的。
vLLM V1 不支持 LLM 草稿模型方法,而是实现了速度更快但准确性较低的提议方案:n-gram、EAGLE [9]和 Medusa [10]。
一句话概括如下:
- n-gram: 取最后
prompt_lookup_max个词元;在序列中查找先前的匹配项;如果找到,则提出紧随该匹配项之后的k个token;否则,减小窗口大小重试,直到窗口大小为prompt_lookup_min。
目前的实现方式是在首次匹配后才返回
k个token。引入一个近因效应并反转搜索方向(即返回上次匹配结果)是否更自然呢?****
- Eagle: 对大型语言模型进行“模型手术”——保留embedding和LM head,用一个轻量的多层感知机(MLP)替换transformer堆栈;以此为基础进行微调来作为一个低成本的草稿模型。
- Medusa: 在大型模型之上(LM head之前的embedding)训练辅助线性头,以并行预测接下来的
k个token;使用这些head比运行单独的小型语言模型可以更高效地提出token。
以下是如何使用vLLM中的ngram草稿方法调用推测性解码:
from vllm import LLM, SamplingParams
prompts = [
"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()
在vLLM中是如何实现的?
设置(在engine构造过程中):
- 初始化device:创建一个
drafter(草稿模型,比如NgramProposer)和一个rejection_sampler(其中部分内容用Triton编写)。 - 加载模型:加载草稿模型的权重(对n-gram无操作)。
之后在generate函数中(假设我们收到一个全新的请求):
- 使用大模型运行常规预填充步骤。
- 经过前向传播和标准采样后,调用
propose_draft_token_ids(k)从草稿模型中采样k个草稿token。 - 在
request.spec_token_ids中存储这些信息(更新请求的元数据)。 - 在下一个引擎步骤中,当请求在运行队列(running queue)中时,将
len(request.spec_token_ids)加到“新token”的计数中,以便allocate_slots为前向传播保留足够的 KV 块。 - 把
spec_token_ids复制到input_batch.token_ids_cpu,以形成(上下文+草稿)token。 - 通过
_calc_spec_decode_metadata计算元数据(这将复制token 到input_batch.token_ids_cpu中,准备logits等),然后对草稿token运行大型模型的前向传播。 - 不要使用常规的logits采样,而是使用
rejection_sampler从左到右的接受/拒绝方法,并生成output_token_ids。 - 重复步骤 2-7,直到满足停止条件为止。
理解这一点的最佳方法是启动调试器并单步执行代码库,但希望本节内容能让你初步了解其原理。此外,还有:
P/D分离(Disaggregated P/D)
我前面已经提到过P/D(预填充/解码)分离背后的动机。
预填充和解码的性能特点截然不同(前者受限于计算能力,后者受限于内存带宽),因此将它们的执行分开是一种合理的设计。这样可以更精确地控制耗时——包括TFTT(首token耗时)和ITL(token间耗时)——更多内容将在基准测试部分介绍。
在实际应用中,我们运行N个vLLM 预填充实例和M个vLLM 解码实例,并根据实时请求情况来自动扩缩容这两类实例。预填充worker进程将KV写入专用的KV cache服务;解码worker进程则从该服务中读取KV。这样就将长时间、突发性的预填充操作与稳定、对延迟敏感的解码操作隔离开来。
那么在vLLM 中是如何实现的?
为了清晰起见,下面的示例依赖于SharedStorageConnector,这是一个调试版本的连接器实现,只是用来说明该机制。
Connector 是 vLLM 用于处理实例间KV交换的抽象层。Connector 接口目前尚不稳定,近期计划进行一些改进,这些改进将涉及接口修改,其中一些修改可能会造成破坏性影响。
我们启动 2 个 vLLM 实例(GPU 0 用于预填充,GPU 1 用于解码),然后在它们之间传输 KV cache:
import os
import time
from multiprocessing import Event, Process
import multiprocessing as mp
from vllm import LLM, SamplingParams
from vllm.config import KVTransferConfig
prompts = [
"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 loop
outputs = 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()
📝笔记:
我还体验了
LMCache[11],它是目前速度最快的生产环境可用型连接器(使用NVIDIA的NIXL作为后端),但它仍处于初始阶段,我遇到了一些bug。由于它的大部分复杂代码都位于外部代码库中,因此用SharedStorageConnector来解释问题是更好的选择。
以下是vLLM的步骤:
-
实例化:在引擎构建过程中,连接器在两个地方创建:
- 在工作进程的 初始化设备过程中(在 初始 worker进程的分布式环境函数中),角色为“worker”。
- 在调度器构造函数中,角色为“调度器”。
-
缓存查找: 当调度器处理
waiting队列中的预填充请求(在本地前缀缓存检查之后)时,它会调用连接器的get_num_new_matched_tokens函数。此函数会检查KV Cache服务器中是否存在外部缓存的token。预填充请求在此处始终为 0;解码请求则可能命中缓存。在调用函数allocate_slots之前,会将结果添加到本地计数中。 -
状态更新:然后调度器调用
connector.update_state_after_alloc,它会记录具有缓存的请求(对预填充无操作)。 -
元构建: 在调度结束时,调度器调用
meta = connector.build_connector_meta:- 预填充会添加所有带有
is_store=True(上传到KV)的请求。 - 解码则添加带有
is_store=False(从KV获取)的请求。
- 预填充会添加所有带有
-
上下文管理器:在前向传播之前,引擎进入KV连接器上下文管理器:
- 进入时:调用函数
kv_connector.start_load_kv。对于解码操作,此函数会从外部服务器加载KV并将其注入到分页内存中。对于预填充操作,此函数不执行任何操作。 - 退出时:调用函数
kv_connector.wait_for_save。对于预填充操作,此函数会阻塞,直到KV上传到外部服务器。对于解码操作,此函数不执行任何操作。
- 进入时:调用函数
以下是一个直观的示例:
📝补充说明:
- “外部服务器”实际上
SharedStorageConnector就是本地文件系统。- 根据配置,KV 传输也可以逐层进行(在每个注意力层之前/之后)。
- 对于解码澳洲只在其请求的第一步加载一次外部 KV;之后它在本地进行计算/存储。
扩展
从UniprocExecutor到MultiProcExecutor
核心技术讲完以后,我们现在可以讨论如何纵向扩展(scale up)了。
假设你的模型权重已经无法放入单个GPU的显存中。
第一种方法是使用张量并行(tensor parallelism)技术将模型分片到同一节点上的多个GPU上(例如,使用TP=8)。如果模型仍然不够,下一步是使用跨节点的流水线并行(pipeline parallelism)技术。
📝笔记:
- 节点内带宽远高于节点间带宽,因此张量并行(TP)通常优于流水线并行(PP)。(此外,PP 传输的数据量也比 TP 少。)
- 我在这里不涉及专家并行(EP),因为我们专注于标准transformer模型架构而不是MoE,也不涉及序列并行(sequence parallelism),因为TP和PP是实践中最常用的。
现阶段,我们需要多个GPU进程(worker进程)和一个编排层来协调它们。而这正是MultiProcExecutor所提供的。
上图中,MultiProcExecutor是在TP=8设置下(driver worker的rank是0)
vLLM 中的工作过程如下:
-
MultiProcExecutor初始化rpc_broadcast_mq消息队列(底层使用共享内存实现)。 -
构造函数会循环遍历
world_size(例如TP=8 ⇒ world_size=8),并通过WorkerProc.make_worker_process为每个rank生成一个守护进程。 -
对于每个worker进程,父进程首先创建一个读取管道(reader pipe)和一个写入管道(writer pipe)。
-
新进程运行
WorkerProc.worker_main,该进程实例化一个worker进程(经历与之前UniprocExecutor相同的“初始化设备”、“加载模型”等过程)。 -
每个工人确定自己是driver(TP 组中的rank为0)还是普通worker。每个worker设置两个队列:
rpc_broadcast_mq(与父进程共享)用于接收任务。worker_response_mq用于返回响应。
-
初始化期间,每个子进程通过管道向父进程发送自己的
worker_response_mq句柄。父进程收到所有句柄后,便解除阻塞——至此协调完成。 -
worker进程随后进入忙循环(busy loop),阻塞在
rpc_broadcast_mq.dequeue函数上。当有工作项到达时,它们会执行该工作项(就像在UniprocExecutor中一样,但现在是针对TP/PP中的特定分区的工作)。结果通过worker_response_mq.enqueue发送回父进程。 -
运行过程中,当请求到达时,
MultiProcExecutor会将该请求入队到rpc_broadcast_mq(非阻塞式),以便所有子进程感知。然后,它会等待指定的输出进程worker_response_mq.dequeue收集最终结果。
从引擎的角度来看,一切都没有改变——所有这些多进程的复杂性都通过调用模型执行器的execute_model函数而被抽象化了。
- 在
UniProcExecutor中:直接在worker上调用execute_mode`。 - 在
MultiProcExecutor中:通过rpc_broadcast_mq在每个worker上间接调用 execute_model。
至此,我们可以使用相同的引擎接口将模型运行在尽可能多的资源上了。
下一步是横向扩展(scale out):启用数据并行(data parallelism,DP > 1)在节点间复制模型,添加轻量级DP协调层,引入跨副本的负载均衡,并在前面放置一个或多个API服务器来处理请求的流量。
服务层
vLLM的分布式系统服务
搭建服务化的基础设施的方法有很多,但具体起见,这里举一个例子:假设我们有两个H100节点,并且想在它们上运行四个vLLM引擎。
如果模型需要TP=4,我们可以这样配置节点:
在第一个节点上,使用以下参数以无头(headless)模式(无API服务器)运行引擎:
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)
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
📝笔记: 这假设网络配置正确,所有节点都可以访问指定的 IP 地址和端口。
VLLM是如何实现的呢?
在无头服务器节点上
在无头节点上,CoreEngineProcManager启动两个进程(每个--data-parallel-size-local)每个进程运行一个函数EngineCoreProc.run_engine_core。这些函数各自创建一个DPEngineCoreProc(引擎核心),然后进入其忙循环。
DPEngineCoreProc初始化其父类EngineCoreProc(EngineCore的子类),该父类:
- 创建一个
input_queue和output_queue(queue.Queue类型)。 - 使用ZMQ socket(异步消息库)与另一节点上的前端使用
DEALER执行初始握手,并接收协调地址信息。 - 初始化DP组(例如使用NCCL后端)。
- 使用
MultiProcExecutor(如前所述,在4个GPU上设置TP=4)去初始化EngineCore。 - 创建一个
ready_event(threading.Event类型)。 - 启动一个输入守护线程(
threading.Thread)去运行process_input_sockets(…, ready_event)。类似地,也会启动一个输出线程。 - 仍在主线程中,
ready_event等待所有4个进程(跨越2个节点)中的所有输入线程完成协调握手,最终执行ready_event.set()。 - 一旦解除阻塞,便向前端发送“ready”消息,其中包含元数据(例如,在分页 KV Cache内存可用的块数量
num_gpu_blocks)。 - 主线程、输入线程和输出线程随后进入各自的忙循环。
简而言之:最终会生成4个子进程(每个DP副本一个),每个子进程运行一个主线程、一个输入线程和一个输出线程。它们与 DP 协调器和前端完成协调握手后,每个进程的所有三个线程都会运行稳定的忙循环。
当前稳定状态:
- 输入线程:阻塞在输入套接字上,直到收到从API服务器路由过来的请求;收到请求后,它会对请求反序列化,然后调用
input_queue.put_nowait(...)将工作项加入队列,然后返回给阻塞的那个套接字。 - 主线程:
input_queue.get(...)被唤醒,将请求传递给引擎;MultiProcExecutor运行前向传播并将结果入队给output_queue。 - 输出线程:
output_queue.get(...)被唤醒,将结果发送回API服务器,然后恢复阻塞状态。
其他机制:
- DP波计数器(DP wave counter):该系统跟踪“波”;当所有引擎都空闲时,它会进入静默状态,当有新工作到达时,计数器会递增(对协调/指标监控很有用)。
- 控制消息(Control messages):API服务器不仅仅可以发送推理请求(还有,中止请求、功能/控制的RPC)。
- 锁步虚拟步骤(Dummy steps for lockstep):如果任一DP副本在运行,则所有副本执行接下来的步骤;没有请求的副本执行虚拟步骤以参与系统所需的同步点(避免阻塞活动副本)。
关于同步机制的澄清:实际上,这仅适用于专家层构成EP或TP组而注意力层仍为DP的MoE模型。目前总是使用DP——这是因为“内置”的非MoE DP用途有限,因为你可以运行多个独立的vLLM并在它们之间进行常规的负载均衡。
接下来是第二部分,API服务器节点上会发生什么呢?
在 API 服务器节点上
我们实例化一个AsyncLLM对象(LLM类的asyncio包装器)。在内部,这将创建一个DPLBAsyncMPClient(数据并行、负载均衡、异步、多进程客户端)。
在父类MPClient中,该launch_core_engines函数运行并:
- 创建用于启动握手的 ZMQ 地址(如在无头节点上所见)。
- 生成一个
DPCoordinator进程。 - 创建
CoreEngineProcManager(与无头节点上相同)。
在AsyncMPClient(MPClient的子类)内部,我们:
- 创建一个
outputs_queue(asyncio.Queue)。 - 我们创建了一个asyncio任务
process_outputs_socket,该任务(通过输出套接字)与全部的4个DPEngineCoreProc通过输出线程进行通信,并将数据写入outputs_queue。 - 随后,
AsyncLLM的另一个asyncio任务output_handler从该队列读取信息,并最终将信息发送到create_completion函数。
在内部,DPAsyncMPClient我们创建一个asyncio任务run_engine_stats_update_task,该任务与DP协调器通信。
DP协调器负责前端(API服务器)和后端(引擎核心)之间的协调。它:
- 周期性地向前端的
run_engine_stats_update_task发送负载均衡信息(队列大小、等待/运行的请求)。 - 通过动态改变引擎数量处理前端发过来的
SCALE_ELASTIC_EP命令(仅适用于 Ray后端)。 - 当前端触发时,向后端发送
START_DP_WAVE事件,并向后端报告波状态更新。
总结一下,前端(AsyncLLM)运行多个asyncio任务(记住:是并发的,不是并行的):
- 一类任务通过该
generate路径处理输入请求(每个新的客户端请求都会生成一个新的asyncio任务)。 - 两个任务(
process_outputs_socket,output_handler)进程处理来自底层引擎的输出消息。 - 一个任务(
run_engine_stats_update_task)进程负责与DP协调器保持通信:发送波触发信号、轮询负载均衡状态以及处理动态扩展请求。
最后,主服务器进程创建一个FastAPI应用,并挂载诸如 OpenAIServingCompletion和OpenAIServingChat之类的端点,这些端点会暴露/completion、/chat/completion以及其他接口。然后,该堆栈通过 Uvicorn提供服务。
综上所述,这就是完整的请求生命周期!
如果从终端发送这个命令:
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
}'
接下来会发生什么:
-
请求到达API服务器上,命中
OpenAIServingCompletion的create_completion函数。 -
该函数异步地对prompt进行分词处理,并准备元数据(请求 ID、采样参数、时间戳等)。
-
然后它调用
AsyncLLM.generate,其流程与同步引擎相同,最终调用DPAsyncMPClient.add_request_async。 -
这反过来会调用
get_core_engine_for_request,该函数根据 DP 协调器的状态在引擎之间进行负载均衡(选择得分最低/负载最低的引擎:)score = len(waiting) * 4 + len(running)。 -
ADD类型的请求被发送到所选引擎的input_socket上。 -
在那个引擎上:
-
输入线程: 解除阻塞,反序列化来自输入套接字(input_socket)的数据,并将工作项放置到
input_queue,这是为了为了主线程感知。 -
主线程: 解除
input_queue的阻塞,将请求添加到引擎,并反复调用engine_core.step(),将中间结果入队到output_queue中,直到满足停止条件。
提醒:
step()调用调度器、模型执行器(这里是MultiProcExecutor!)等等。我们已经见过这种情况了!- 输出线程:解除
output_queue阻塞并将结果通过输出套接字(output_socket)发送回去。
-
-
这些结果会触发
AsyncLLM的用来输出的asyncio任务(process_outputs_socket和output_handler),这些任务会将token传回 FastAPI的create_completion路由。 -
FastAPI会附加元数据(完成原因、logprobs、使用信息等),然后通过 Uvicorn将
JSONResponse结果返回到你的终端!
就这样,你的completion请求回来了——原来整个分布式机制都隐藏在一个简单的curl命令背后!:) 真是太有趣了!!!
📝补充说明:
- 添加更多 API 服务器时,负载均衡由操作系统/套接字层处理。从应用程序的角度来看,不会发生任何重大变化——复杂性被隐藏起来了。
- 使用 Ray 作为 DP 后端,您可以暴露一个URL端点(
/scale_elastic_ep),从而实现引擎副本数量的自动扩缩容。
基准测试和自动调优
延迟与吞吐量
到目前为止,我们好像一直在分析“气体粒子”——请求在引擎/系统中流动的内部机制。现在是时候跳出细节,从整体上审视整个系统,并提出这样的问题:我们如何衡量推理系统的性能?
在最高层面上,存在两套相互竞争的衡量标准:
- 延迟:从提交请求到返回token所需的时间
- 吞吐量:系统每秒可以生成或处理的token数量除以请求数量。
对于交互式应用程序而言,因为用户需要等待响应,因此延迟最为重要。
对于离线任务(例如训练前/训练后运行的合成数据生成、数据清理/处理以及一般的任何类型的离线批量推理作业)而言,吞吐量很重要。
在解释延迟和吞吐量为何相互竞争之前,让我们先定义一些常见的推理指标:
| 指标 | 定义 |
|---|---|
TTFT (到达第一个token的时间) | 从请求提交到收到第一个输出token所需的时间 |
ITL (token间延迟) | 两个连续token之间的时间间隔(例如,从token i-1 到token i) |
TPOT (每个token输出所需时间) | 请求中所有输出token的平均 ITL |
Latency / E2E (端到端延迟) | 处理请求的总时间,即 TTFT + 所有 ITL 之和,或者等效地,从提交请求到收到最后一个输出token之间的时间。 |
Throughput | 每秒处理的总token数(输入、输出或两者兼有),或者每秒请求数。 |
Goodput | 吞吐量需满足服务级别目标 (SLO),例如最大 TTFT、TPOT 或端到端延迟。例如,仅统计满足这些 SLO 的请求所产生的token。 |
以下是一个简化的模型,解释了这两个指标之间的竞争关系。
假设:权重 I/O 而非 KV 缓存 I/O 占主导地位;即,我们处理的是短序列。
在观察批处理大小B如何影响单个解码步骤时,权衡(tradeoff)就变得清晰起来。当批处理大小B ↓趋近于 1 时,ITL 下降:每一步的工作量减少,并且该token不会与其他token“竞争”。当批处理大小B ↑趋近于无穷大时,ITL 上升,因为每一步执行的 FLOP 操作更多——但吞吐量会提高(直到达到性能峰值),因为权重 I/O 被分摊到更多token上。
屋顶线模型(roofline model)有助于理解这一点:在饱和批次B_sat以下,每一步的时间主要取决于 HBM 带宽(将权重逐层流式传输到片上内存),因此每一步的延迟几乎保持不变——计算1个token和计算10 个token所需的时间相近。超过饱和批次B_sat后,内核成为计算瓶颈,步进时间大致随批次增加B;每个额外的令牌都会增加 ITL。
📝笔记:
为了更严谨地处理这个问题,我们必须考虑内核自动调优:随着批处理大小
B的增加,运行时可能会切换到更适合该形状的高效内核,从而改变最终的性能P_kernel。每一步的延迟为t = FLOPs_step / P_kernel,其中FLOPs_step是该步的计算量。可以看出,随着P_kernel达到P_peak,每步的计算量增加会直接导致延迟增加。
如何在 vLLM 中进行基准测试
vLLM 提供了一个vllm bench {serve,latency,throughput}的CLI,它封装了 vllm / benchmarks / {server,latency,throughput}.py。
以下是脚本的功能:
- latency: 使用较短的输入(默认32个token),采样128个输出token,较小的批次(默认 8 个)。它运行多次迭代并报告该批次的端到端延迟。
- throughout: 一次性提交一组固定的prompt集合(默认值:1000个ShareGPT 样本)(也称为
QPS=Inf模式),并报告运行期间每秒的输入/输出/总的token和请求数。 - serve: 启动 vLLM 服务器,并通过从泊松分布(或更一般地,伽马分布)中采样请求到达间隔时间来模拟真实世界的工作负载。它会在时间窗口内发送请求,测量我们讨论过的所有指标,并且可以选择强制执行服务器端最大并发数(通过信号量,例如将服务器限制为64个并发请求)。
以下是如何运行latency脚本的示例:
vllm bench latency
--model <model-name>
--input-tokens 32
--output-tokens 128
--batch-size 8
CI 中使用的基准测试配置位于
.buildkite/nightly-benchmarks/tests.
此外,还有一个自动调优脚本,用于驱动服务器基准测试,以找到满足目标SLO的参数设置(例如,“在保持 p99 e2e < 500 ms 时的最大化吞吐量”),并返回建议的配置。
结语
我们从基本引擎核心(UniprocExecutor)开始,添加了推测解码和前缀缓存等高级功能,扩展到MultiProcExecutor(使用TP/PP > 1),最后进行了横向扩展,将所有内容封装在异步引擎和分布式服务技术栈中——最后讨论了如何衡量系统性能。
vLLM 还包含一些我本文省略的其他特殊能力,例如:
- 多样化的硬件后端: TPU、AWS Neuron(Trainium/Inferentia)等。
- 架构/技术:
MLA、MoE,encoder-decoder(例如 Whisper)、池化/嵌入模型、EPLB、m-RoPE、LoRA、ALiBi,无注意力的变体、滑动窗口注意力、多模态语言模型和状态空间模型(例如 Mamba/Mamba-2、Jamba)。 - TP/PP/SP
- 混合KV Cache的逻辑(Jenga)、更复杂的采样方法(如束流采样)等等。
- 实验性功能:异步调度。
好处在于,其中大多数与上面描述的主要流程正交——你几乎可以把它们当作“插件”来对待(当然,在实践中还是有一些耦合的)。
我喜欢研究各种系统。不过话说回来,在如此宏观的角度下,细节确实受到了影响。因此在接下来的文章中,我会聚焦于特定的子系统,并深入探讨其中的细节。
参考资料
- vLLM
- "Attention Is All You Need"
- "Efficient Memory Management for Large Language Model Serving with PagedAttention"
- "DeepSeek-V2: A Strong, Economical, and Efficient Mixture-of-Experts Language Model"
- "Jenga: Effective Memory Management for Serving LLM with Heterogeneity"
- "Orca: A Distributed Serving System for Transformer-Based Generative Models"
- "XGrammar: Flexible and Efficient Structured Generation Engine for Large Language Models"
- "Accelerating Large Language Model Decoding with Speculative Sampling"
- "EAGLE: Speculative Sampling Requires Rethinking Feature Uncertainty"
- "Medusa: Simple LLM Inference Acceleration Framework with Multiple Decoding Heads"
- LMCache