1. 从需求出发:一个推理引擎必须解决什么问题?
如果把大模型推理服务想象成一条永不停歇的流水线,那么推理引擎(Engine)就是那台“总控器”:它不断接收新请求,把活儿组织成 GPU 能高效执行的批次,推进每个请求的状态机,并在合适的时刻把结果交回给用户。很多“工程复杂度”看起来分散在各种模块里,但它们都围绕同一个核心循环展开:调度(decide what)→ 执行(do the compute)→ 回填状态(update state)。
理解 Engine 的第一步,是把请求的生命周期拆成两个阶段:prefill 和 decode。prefill 往往一次性吞下整段 prompt,建立/补齐 KV cache;decode 则以更细的粒度持续推进(常见实现里每序列每步 1 token)。你会很快发现,这两个阶段不仅计算形态不同,瓶颈也不同:prefill 更像吞吐导向的“批处理”,decode 更像被 KV 读写与同步约束的“增量推进”。因此,一个能用的引擎必须同时照顾 TTFT(prefill 相关)与 TPOT(decode 相关),而这恰好把问题推到了调度策略与资源管理上。
从“必须实现”的能力来看,一个推理引擎通常绕不开四类事情:它要维护请求状态(prompt/已生成 token/停止条件/采样参数),要在硬约束下构造可执行 batch(最大 batch token、最大并发序列数、KV cache 容量等),要驱动执行器跑完一轮计算并拿到 token 输出,还要把系统边界收拾干净(退出/回收/最基本的统计)。
nano-vLLM 把这些能力压缩成一个极简但完整的骨架:你几乎可以在一眼看到的代码里,读出它对 Scheduler、ModelRunner、BlockManager、Sequence 的“接口契约”。这也是它作为入门项目最值得看的地方。
1.1 工业界的共同结构:先抓住 step() 这一刀
读 vLLM、SGLang 或其它 serving 框架时,我最推荐的启发式方法是:先找到类似 step()/tick() 的函数。因为 serving 的复杂度再高,最终都要落到“每一步推进系统一次”的节拍上。只要你锁定了 step 的输入输出,就能反推出模块分工的大框架:
schedule():本轮挑哪些请求组成 batch?本轮跑 prefill 还是 decode(或者混跑)?run():把 batch 打包成张量、跑 forward、读写 KV、做并行同步与采样。postprocess():把输出 token 写回请求状态,判断是否结束,并在需要时回收资源。
在这个视角下,Scheduler 的核心不是“排队”,而是在约束里构造可执行 batch;Runner 的核心不是“调用模型”,而是把 batch 变成可复用的高效执行路径;而 Engine 作为总控,最重要的是把这些模块串成稳定的闭环。
将这些分析映射到 nano vLLM 代码的具体实现:
- 引擎循环在哪?
def step(self):
seqs, is_prefill = self.scheduler.schedule()
token_ids = self.model_runner.call("run", seqs, is_prefill)
self.scheduler.postprocess(seqs, token_ids)
outputs = [(seq.seq_id, seq.completion_token_ids) for seq in seqs if seq.is_finished]
num_tokens = sum(len(seq) for seq in seqs) if is_prefill else -len(seqs)
return outputs, num_tokens这段代码基本把 nano-vLLM 的“控制面协议”写死了:Scheduler 决定本轮要推进哪些序列、以什么语义推进;Runner 只负责按该语义跑一次模型并吐出 token;Scheduler 再把 token 写回状态并回收资源。Engine 本身不做任何“聪明决策”,它只把闭环串起来。
这里我更愿意从输入输出契约来理解,而不是逐行翻译:
首先,schedule() 返回的不是“所有活跃序列”,而是本轮的可执行 batch:seqs 与 is_prefill。is_prefill 的存在相当于把系统的两条数据面路径显式暴露给了 Runner:prefill 通常要处理一段长度不等的 prompt(一次性补齐 KV,并产出第一个要采样的 logits);decode 则更强调“每步每序列追加 1 token”的稳定推进。也正因为这个二值信号存在,Scheduler 才能用最小接口表达最重要的策略选择:本轮到底是在“吃新请求”(prefill)还是在“推进已在运行的请求”(decode)。
其次,model_runner.call("run", seqs, is_prefill) 的关键不在于它是不是 RPC,而在于它把 Runner 的职责边界切得非常清楚:Runner 的返回值 token_ids 必须与 seqs 一一对应且顺序一致。你之后会在 postprocess 里看到这种对齐假设被反复使用——这也是 serving 系统里最常见、也最容易踩坑的“隐式协议”之一:一旦 batch 的顺序在多个模块之间不一致,bug 往往会表现为非常诡异的输出错位。
再次,postprocess(seqs, token_ids) 是系统里“状态与资源一致性”的闸门:它既要把新 token 写回 Sequence,又要在触发停止条件时把序列标记为 finished,并释放它占用的 KV block。换句话说,Scheduler 不只是“挑 batch”,它还必须维护 KV cache 与队列状态的一致推进。
最后,注意 Engine 对外的产出语义:outputs 只收集 is_finished 的序列,而且返回的是该序列的完整 completion_token_ids(累积),不是本步增量 token。于是大多数 step 返回空输出,只有“恰好在这一轮结束”的请求才会被一次性返回——这也解释了为什么这个实现天然是非流式(non-streaming)的。
num_tokens 则是一个用于吞吐统计的小技巧:prefill 用本轮处理的 token 数做粗估;decode 因为每序列每步生成 1 token,所以本轮生成 token 数就是 len(seqs),加负号只是为了在上层区分 prefill/decode 并更新不同的吞吐统计。
- 请求状态是谁?
def add_request(self, prompt: str | list[int], sampling_params: SamplingParams):
if isinstance(prompt, str):
prompt = self.tokenizer.encode(prompt)
seq = Sequence(prompt, sampling_params)
self.scheduler.add(seq)add_request 这段代码把 nano-vLLM 的“请求归属”讲得很直白:prompt 在 Engine 这一层只做两件事——分词成 token ids,然后变成一个 Sequence 对象。之后请求的生命周期推进(waiting→running→finished)以及与 KV cache 的映射关系,都不再由 Engine 管理,而是交给 Scheduler/BlockManager/Runner 在各自职责范围内完成。
这一点看似平淡,但它决定了后续各章的组织方式:当你想理解 Scheduler 或 BlockManager 的设计时,永远先问“Engine 把什么事情留给了它?它又必须向 Engine/Runner 暴露什么接口,才能满足 step() 的契约?”——从这个问题出发,代码会比从字段/函数细节堆砌更容易读懂。
- 调度器解决哪些约束?Engine 如何把约束“交付”给它
class LLMEngine:
def __init__(self, model, **kwargs):
# 获取config参数
config_fields = {field.name for field in fields(Config)}
config_kwargs = {k: v for k, v in kwargs.items() if k in config_fields}
config = Config(model, **config_kwargs)
# 用于TP(需要ranks>0)的worker进程列表
self.ps = []
# 用于进程同步的多进程(multiprocessing)事件列表
self.events = []
# 以spawn语义派生子进程
ctx = mp.get_context("spawn")
# 为rank 1..TP_size-1各自创建进程,每个进程的入口是ModelRunner
for i in range(1, config.tensor_parallel_size):
event = ctx.Event()
process = ctx.Process(target=ModelRunner, args=(config, i, event))
process.start()
self.ps.append(process)
self.events.append(event)
# rank==0的主进程也创建一个ModelRunner
self.model_runner = ModelRunner(config, 0, self.events)
# 从预训练模型中获取其相匹配的tokenizer
self.tokenizer = AutoTokenizer.from_pretrained(config.model, use_fast=True)
# 在根据config创建scheduler之前将eos标记的id写入config,保证终止判定的可用性
config.eos = self.tokenizer.eos_token_id
self.scheduler = Scheduler(config)
# 保证进程退出时尽量释放worker/进程组/共享内存
atexit.register(self.exit)到这里我们终于可以回答一个看似“反常识”的问题:为什么一个叫 Engine 的类,在 __init__ 里要做这么多杂活(起进程、建 tokenizer、填 eos、再创建 scheduler)?答案是:Scheduler 的策略不是凭空拍脑袋出来的,它必须建立在一组清晰的硬约束与系统边界之上。而这些约束的来源,恰恰是在 Engine 启动时被“装配”出来的。
首先,Config(model, **config_kwargs) 这一段做的事情非常朴素,但很工程:Engine 允许你传很多 kwargs,但只会把 Config dataclass 里声明过的字段过滤出来,避免配置污染。对 Scheduler 来说,Config 就是它的“物理世界”:最大并发序列数、最大 batched token、block 大小、KV cache 可用空间、prefill chunk 等等,都应该在这个对象里以确定的数值出现。之后你再去读 schedule(),就不会把调度理解成“纯算法问题”,而会把它理解成“在预算里选一组可执行工作”的组合优化问题。
其次,Engine 必须在创建 Scheduler 之前先初始化 tokenizer,并把 eos_token_id 写回 config.eos。这一步表面上只是“填个字段”,但它实际上是在定义请求何时结束这件事的系统事实:不同模型的 EOS id 不同,Scheduler 的 postprocess() 需要用它来判断停止条件,并在停止时释放 KV block、把序列从 running 队列移走。换句话说,config.eos 是 Scheduler 正确推进状态机的前置条件。
然后是并行执行器的装配:Engine 用 mp.get_context("spawn") 起 TP worker 进程(rank 1..TP_size-1),主进程里再创建 rank0 的 ModelRunner 作为 driver,并用 Event 做显式同步信号。你可以先把它理解成一个最小的 driver/worker 外壳:rank0 负责驱动 step、分发调用与聚合结果,其他 rank 只做计算;更细的 IPC、共享内存与 barrier 细节,我们放到 ModelRunner 章节再展开。
为什么推理里更偏好
spawn?当你在 Python 里用多进程跑 CUDA,
fork往往会把父进程里“半初始化”的线程/锁/CUDA runtime/NCCL 句柄等状态继承到子进程,导致难复现的 hang 或崩溃;spawn则让子进程在全新的解释器里重新初始化,边界更清晰、更可控。代价是启动更慢、传参需要可序列化,但对 serving/推理场景通常更值得。
最后,atexit.register(self.exit) 属于系统边界的一部分:它保证即使用户没有显式调用 exit(),进程退出时也会尽量把 worker、进程组与共享内存收拾干净,避免留下僵尸进程或资源泄漏。
对应的退出路径也很直接:
def exit(self):
self.model_runner.call("exit")
del self.model_runner
for p in self.ps:
p.join()把它和 ModelRunner.loop/exit 对起来看,会发现这也是典型的 driver/worker 退出协议:rank0 发出退出指令并唤醒各 worker;worker 在循环里读到 exit 后释放自身资源(共享内存、进程组、可能的 cudagraph 等)并跳出监听;最后主进程 join() 等待所有子进程干净退出。这个“退出协议”看似与调度无关,但它决定了系统能否稳定地作为服务长时间运行——同样属于 Engine 必须解决的约束之一。
- 执行器是什么形态?一个最小的 driver/worker 外壳
前面我们一直在讲 Engine 的“控制面协议”:schedule() → run() → postprocess()。执行器(这里是 ModelRunner)则是这条协议对应的数据面实现。工业界里执行器常见有两种形态:最简单的是单进程单卡(Engine 直接调用模型 forward);更常见、也更贴近真实部署的是 driver/worker:rank0 负责组织输入、触发执行与汇总输出,其他 rank 只负责计算。这种形态并不只出现在 vLLM,TensorRT-LLM、一些基于 NCCL 的 TP/PP 推理实现也基本都能看到类似分工,只是通信/RPC 层的工程复杂度不同。
nano-vLLM 选择了第二种形态:Engine 在初始化时 spawn 出 tensor_parallel_size-1 个子进程,同时在主进程里构造 rank0 的 ModelRunner。之后 Engine 对 Runner 的交互被收敛成一个极小接口:call("run", ...) 驱动一次计算步进,call("exit") 触发干净退出。你可以把它理解为一个“逻辑 RPC”:哪怕 rank0 是同进程对象,接口仍然保持一致,这样 driver/worker 在语义上更对齐,也更容易在后续替换成更复杂的通信机制(队列、共享内存、socket、Ray 等)。
这里的 Event 同步也很值得注意:它意味着 nano-vLLM 把多 rank 的步进显式同步了起来——rank0 发出信号,各 worker 醒来执行同一步,再一起回到等待状态。这个设计虽然牺牲了一些并发自由度,但换来的是极强的确定性:对于入门实现来说,它能把“多进程 + 多 GPU”最容易错的时序问题压到最小。
generate():把一次请求变成“持续推进直到完成”
如果说 step() 是引擎的节拍器,那么 generate() 就是对外的编排层:它把用户输入变成 Sequence 入队,然后不断调用 step() 推进系统,直到所有序列完成,再把 token ids 解码成文本输出。
原始代码里还包含进度条与吞吐统计;为了不被细节打断,这里只保留主干逻辑:
def generate(self, prompts, sampling_params, use_tqdm=True):
if not isinstance(sampling_params, list):
sampling_params = [sampling_params] * len(prompts)
for prompt, sp in zip(prompts, sampling_params):
self.add_request(prompt, sp)
outputs = {}
while not self.is_finished():
output, _ = self.step()
for seq_id, token_ids in output:
outputs[seq_id] = token_ids
outputs = [outputs[seq_id] for seq_id in sorted(outputs.keys())]
return [{"text": self.tokenizer.decode(token_ids), "token_ids": token_ids} for token_ids in outputs]这段代码背后隐含了两个重要语义。
第一,它继承了我们在 step() 里看到的“非流式契约”:output 只在序列 finished 时才会出现,而且带的是累积 completion token。因此,generate() 的对外行为天然是“一次性返回最终结果”,而不是“边生成边回传”。如果你要把它改成 streaming,最直接的切口并不是在 generate() 里加 yield,而是让 postprocess()/step() 能返回增量 token 事件(每一步的 delta),再由 generate() 把事件向上层接口交付。
第二,它必须对输出做一次按 seq_id 的排序。原因也很工程:调度器为了吞吐/资源,很可能会让不同请求以不同速度完成;完成顺序与输入顺序并不一致。这里用 seq_id 把“执行顺序的不确定性”隔离在内部,保证对外返回仍然可预测。
到这里,Engine 这一章其实已经把整套系统的外壳讲完了:控制面协议、数据面形态、对外 API 语义都已确定。接下来进入 ### Scheduler,我们会把镜头对准这个系统里最关键的“决策点”:在 KV 与 token budget 的硬约束下,到底每一步该跑哪些序列、该如何在 prefill 与 decode 之间取舍。
Scheduler
前面 Engine 已经把协议钉死:每一步 step() 都要由 Scheduler 构造一个可执行 batch,然后交给 Runner 计算,再由 Scheduler 负责回填与资源回收。于是 Scheduler 在这个系统里变成了最“关键也最现实”的模块:它不是纯算法题,而是一个在硬约束下做启发式决策的资源管理器。
把 Engine 的三段式 loop 再写一遍,你会更清楚 Scheduler 的边界在哪里:seqs, is_prefill = schedule() → token_ids = run(seqs, is_prefill) → postprocess(seqs, token_ids)。Scheduler 至少要同时满足三类约束。
第一类是接口契约。Engine 只认识 seqs 与 is_prefill,Runner 只回 token_ids。因此 Scheduler 必须保证 batch 的顺序稳定、语义明确,并且能在 postprocess() 中仅凭 (seq, token_id) 推进状态机(追加 token、判断 stop、标记 finished)。
第二类是资源契约。Engine 不直接管理 KV cache,那么 Scheduler 就必须替它管住 KV:什么时候可以让一个序列进入 batch、什么时候必须拦住,乃至什么时候要回收或抢占。也正因此,哪怕 nano-vLLM 很小,仍然需要一个 BlockManager 作为 paged KV 的雏形:没有“分页 + 分配器”,你就很难在高并发 decode 里保证不会超用显存。
第三类是系统目标。Engine 的循环里没有优先级、deadline 或 cost model 的入口,意味着所有“吞吐 vs 尾延迟”的权衡只能发生在 Scheduler 内部:prefill 与 decode 如何取舍、waiting/running 如何组织、KV 不够时怎么处理。换句话说,在这个架构里,Scheduler 是唯一能系统性影响 TTFT(prefill 相关)与 TPOT(decode 相关)的地方。
如果把这些话压缩成一句话:Scheduler 必须完成“可执行 batch 的构造(prefill/decode 的选择与组合)、KV 的预算/分配/追加/回收、以及请求状态的推进与终止”。
从“物理事实”到最小实现:你至少需要哪些零件
从推理系统的物理约束出发,一个最小但可用的 Scheduler 往往会自然地长成下面的样子。
它首先需要一个队列与状态机:最直观的是 waiting/running 两个队列(finished 通过标志位或回收逻辑体现)。新请求进 waiting;被 prefill 且成功分配 KV 后进入 running;decode 推进直到结束,结束时释放 KV 并从 running 移走。这个骨架足以跑通端到端。
然后你需要把“能不能进 batch”变成可计算的判定,也就是硬预算。最常见的两个预算是 max_num_seqs(每一步最多并发多少条序列)与 max_num_batched_tokens(prefill 一步最多吃多少 token)。工业级系统会把预算切得更细(prefill chunk budget、decode budget、甚至用 cost model 估算 step time),但在 nano 级别,先把这两根“硬栏杆”立起来就够了。
接着是paged KV 的抽象。把每条序列的 KV 看成由固定大小 block 组成:prefill/append 之前必须确保有足够 block;执行完成或被抢占后回收 block。这个抽象正是 vLLM paged attention 思想的核心之一(当然 vLLM 的 block 管理更复杂,会叠加 swap、prefix 复用、跨请求共享等能力)。
最后是“KV 不够时怎么办”。从系统角度看,你只有几个方向:要么施加 backpressure(拒绝/延后),要么 preempt(抢占并回收别人),要么 offload(swap/CPU),要么干脆把 prefill 与 decode 拆成不同实例(disaggregated prefill/decode)。nano-vLLM 选择了最直接的 preempt;vLLM 则更常通过 chunked prefill + continuous batching 降低长 prefill 对 decode 的独占,从而缓解尾延迟。
当你把这些零件搭起来之后,可观测性通常是“顺手就做”的最后一件事:暴露每步 prefill token 数、decode token 数、preempt 次数、KV block 利用率等计数,让上层能看见系统到底在怎样做取舍。nano-vLLM 目前只用 num_tokens 做了最简吞吐统计,但工业界通常会把这些指标作为调参闭环的输入。
如果把 Engine 往工业界演进,Scheduler 会先被迫改变什么
站在你现在这个“同步、非 streaming、prefill/decode 二选一”的 Engine 上往前看,Scheduler 的演进方向其实很清晰:它要么改变对外契约(支持 streaming/混排),要么变成更强的缓存/内存管理器(prefix caching、分层 KV),要么扩展“一步推进 1 token”的假设(speculative/jump decoding)。
其中最直接的变化是 streaming:postprocess() 不能再只负责“更新内部状态”,而必须产出可被上层消费的增量事件(例如 (seq_id, delta_token_id, finished_flag)),这样 Engine 才能在每个 decode step 把 delta token 交付给 client。
再往前一步是 continuous batching + chunked prefill:schedule() 不再只是返回一个全局 is_prefill,而要能描述一个混合 batch(哪些序列在跑 prefill chunk,哪些在跑 decode token),并把 token budget 在两类工作之间分配。vLLM 与 SGLang 的很多性能差异,最终都会体现在这类“混合 batch 的细节”上。
当系统开始做 prefix caching 时,Scheduler 的角色会进一步上移:它不只是分配私有 KV block,而要能“查找并复用已有前缀对应的 KV pages”。这通常会引入前缀索引结构(例如 vLLM 的 automatic prefix caching,SGLang 常见 radix tree/RadixAttention),以及缓存感知调度(优先调度能命中缓存的请求以提升 hit rate)。
最后,speculative/guided decoding 等优化会迫使 Scheduler 从“一步 1 token”的简化假设里走出来:它需要用更统一的进度抽象来描述“一步可能推进多个 computed tokens”,从而把 speculative、chunked prefill、prefix caching 等优化统一到同一个调度框架里。
回到 nano vLLM
- 队列与状态机
class Scheduler:
def __init__(self, config: Config):
self.max_num_seqs = config.max_num_seqs
self.max_num_batched_tokens = config.max_num_batched_tokens
self.eos = config.eos
self.block_manager = BlockManager(config.num_kvcache_blocks, config.kvcache_block_size)
self.waiting: deque[Sequence] = deque()
self.running: deque[Sequence] = deque()
def is_finished(self):
return not self.waiting and not self.running
def add(self, seq: Sequence):
self.waiting.append(seq)
def preempt(self, seq: Sequence):
seq.status = SequenceStatus.WAITING
self.block_manager.deallocate(seq)
self.waiting.appendleft(seq)这段类定义把 nano-vLLM 的调度器“骨架”讲得非常清楚:它维护 waiting/running 两条队列,在每一步 schedule() 里决定本轮跑 prefill 还是 decode,并通过 BlockManager 做 KV cache 的分配/追加/回收,确保显存不会超用。
在 __init__() 里,Scheduler 把 Engine 装配出来的约束固化成自己的“预算参数”:max_num_seqs 控制每一步能并发多少条序列,max_num_batched_tokens 控制 prefill 一步最多吞多少 token,eos 则用于 postprocess() 的 stop 判定。这也解释了为什么 Engine 必须在创建 Scheduler 之前先把 config.eos 填好:Scheduler 会把它复制到 self.eos,后续就以它为准推进状态机。
BlockManager 可以理解为 KV cache 的内存分配器/记账器,它给 Scheduler 提供了一组“可调度性判定”接口:prefill 前问 can_allocate(seq),decode 追加前问 can_append(seq),真正进入 batch 时调用 allocate/may_append 记账,结束或抢占时 deallocate 归还 block。核心点在于:Scheduler 决定“能不能把某个 seq 放进 batch”,不是只看 max_num_seqs 这种数量上限,还必须把 KV block 是否够用一起纳入判定,否则系统在高并发下很快就会把显存打爆。
队列本身用的是 deque,两端 push/pop 都是 O(1),非常适合做“头尾插入”的调度小动作。你可以把这两条队列的语义理解为:waiting 里是“还没拿到 KV 的请求”,running 里是“已经分配了 KV、可以持续 decode 推进的请求”。它们之间的典型流转是:
- 新请求
add()进入 waiting; - prefill 被调度且成功
allocate后,从 waiting 移到 running; - 运行完成(FINISHED)或被抢占(preempt)时,释放 KV 并从 running 移走;被抢占的序列会被塞回 waiting 的队首(
appendleft),形成一种非常朴素的“优先恢复被抢占者”的策略,尽量避免饿死。
- prefill 的 schedule
def schedule(self) -> tuple[list[Sequence], bool]:
# prefill
scheduled_seqs = []
num_seqs = 0
num_batched_tokens = 0
while self.waiting and num_seqs < self.max_num_seqs:
seq = self.waiting[0]
if num_batched_tokens + len(seq) > self.max_num_batched_tokens or not self.block_manager.can_allocate(seq):
break
num_seqs += 1
self.block_manager.allocate(seq)
num_batched_tokens += len(seq) - seq.num_cached_tokens
seq.status = SequenceStatus.RUNNING
self.waiting.popleft()
self.running.append(seq)
scheduled_seqs.append(seq)
if scheduled_seqs:
return scheduled_seqs, True
# decode
...prefill 的策略非常“教科书”:严格 FCFS(只看 waiting 队首),再叠加两道硬门槛——token budget 与 KV budget。它每次只尝试把 waiting 队首的序列塞进 batch:如果 num_batched_tokens + len(seq) 超过 max_num_batched_tokens,或者 BlockManager.can_allocate(seq) 为假,就直接 break 结束装载。这意味着一个很典型的现象:即使队列后面还有更短的请求,本轮也不会插队进来(head-of-line blocking)。nano-vLLM 选择了更简单的实现与更清晰的行为,而不是在这里做更复杂的“短请求优先”或 cost model。
当一个序列被装入 prefill batch 时,allocate(seq) 会为它分配 KV block,并把它从 waiting 移到 running。这里有一个细节体现了“控制面契约对齐”的重要性:num_batched_tokens 的增长用的是 len(seq) - seq.num_cached_tokens,也就是只把本轮新增要计算的 token 计入预算;对应地,Runner 在 prefill 打包输入时也会用 seq.num_cached_tokens 截取未缓存部分。两边只要有一边算错,轻则吞吐统计不准,重则直接把 batch 形状与 KV 写入搞乱。
- prefill/decode 二选一:
if scheduled_seqs: return scheduled_seqs, True明确说明只要能做任何 prefill,本 step 就不会 decode。它牺牲了 decode 的尾延迟稳定性,换取简单的推理引擎实现。
从系统语义上看,这行 return 非常重要:只要本轮能装进任何 prefill 序列,Scheduler 就不会进入 decode 分支。这等价于一种“prefill 优先”的策略:新请求会被尽快吃进来换取更好的 TTFT,但代价是 decode 的尾延迟可能更不稳定(长 prompt 的 prefill 容易独占多个 step)。工业界通常会用 chunked prefill/continuous batching 来缓解这件事,但 nano-vLLM 在这里选择了最简的二选一。
- decode 的 schedule
# prefill
...
# decode
while self.running and num_seqs < self.max_num_seqs:
seq = self.running.popleft()
while not self.block_manager.can_append(seq):
if self.running:
self.preempt(self.running.pop())
else:
self.preempt(seq)
break
else:
num_seqs += 1
self.block_manager.may_append(seq)
scheduled_seqs.append(seq)
assert scheduled_seqs
self.running.extendleft(reversed(scheduled_seqs))
return scheduled_seqs, Falsedecode 的逻辑可以用一句话概括:尽量让 running 队列头部的若干序列各生成 1 个 token;如果 KV 追加空间不够,就从 running 队尾开始抢占,释放出 block 让前面的序列能继续前进。
实现上,Scheduler 从 running 队首 popleft() 拿一个序列,先问 can_append(seq):如果不能追加,就优先抢占 running 队尾的序列(running.pop()),把它踢回 waiting 并释放它的 KV;如果 running 已经空了仍然 append 不动,代码会把当前序列也 preempt 回 waiting 并 break。在正常情况下(能 append),它会调用 may_append(seq) 记账并把该序列加入本轮 scheduled_seqs。
一个边界情况
decode 分支最后有
assert scheduled_seqs。也就是说,如果系统已经走到 decode 分支,但因为 KV 极度紧张导致本轮一个序列都无法 append,那么这里会触发断言失败。这更像是 nano 实现为了简化逻辑而保留的“未覆盖边界”:工业级系统通常会在这里施加 backpressure、阻塞等待、或触发更强的内存层级策略,而不是直接 assert。
-
decode 后把本批 seq 放回队首:
self.running.extendleft(reversed(scheduled_seqs))这个细节非常关键。因为 decode 时我们把若干序列从 running 头部popleft()拿出来推进了一步,如果不把它们放回去,running 队列就会被“掏空”;而把它们放回队首还能制造一种很强的局部性:刚刚 decode 过的序列在下一步仍然更容易被选中,从而形成更连续、更稳定的 per-request 延迟。这里用reversed是为了抵消extendleft的“反向插入”行为,保持相对顺序不被颠倒。 -
postprocess
def postprocess(self, seqs: list[Sequence], token_ids: list[int]) -> list[bool]:
for seq, token_id in zip(seqs, token_ids):
seq.append_token(token_id)
if (not seq.ignore_eos and token_id == self.eos) or seq.num_completion_tokens == seq.max_tokens:
seq.status = SequenceStatus.FINISHED
self.block_manager.deallocate(seq)
self.running.remove(seq)postprocess() 则是“状态与资源一致性”的收口点。它严格用 zip(seqs, token_ids) 对齐更新:把 token 追加进 Sequence,再基于 EOS 或 max_tokens 判定 FINISHED。一旦结束,它会立刻 deallocate 回收 KV block,并从 running 中移除该序列。你可以把它理解为:调度器不仅决定“算什么”,还必须负责把“算出来的结果”变成系统状态机的下一步,并把资源释放干净。
这里的 self.running.remove(seq) 是线性查找,规模小的时候完全可接受;但在大规模并发时会变成热点。工业级实现通常会用更适合的数据结构(例如链表节点句柄、哈希集合、或专门的 running pool)来避免在关键路径上做 O(n) 的移除。
Model Runner
从 Engine 的视角看,ModelRunner 是“数据面”的唯一入口:call("run", seqs, is_prefill) 这一跳必须完成一次真实的模型计算,并把输出 token 严格按 seqs 的顺序对齐返回。与此同时,它还要吞下 Scheduler 产出的 KV 元数据(block tables、slot mapping 等),把控制面语言翻译成 attention kernel 真正需要的寻址信息。在多卡 TP 场景下,这个约束会更硬:所有 rank 必须同步步进,否则 Engine 的 step 协议就会被时序撕裂。
因此,理解 ModelRunner 最好的方式不是从某个函数开始,而是把它必须承担的职责拆成几块“物理工作”:
首先是数据准备。prefill 与 decode 两条路径的张量形状与语义不同:prefill 需要把每条序列未缓存的 token 组成 ragged batch(常见做法是扁平化 input_ids + cu_seqlens),同时为 KV 写入构造 slot_mapping;decode 则更接近“每序列一个 last_token”,但需要为每条序列准备 context_lens、block_tables 等上下文,确保 attention 能把新 token 写到正确的 KV 槽位。
其次是KV cache 的物理承载。Runner 要根据显存预算分配一块足够大的 KV 缓存(通常按 block/page 组织),并把它绑定到每一层 attention 模块上(例如通过 k_cache/v_cache 指针或 buffer 引用)。Scheduler 负责“谁用多少”,Runner 负责“把 KV 放在什么地方,以及怎么写进去”。
然后是把寻址上下文交给 attention 实现。nano-vLLM 用了一个很直白的做法:通过 set_context/get_context/reset_context 维护一个全局上下文,让 attention kernel 在 forward 时读取 slot_mapping/context_lens/block_tables/cu_seqlens...。这在工程上有争议(全局状态容易被踩),但作为极简实现,它让“控制面→数据面”的信息流非常直观。
再往下是执行路径优化。decode 往往是小 batch、频繁 step,容易被 launch overhead 吃掉;因此 nano-vLLM 和 vLLM 一样,会倾向在 decode 里用 CUDA Graph replay,在 prefill 或大 batch 时走 eager,以换取更稳定的吞吐。
最后是并行控制。TP 需要初始化进程组、正确绑定 GPU、做必要的通信同步,并在 driver/worker 架构下把“run/exit 指令”可靠地广播到各 worker。nano-vLLM 用 NCCL + spawn,再叠加共享内存与 Event 做命令分发,是一种很典型的“最小可用”方案。
如果把 Engine 往工业界演进,ModelRunner 会先被迫改变什么
和 Scheduler 类似,Runner 的演进也常常不是“想优化就优化”,而是被上层契约逼出来的。最常见的几条主线是:
1)混合 batch / chunked prefill:Runner 需要在同一个 forward 内同时处理“部分序列是 prefill chunk、部分是 decode token”。这要求 prepare 阶段能构造更丰富的上下文,而不是只靠一个全局 is_prefill 布尔值切两条路径。vLLM 之所以能把 prefill chunk 与 decode 放进同一批,关键就在于 Scheduler 输出更丰富的 batch 描述,Runner 能据此构造 attention backend 需要的张量布局。
2)prefix caching(跨请求复用):Runner 的 prefill 准备阶段要能引用“别的请求留下的 KV pages”,block tables 与 slot mapping 的来源不再是单个 Sequence 的私有状态,而会来自一个全局 cache index(例如 vLLM automatic prefix caching;SGLang 常见 radix tree/RadixAttention,并配合 LRU eviction)。
3)speculative decoding:Runner 不再只输出 1 个 token,而可能一次输出 k 个候选 token 并验证(draft+verify 或其它变体)。这会改变 run() 的返回形态与 postprocess 的推进逻辑;对应地,Scheduler 也会被迫从“一步 1 token”升级为“基于 computed tokens 的进度推进”的统一抽象。
4)多机/多实例与 disaggregated prefill/decode:Runner 需要把 KV cache 从“本地 GPU 内存”抽象成“可传输/可共享”的对象,至少要能在 prefill 实例与 decode 实例间交换 KV。此时 KV 的物理映射从本地 HBM 扩展到网络与远端内存,系统目标也更接近 goodput 而不只是单卡 tok/s。
回到 nano vLLM
1. 初始化与运行时环境配置
class ModelRunner:
def __init__(self, config: Config, rank: int, event: Event | list[Event]):
# 读取config及相关参数并初始化
self.config = config
hf_config = config.hf_config
self.block_size = config.kvcache_block_size
self.enforce_eager = config.enforce_eager
self.world_size = config.tensor_parallel_size
self.rank = rank # 如果rank>1,则说明多卡并行
self.event = event # event是union类型,rank0 持有“多个 worker 的 Event 列表”,rank>0 持有“单个 Event”。
# 建立分布式并行环境,绑定GPU
dist.init_process_group("nccl", "tcp://localhost:2333", world_size=self.world_size, rank=rank)
torch.cuda.set_device(rank)
default_dtype = torch.get_default_dtype()
torch.set_default_dtype(hf_config.torch_dtype)
torch.set_default_device("cuda")
# 构造模型结构并加载权重
self.model = Qwen3ForCausalLM(hf_config)
load_model(self.model, config.model)
# 创建采样器
self.sampler = Sampler()
# 预热
self.warmup_model()
self.allocate_kv_cache()
# 可选的性能优化,根据设备支持cuda graph与否而启用
if not self.enforce_eager:
self.capture_cudagraph()
# 恢复默认device/dtype,主要是避免污染外部环境
torch.set_default_device("cpu")
torch.set_default_dtype(default_dtype)
# 跨进程协同:让所有rank同步跑同一个方法
if self.world_size > 1:
if rank == 0:
self.shm = SharedMemory(name="nanovllm", create=True, size=2**20)
dist.barrier()
else:
dist.barrier()
self.shm = SharedMemory(name="nanovllm")
self.loop()
def warmup_model(self):
torch.cuda.empty_cache()
torch.cuda.reset_peak_memory_stats()
max_num_batched_tokens, max_model_len = self.config.max_num_batched_tokens, self.config.max_model_len
num_seqs = min(max_num_batched_tokens // max_model_len, self.config.max_num_seqs)
seqs = [Sequence([0] * max_model_len) for _ in range(num_seqs)]
self.run(seqs, True)
torch.cuda.empty_cache()从 __init__() 的主线来看,这段代码并不是在“做很多杂事”,而是在完成一件非常明确的工作:把一次推理所需的运行时环境装配好,并把后续 run() 能稳定复用的资源提前准备出来。它大致分成几步:建立分布式环境并绑定 GPU → 构造模型并加载权重 → 创建采样器 → warmup → 分配 KV cache →(可选)捕获 CUDA Graph → 恢复默认 dtype/device,避免污染外部环境。
其中几项配置字段对理解 Runner 的行为非常关键。
config.hf_config 是 Hugging Face 的模型配置(在 config.py 的 __post_init__ 中通过 AutoConfig.from_pretrained(config.model) 加载)。Runner 会用它来确定默认计算 dtype(例如 fp16/bf16)、构造模型结构,也会在 allocate_kv_cache() 等地方用模型结构参数(层数、hidden size、KV heads 等)估算 KV cache 的开销,并在 capture_cudagraph() 里据此确定一些 buffer 的形状。
config.kvcache_block_size 决定 KV cache 的 block 粒度(一个 block 覆盖多少 token 的 KV)。这相当于把 KV 的资源管理从“按 token”提升到“按 block/page”:一方面影响 allocate_kv_cache() 的缓存布局与每块大小,另一方面也决定 prepare_prefill/prepare_decode() 里 slot_mapping 的线性映射规则(常见形式是 block_id * block_size + offset)。
config.enforce_eager 是性能与可调试性的开关:为真时强制走 eager(禁用 CUDA Graph),为假时允许捕获并在 decode 里 replay,从而减少每步的 launch overhead。实际工程里遇到动态 shape、cudagraph 不稳定、或需要调试时,往往都会把它打开。
config.tensor_parallel_size(以及 rank) 决定这是单卡还是多进程多卡 TP。dist.init_process_group(..., world_size, rank) 把各进程加入同一 NCCL 组;torch.cuda.set_device(rank) 约定 rank i 使用第 i 张卡;KV cache 的分配也会随 world size 做切分(例如 KV heads 按 rank 分摊)。
你还会看到代码里多次切换全局默认 dtype/device,最后再恢复。这是为了让 Runner 内部创建张量时更省事(默认就在 GPU、默认 dtype 与模型一致),但又不把这种全局状态泄漏到外部模块或用户代码里。
warmup_model() 的目的也不是得到有意义的输出,而是把后续会走到的推理路径先跑一遍:触发 CUDA kernel 的 lazy init,让 cuBLAS/cuDNN 内部缓存与 PyTorch allocator 行为更稳定,同时为后续的 CUDA Graph capture 减少“捕获到意外分支”的概率。紧接着的 allocate_kv_cache() 会测量显存可用量、写回 config.num_kvcache_blocks,并在 GPU 上真实分配 self.kv_cache,再把每层 attention 的 KV 指针绑定到这块大缓存上。注意这里和 Engine 的配合也很关键:Engine 必须先构造 ModelRunner 让 config.num_kvcache_blocks 变成真实值,再创建 Scheduler/BlockManager,否则调度器根本不知道 KV 的上限在哪里。
2. 跨进程协同(IPC/RPC)
这里我们开始进入 nano-vLLM 里最“工程味”的部分:多进程 TP 的协同。目标其实很明确:每个 GPU 对应一个进程(一个 rank),所有 rank 共同参与一次前向计算(NCCL 通信),但只有 rank0 负责采样出 token id 并把结果返回给 LLMEngine。
回到 LLMEngine.__init__() 的装配过程:它用 torch.multiprocessing.get_context("spawn") 拿到 ctx,然后为 rank=1..tp-1 依次启动子进程,入口就是 ModelRunner(config, rank, event)(把类当作可调用对象使用,本质上就是执行一次 __init__ 完成装配并进入 worker 的常驻循环)。rank0 则在主进程里直接构造一个 ModelRunner,扮演 driver,用来发出 run/exit 指令并聚合结果。
这一点在代码里对应两条路径:主进程(rank0)会直接创建 ModelRunner(config, 0, self.events),作为 driver;子进程(rank>0)在执行完 __init__ 装配后,会进入一个常驻循环等待命令。
在进入“命令分发”之前,每个进程都必须先完成两件事:加入同一个 NCCL 进程组、并绑定到正确的 GPU。也就是你在 ModelRunner.__init__() 里看到的:
dist.init_process_group("nccl", "tcp://localhost:2333", world_size=self.world_size, rank=rank):以 TCP 地址做 rendezvous,把所有 rank 拉进同一个分布式组。torch.cuda.set_device(rank):约定 rank i 使用第 i 张卡(单机多卡最常见的写法)。
之后 Runner 会临时切换默认 dtype/device(例如 torch.set_default_dtype(hf_config.torch_dtype)、torch.set_default_device("cuda")),让模型权重、KV cache 等张量的创建落在正确的设备与精度上;末尾再恢复默认值,避免污染外部环境(前一节已经解释过这一点)。
def __init__(self, config: Config, rank: int, event: Event | list[Event]):
...
if self.world_size > 1:
if rank == 0:
self.shm = SharedMemory(name="nanovllm", create=True, size=2**20)
dist.barrier()
else:
dist.barrier()
self.shm = SharedMemory(name="nanovllm")
self.loop()
def exit(self):
if self.world_size > 1:
self.shm.close()
dist.barrier()
if self.rank == 0:
self.shm.unlink()
if not self.enforce_eager:
del self.graphs, self.graph_pool
torch.cuda.synchronize()
dist.destroy_process_group()
def loop(self):
while True:
method_name, args = self.read_shm()
self.call(method_name, *args)
if method_name == "exit":
break
def read_shm(self):
assert self.world_size > 1 and self.rank > 0
self.event.wait()
n = int.from_bytes(self.shm.buf[0:4], "little")
method_name, *args = pickle.loads(self.shm.buf[4:n+4])
self.event.clear()
return method_name, args
def write_shm(self, method_name, *args):
assert self.world_size > 1 and self.rank == 0
data = pickle.dumps([method_name, *args])
n = len(data)
self.shm.buf[0:4] = n.to_bytes(4, "little")
self.shm.buf[4:n+4] = data
for event in self.event:
event.set()
def call(self, method_name, *args):
if self.world_size > 1 and self.rank == 0:
self.write_shm(method_name, *args)
method = getattr(self, method_name, None)
return method(*args)LLMEngine 只需要调用 rank0 的 ModelRunner.call("run", ...),rank0 会把“命令”广播给所有 worker(rank>0),让它们也执行同一个 run(),从而满足 NCCL 并行计算“所有 rank 必须同时进入同一段分布式计算”的要求。
这段实现里最核心的机制是:rank0 用“共享内存 + Event”把命令广播给所有 worker;worker 永远在 loop() 里阻塞,等被唤醒后执行同名方法。配合 dist.barrier(),这套协议确保了一个对 NCCL 至关重要的性质:所有 rank 会以完全相同的顺序进入 collective,从而避免“某些 rank 进入下一次 all-reduce、另一些还停在上一次”的错位死锁。
你可以把它读成一个最小 RPC:
- 初始化阶段:rank0 创建具名共享内存(1MB),然后所有 rank
dist.barrier()对齐时序;rank>0 在 barrier 之后打开同名共享内存并进入loop()常驻。 - 写入阶段(rank0):
write_shm()把[method_name, *args]用 pickle 序列化,先写 4 字节长度头,再写 payload,最后event.set()唤醒所有 worker。 - 读取阶段(worker):
read_shm()先event.wait(),再按长度头读取 payload 并反序列化,清掉 event,返回method_name与参数。 - 分发阶段:
call()在 rank0 上先广播命令,再本地执行;在 rank>0 上只执行本地方法。这样一条ModelRunner.call("run", seqs, is_prefill)就能让所有 rank 同步进入同一次run()。
退出路径也遵循同样的“广播 + 同步”思路:rank0 广播 exit,worker 执行 exit() 后跳出循环;共享内存先 close(),再 barrier() 对齐,最后由 rank0 unlink() 删除命名对象,然后销毁进程组。
一个非常现实的约束:
Sequence必须可序列化因为 rank0 会把
seqs(Sequence列表)直接 pickle 后通过共享内存广播给 worker,所以Sequence里放的必须是可 pickle 的 Python 数据结构(token ids、block_table 等)。如果你往Sequence里塞 GPU tensor、句柄或不可序列化对象,这条协议就会立刻崩掉——工业级系统通常会用更“瘦”的 request 描述(id + 共享 buffer)来避免在控制面传重对象。
3. KV cache 分配
这里的核心思想是:先让 ModelRunner 在 GPU 上算出“最多能放多少 KV cache blocks”,把结果写回 config.num_kvcache_blocks,然后 Scheduler 再用这个上限去做 block 级别调度与抢占。
def allocate_kv_cache(self):
config = self.config
hf_config = config.hf_config
free, total = torch.cuda.mem_get_info()
used = total - free
peak = torch.cuda.memory_stats()["allocated_bytes.all.peak"]
current = torch.cuda.memory_stats()["allocated_bytes.all.current"]
num_kv_heads = hf_config.num_key_value_heads // self.world_size
head_dim = getattr(hf_config, "head_dim", hf_config.hidden_size // hf_config.num_attention_heads)
block_bytes = 2 * hf_config.num_hidden_layers * self.block_size * num_kv_heads * head_dim * hf_config.torch_dtype.itemsize
config.num_kvcache_blocks = int(total * config.gpu_memory_utilization - used - peak + current) // block_bytes
assert config.num_kvcache_blocks > 0
self.kv_cache = torch.empty(2, hf_config.num_hidden_layers, config.num_kvcache_blocks, self.block_size, num_kv_heads, head_dim)
layer_id = 0
for module in self.model.modules():
if hasattr(module, "k_cache") and hasattr(module, "v_cache"):
module.k_cache = self.kv_cache[0, layer_id]
module.v_cache = self.kv_cache[1, layer_id]
layer_id += 1这段 allocate_kv_cache() 做了三件事:测量“我还能用多少显存”、把 KV cache 的开销换算成“每个 block 多少字节”、最后据此算出能分配多少 block,并把一块大 KV tensor 绑定到每一层 attention 上。
第一步是显存测量。torch.cuda.mem_get_info() 给的是“整个 GPU 的空闲/总显存”(系统视角,包含其它进程);而 torch.cuda.memory_stats() 给的是“本进程的 PyTorch allocator 视角”(含 peak/current 等统计)。这两个视角要同时看,才能做出更稳一点的估算。
第二步是估算一个 KV block 的字节数:它与层数、block size、KV heads、head_dim、dtype 字节数成正比,最前面的 2 表示 K/V 两份缓存;TP 情况下 num_kv_heads 会按 world_size 切分(每张卡只存自己那份 heads)。
第三步是把“可用显存预算”除以 block_bytes 得到 config.num_kvcache_blocks。代码里用的是 total * utilization - used - (peak - current) 这种更保守的写法:它等价于在预算里额外扣掉 PyTorch 可能重新占用的缓存空间(peak-current),从而降低后续分配/运行时突然 OOM 的风险。
为什么不直接用
free?因为
free看见的是系统层面的空闲显存,但 PyTorch allocator 的缓存与碎片会影响“能否分配出足够大的连续块”。用peak-current做修正是一种常见的工程启发式:它让估算偏保守,从而换取更稳定的运行。
一旦 block 数确定,Runner 就会分配 self.kv_cache = torch.empty(...) 这一块大缓存,并遍历模型的子模块,把每层的 k_cache/v_cache 指到对应切片上。之后 attention 写 KV 就不需要再临时分配缓冲区了。
与 Scheduler/BlockManager 的关键配合:
- 在 LLMEngine 里,先创建
ModelRunner(config, 0, ...),它会在__init__中调用allocate_kv_cache()并写回config.num_kvcache_blocks;之后才创建Scheduler(config)。 - 而 Scheduler 在
__init__里会立刻构造BlockManager(config.num_kvcache_blocks, config.kvcache_block_size)。 所以这个顺序是刻意的:必须先“测出并写回 blocks 上限”,再初始化调度器,否则 BlockManager 的容量就是错的,会直接影响能否 allocate/append/preempt。
4. prefill 输入打包
prefill 的目标:一次性把多条序列的“尚未缓存的 prompt tokens”送进模型,让模型建立/填充 KV cache。
def prepare_block_tables(self, seqs: list[Sequence]):
"""
将一批seqs的block table填充至相同长度,并转换为GPU tensor,
供后续注意力计算使用(在需要访问已缓存KV的场景)
"""
# 计算所有序列中block table的最大长度(每个序列所占用的物理块数)
max_len = max(len(seq.block_table) for seq in seqs)
# 对每个seq,将其block table复制,并在末尾填充-1表示无效块
block_tables = [seq.block_table + [-1] * (max_len - len(seq.block_table)) for seq in seqs]
# 将二维列表转换为torch.int32类型的tensor,先固定在pinned memory,再异步复制到GPU
block_tables = torch.tensor(block_tables, dtype=torch.int32, pin_memory=True).cuda(non_blocking=True)
return block_tables
def prepare_prefill(self, seqs: list[Sequence]):
input_ids = []
positions = []
cu_seqlens_q = [0]
cu_seqlens_k = [0]
max_seqlen_q = 0
max_seqlen_k = 0
slot_mapping = []
block_tables = None
for seq in seqs:
seqlen = len(seq)
input_ids.extend(seq[seq.num_cached_tokens:])
positions.extend(list(range(seq.num_cached_tokens, seqlen)))
seqlen_q = seqlen - seq.num_cached_tokens
seqlen_k = seqlen
cu_seqlens_q.append(cu_seqlens_q[-1] + seqlen_q)
cu_seqlens_k.append(cu_seqlens_k[-1] + seqlen_k)
max_seqlen_q = max(seqlen_q, max_seqlen_q)
max_seqlen_k = max(seqlen_k, max_seqlen_k)
if not seq.block_table: # warmup
continue
for i in range(seq.num_cached_blocks, seq.num_blocks):
start = seq.block_table[i] * self.block_size
if i != seq.num_blocks - 1:
end = start + self.block_size
else:
end = start + seq.last_block_num_tokens
slot_mapping.extend(list(range(start, end)))
if cu_seqlens_k[-1] > cu_seqlens_q[-1]: # prefix cache
block_tables = self.prepare_block_tables(seqs)
input_ids = torch.tensor(input_ids, dtype=torch.int64, pin_memory=True).cuda(non_blocking=True)
positions = torch.tensor(positions, dtype=torch.int64, pin_memory=True).cuda(non_blocking=True)
cu_seqlens_q = torch.tensor(cu_seqlens_q, dtype=torch.int32, pin_memory=True).cuda(non_blocking=True)
cu_seqlens_k = torch.tensor(cu_seqlens_k, dtype=torch.int32, pin_memory=True).cuda(non_blocking=True)
slot_mapping = torch.tensor(slot_mapping, dtype=torch.int32, pin_memory=True).cuda(non_blocking=True)
set_context(True, cu_seqlens_q, cu_seqlens_k, max_seqlen_q, max_seqlen_k, slot_mapping, None, block_tables)
return input_ids, positionsprepare_prefill(seqs) 的结构是典型的“收集 Python 列表 → 转 GPU tensor”流程:
你可以把它分成三段来读:先在 Python 侧把“本批要算的 token”扁平化收集起来,再把这些列表转成 GPU tensor,最后把 attention 真正需要的寻址元信息塞进 context。
prepare_block_tables() 的作用很单一:把每条序列的 block_table pad 到相同长度(用 -1 补齐无效项),再转成 torch.int32 搬到 GPU。它只在“本批的 K 长度大于 Q 长度”时才需要(也就是存在已缓存前缀、attention 需要读取历史 KV 的场景),所以 prepare_prefill() 里会用 cu_seqlens_k[-1] > cu_seqlens_q[-1] 作为一个简单的触发条件。
prepare_prefill() 则是在构造一批 ragged prefill 的输入表示:
input_ids/positions:只收集每条序列“尚未缓存”的那段 token(从seq.num_cached_tokens开始),并为它们生成对齐的位置索引。cu_seqlens_q/cu_seqlens_k与max_seqlen_q/max_seqlen_k:用前缀和的形式描述 ragged batch(拼接后的每条序列在大扁平张量里占哪一段),分别对应 query(本轮新 token)与 key/value(全上下文)。slot_mapping:把“本轮要写入 KV 的每个新 token”映射到“KV cache 大数组里的物理槽位”。它依赖 Scheduler/BlockManager 事先分配好的seq.block_table:先定位到物理 block,再用block_size与 block 内 offset 计算线性槽位。
当这些列表准备好后,代码用 torch.tensor(..., pin_memory=True).cuda(non_blocking=True) 把它们搬到 GPU,再通过 set_context(...) 把 cu_seqlens/slot_mapping/block_tables 等元信息交给 attention 层读取。这样 forward 的函数签名可以保持简单,但 attention 仍然能拿到它需要的寻址上下文。
pin_memory=True+non_blocking=True在做什么
pin_memory=True会让 CPU 侧张量落在页锁定内存(更利于 DMA 传输);配合.cuda(non_blocking=True),H2D 拷贝可以更容易与 CPU 侧的下一步准备工作重叠。在 step 很密集、每步都要准备一堆小张量的推理场景里,这个小技巧经常能让吞吐更稳。
- CPU 利用率:对于需要连续准备数据并送 GPU 的场景(如训练循环),使用 pinned memory + 异步传输可以显著提高 CPU 有效利用率,整体性能可能提升 20%~50% 甚至更高(取决于计算与传输的比例)。
- 实际收益:如果数据量较小(如 < 1 MB),传输延迟本就可忽略,收益不大;但对于大尺寸张量(如大模型推理时的 KV 缓存、大批量输入),收益非常显著。
5. decode 输入打包
decode 的目标:对一批正在 running 的序列,各生成 下一个 token。因此每条序列本轮只需要喂一个 token(上一步生成的 token),并且要告诉模型“上下文长度”和“下一 token 应写到 KV cache 的哪个槽”。
def prepare_decode(self, seqs: list[Sequence]):
input_ids = []
positions = []
slot_mapping = []
context_lens = []
for seq in seqs:
input_ids.append(seq.last_token)
positions.append(len(seq) - 1)
context_lens.append(len(seq))
slot_mapping.append(seq.block_table[-1] * self.block_size + seq.last_block_num_tokens - 1)
input_ids = torch.tensor(input_ids, dtype=torch.int64, pin_memory=True).cuda(non_blocking=True)
positions = torch.tensor(positions, dtype=torch.int64, pin_memory=True).cuda(non_blocking=True)
slot_mapping = torch.tensor(slot_mapping, dtype=torch.int32, pin_memory=True).cuda(non_blocking=True)
context_lens = torch.tensor(context_lens, dtype=torch.int32, pin_memory=True).cuda(non_blocking=True)
block_tables = self.prepare_block_tables(seqs)
set_context(False, slot_mapping=slot_mapping, context_lens=context_lens, block_tables=block_tables)
return input_ids, positionsprepare_decode(seqs) 的数据组织更简单:
decode 的张量形状比 prefill 更规整:每条序列只输入 1 个 token,因此 input_ids/positions/context_lens/slot_mapping 都是长度为 batch_size 的一维数组。
关键在于 slot_mapping:它告诉 attention “这一步计算出来的 KV 应该写到 KV cache 的哪个槽位”。nano-vLLM 这里用的是最直接的线性寻址:取最后一个物理 block(seq.block_table[-1]),再加上最后一个 block 内的偏移(seq.last_block_num_tokens - 1),得到一个全局槽位索引。
decode 分支里每次都会准备 block_tables = prepare_block_tables(seqs) 并写入 context,因为 decode 的 attention 必须读取完整历史 KV,而历史 KV 分布在多个 block 上,离不开 block table 去做页表式寻址。
与 Scheduler 的配合点也在这里:Scheduler 在 decode 前调用 block_manager.may_append(seq),相当于把“我要追加 1 个 token”的资源与页表状态先准备好(必要时扩一个新 block,推进 last_block_num_tokens 等计数)。这样 Runner 才能在不做额外决策的情况下,直接用 seq.block_table/last_block_num_tokens 算出正确的 slot_mapping。
为什么 prefill 有时不需要
block_tables,decode 却几乎总需要?prefill 在“全新 prompt”场景下主要是写 KV(slot mapping 足够);只有当存在前缀缓存、需要读取历史 KV 时才需要 block table。decode 则每一步都要读取全部历史 KV,因此 block table 更像是 decode 的必需品。
6. 执行与采样
这一部分回答两个问题:模型到底怎么跑、token 怎么选出来。
def prepare_sample(self, seqs: list[Sequence]):
# 提取这一批seq的temperature参数
temperatures = []
for seq in seqs:
temperatures.append(seq.temperature)
# 转换成GPU tensor并异步复制
temperatures = torch.tensor(temperatures, dtype=torch.float32, pin_memory=True).cuda(non_blocking=True)
return temperatures
@torch.inference_mode()
def run_model(self, input_ids: torch.Tensor, positions: torch.Tensor, is_prefill: bool):
if is_prefill or self.enforce_eager or input_ids.size(0) > 512:
return self.model.compute_logits(self.model(input_ids, positions))
else:
bs = input_ids.size(0)
context = get_context()
graph = self.graphs[next(x for x in self.graph_bs if x >= bs)]
graph_vars = self.graph_vars
graph_vars["input_ids"][:bs] = input_ids
graph_vars["positions"][:bs] = positions
graph_vars["slot_mapping"].fill_(-1)
graph_vars["slot_mapping"][:bs] = context.slot_mapping
graph_vars["context_lens"].zero_()
graph_vars["context_lens"][:bs] = context.context_lens
graph_vars["block_tables"][:bs, :context.block_tables.size(1)] = context.block_tables
graph.replay()
return self.model.compute_logits(graph_vars["outputs"][:bs])
def run(self, seqs: list[Sequence], is_prefill: bool) -> list[int]:
input_ids, positions = self.prepare_prefill(seqs) if is_prefill else self.prepare_decode(seqs)
temperatures = self.prepare_sample(seqs) if self.rank == 0 else None
logits = self.run_model(input_ids, positions, is_prefill)
token_ids = self.sampler(logits, temperatures).tolist() if self.rank == 0 else None
reset_context()
return token_ids这三段函数合在一起,就是 Runner 的“最小数据面闭环”:
prepare_sample()从每条Sequence里抽取采样温度等参数(这里只实现了 temperature),搬到 GPU 上,供采样器使用。run_model()负责真正执行模型前向并返回 logits。它有两条路径:prefill 或大 batch / 强制 eager 时直接走 eager;decode 且小 batch 且允许时走 CUDA Graph replay(从graph_vars里读固定缓冲,写入本轮 input 与 context,再graph.replay())。run()把“输入打包 → forward 得到 logits → rank0 采样 token → reset_context 清理”串起来。尤其注意这里的 TP 语义:所有 rank 都会算 logits,但只有 rank0 负责采样并返回 token ids,其它 rank 返回None。
这也解释了 Runner 与 Scheduler 的职责分界:Runner 只做“算 + 采样”,不做“是否结束/何时回收”。请求状态机与 KV block 的回收完全由 Scheduler 在 postprocess() 里负责。
为什么 CUDA Graph 更适合 decode,而不是 prefill?
decode 每步输入形状更稳定、重复次数更多,CPU launch overhead 更容易成为瓶颈;CUDA Graph 把一串 kernel 调用固化下来,重放时能显著减少调度开销。prefill 的输入长度与 ragged 形状更动态(
cu_seqlens/max_seqlen等随 batch 波动),图的复用价值更低,反而更适合直接 eager。
7. 性能优化
这部分覆盖 capture_cudagraph()、run_model() 的图路径、以及 exit() 的释放顺序。它们共同目标是:decode 阶段小 batch 高频调用时减少 Python 与 kernel launch 的开销,同时保证退出干净。
@torch.inference_mode()
def capture_cudagraph(self):
config = self.config
hf_config = config.hf_config
max_bs = min(self.config.max_num_seqs, 512)
max_num_blocks = (config.max_model_len + self.block_size - 1) // self.block_size
input_ids = torch.zeros(max_bs, dtype=torch.int64)
positions = torch.zeros(max_bs, dtype=torch.int64)
slot_mapping = torch.zeros(max_bs, dtype=torch.int32)
context_lens = torch.zeros(max_bs, dtype=torch.int32)
block_tables = torch.zeros(max_bs, max_num_blocks, dtype=torch.int32)
outputs = torch.zeros(max_bs, hf_config.hidden_size)
self.graph_bs = [1, 2, 4, 8] + list(range(16, max_bs + 1, 16))
self.graphs = {}
self.graph_pool = None
for bs in reversed(self.graph_bs):
graph = torch.cuda.CUDAGraph()
set_context(False, slot_mapping=slot_mapping[:bs], context_lens=context_lens[:bs], block_tables=block_tables[:bs])
outputs[:bs] = self.model(input_ids[:bs], positions[:bs]) # warmup
with torch.cuda.graph(graph, self.graph_pool):
outputs[:bs] = self.model(input_ids[:bs], positions[:bs]) # capture
if self.graph_pool is None:
self.graph_pool = graph.pool()
self.graphs[bs] = graph
torch.cuda.synchronize()
reset_context()
self.graph_vars = dict(
input_ids=input_ids,
positions=positions,
slot_mapping=slot_mapping,
context_lens=context_lens,
block_tables=block_tables,
outputs=outputs,
)capture_cudagraph() 是 decode 性能优化的“准备阶段”:它提前为一组常见 batch size 捕获 CUDA Graph,并预分配一批固定形状的输入/输出缓冲(graph_vars)。这样在真正 decode 时,run_model() 只需要把本轮的 input_ids/positions 和 context(slot_mapping/context_lens/block_tables)写进这些固定缓冲,然后 graph.replay() 就能以很低的 CPU 调度开销重放整段前向。
这里有三个实现细节值得抓住:
第一,分桶捕获。self.graph_bs = [1,2,4,8] + [16,32,...] 是一种折中:它覆盖常见小 batch,又避免为每个整数 bs 都捕获一张图(那会浪费时间与显存)。运行时用“最小的 >=bs 的桶”来复用图。
第二,固定形状缓冲与内存池复用。Graph 捕获要求内存地址稳定,因此 input_ids/positions/block_tables/outputs 都要提前分配,之后只能做原地写入。代码从大到小遍历 bs,是为了让 graph_pool 一次性分配到足够大,后续小图复用同一池,减少碎片与重复分配。
第三,context 的传递方式。图路径不会直接读取 get_context() 返回的对象,而是把其中的数据拷贝进 graph_vars 的固定张量里,再 replay。也就是说,图模式下的“上下文输入”本质是“写入固定缓冲”,而不是“动态对象引用”。
和 Engine 的配合也很直接:Engine 通过 rank0 的 call("exit") 广播退出,让所有 worker 清理共享内存、释放 cudagraph 相关资源,并销毁进程组;主进程最后 join() 等待 worker 干净退出。
退出顺序为什么是“shm → cudagraph → synchronize → destroy_process_group”
核心目标是避免资源仍在被使用时就提前销毁:先 barrier 对齐并由 rank0
unlink掉共享内存;再释放 graph/内存池引用;再torch.cuda.synchronize()等所有 kernel/通信完成;最后销毁进程组,让 NCCL 资源干净落地。
KV Cache Block Manager
Engine 的三段式协议决定了一个事实:Scheduler 能否构造“可执行 batch”,最终取决于两类东西是否一致——请求的逻辑进度(Sequence)与 KV cache 的物理映射(BlockManager)。这也是为什么在 serving 系统里,Sequence/BlockManager 往往是最“像操作系统”的部分:它们在逻辑世界(token 序列)与物理世界(KV pages/blocks)之间做映射与资源管理。
把 schedule() → run() → postprocess() 的契约再对齐一遍,你就能看出上层到底需要它们提供什么。
Sequence 是请求状态的载体,它既要让 Scheduler 看见“这个请求现在到哪了”(状态机、长度、stop 规则),也要让 Runner 拿到“该怎么打包输入以及怎么寻址 KV”(未缓存 token、last token、block table 等)。其中最关键的一类信息是缓存进度:num_cached_tokens/num_cached_blocks 决定 prefill 需要从哪里开始算,也决定 prefix cache 与抢占恢复能不能成立。
BlockManager 则是 KV cache 的资源管理器。Scheduler 会用它做三类判定与动作:prefill 前能否分配(can_allocate/allocate),decode 前能否追加(can_append/may_append),以及 finish/preempt 时的回收(deallocate)。Runner 不直接调用 BlockManager,但它完全信任 BlockManager 通过 seq.block_table 暴露出来的“页表”——只要页表或计数不变量错了,attention 的寻址就会直接写错 KV。
如果不看代码、只从设计出发,Sequence/BlockManager 最重要的一条启发式其实来自 decode 的时序:写入 KV 的对象是“当前输入 token”(last token),而不是“本步采样出来的新 token”。新 token 会在下一步成为输入时才被写进 KV。你后面会看到 nano-vLLM 的 may_append() 正是利用了这个事实,把“是否需要新 block/是否要固化 hash”提前做在 Runner 之前,从而保证 slot_mapping 总能写到正确的槽位。
往工业界对齐时,这层的演进也很有规律:
- 当上层从 non-streaming 走向 streaming,Sequence 需要引入“增量游标”(例如已交付到哪里),否则你无法把 completion token 变成可重复交付的事件流。
- 当上层做 continuous batching / chunked prefill,Sequence 的进度不再是一个
num_cached_tokens就够,而需要更细粒度的“已计算/待计算”状态;BlockManager 往往也会引入 lookahead slots 等机制来降低调度与执行的耦合。 - 当 prefix caching 变成跨请求的核心能力,BlockManager 会从“简单 hash + refcount”变成更完整的缓存管理器:eviction(LRU/clock)、更严格的 Copy-on-Write、以及更复杂的前缀索引结构(vLLM 偏 hash-based,SGLang 常见 radix tree)。
- 当 preemption 更真实(swap/offload/压缩),BlockManager 需要表达的不再只是 free/used,而是更丰富的内存层级状态;Scheduler 也会随之变成 memory-aware / cache-aware。
回到 nano vLLM
如果把 KV cache 看成一段“按 token 轴增长的内存”,BlockManager 做的事情就像一个极简的页式内存管理:把 token 轴切成固定大小的 block(page),用 block_table 把逻辑块映射到物理块 id,并在这个映射之上实现分配、复用、追加与回收。
在 nano-vLLM 里,它的职责可以更具体地表述为四件事:
- 分配:prefill 前为
Sequence建立block_table(每个逻辑块分配一个物理块 id)。 - 复用:如果某段前缀块内容完全一致,就用 refcount 共享同一个物理块(prefix caching 的雏形)。
- 追加:decode 过程中根据块边界决定“是否需要新块/是否刚填满一个块”,并维护这些状态不变量。
- 回收:序列完成或被抢占时,递减 refcount,归零的物理块回到 free list。
你会发现这和 vLLM PagedAttention 的核心思想高度同构:固定大小的 block pool + free list、用 refcount 支持共享、用内容指纹(hash)做前缀匹配(nano 选择 hash-based;SGLang 常见 radix tree 这条路线则更偏显式结构)。
1. Block 的定义
class Block:
def __init__(self, block_id):
self.block_id = block_id # 唯一的块id
self.ref_count = 0
self.hash = -1
self.token_ids = []
def update(self, hash: int, token_ids: list[int]):
"""更新块的哈希值和token列表
"""
self.hash = hash
self.token_ids = token_ids
def reset(self):
"""重置块状态,准备重新使用
"""
self.ref_count = 1 # 块被分配出去时默认被一个序列引用
self.hash = -1
self.token_ids = []Block 是一个“物理块”的抽象,它身上只有三类信息:引用计数、内容指纹、以及用于二次校验的 token 列表。ref_count 决定了共享块能不能被回收;hash 只在“整块满了”时才有效(不满块会继续变化,不适合作为稳定缓存单元);token_ids 则用来做 hash 命中的二次校验,避免哈希碰撞或陈旧映射导致错误复用。
2. BlockManager 初始化与 hash 的计算
class BlockManager:
def __init__(self, num_blocks: int, block_size: int):
self.block_size = block_size
self.blocks: list[Block] = [Block(i) for i in range(num_blocks)]
self.hash_to_block_id: dict[int, int] = dict()
self.free_block_ids: deque[int] = deque(range(num_blocks))
self.used_block_ids: set[int] = set()
@classmethod
def compute_hash(cls, token_ids: list[int], prefix: int = -1):
h = xxhash.xxh64()
if prefix != -1:
h.update(prefix.to_bytes(8, "little"))
h.update(np.array(token_ids).tobytes())
return h.intdigest()BlockManager 本身是一个典型的“池 + 索引”结构:free_block_ids 提供 O(1) 的空闲块分配,used_block_ids 记录哪些块处于使用中,hash_to_block_id 则提供“内容指纹 → 物理块”的查找,用于前缀复用。
compute_hash(token_ids, prefix=-1) 的设计体现了一个重要的工程取舍:它不是只对“当前块内容”做哈希,而是把前一块的 hash 混进来,形成链式哈希。这样第 i 块的指纹不仅取决于本块 token,也取决于从开头到 i-1 的整段前缀,能更准确地表达“前缀完全一致”这一复用条件。至于哈希算法,nano-vLLM 选了速度很快的非加密哈希 xxhash,并在命中时用 token_ids 做二次校验,把碰撞风险压到可接受范围。
3. 分配块:allocate 如何建立 block table,并如何推进 num_cached_tokens?
def _allocate_block(self, block_id: int) -> Block:
"""分配某个block的工具函数
"""
block = self.blocks[block_id]
assert block.ref_count == 0
block.reset()
self.free_block_ids.remove(block_id)
self.used_block_ids.add(block_id)
return self.blocks[block_id]
def can_allocate(self, seq: Sequence) -> bool:
return len(self.free_block_ids) >= seq.num_blocks
def allocate(self, seq: Sequence):
# 确保该序列当前未被分配任何block table
assert not seq.block_table
h = -1
cache_miss = False
for i in range(seq.num_blocks):
token_ids = seq.block(i)
h = self.compute_hash(token_ids, h) if len(token_ids) == self.block_size else -1
block_id = self.hash_to_block_id.get(h, -1)
if block_id == -1 or self.blocks[block_id].token_ids != token_ids:
cache_miss = True
if cache_miss:
block_id = self.free_block_ids[0]
block = self._allocate_block(block_id)
else:
seq.num_cached_tokens += self.block_size
if block_id in self.used_block_ids:
block = self.blocks[block_id]
block.ref_count += 1
else:
block = self._allocate_block(block_id)
if h != -1:
block.update(h, token_ids)
self.hash_to_block_id[h] = block_id
seq.block_table.append(block_id)allocate(seq) 是把一个“逻辑序列”挂到“物理 KV 页面”上的第一步:它遍历该序列的每个逻辑块,为它填满 seq.block_table,并在可能的情况下把前缀命中缓存(推进 seq.num_cached_tokens)。
这里有三条关键不变量:
allocate()返回后,len(seq.block_table) == seq.num_blocks,每个逻辑块 i 都能通过seq.block_table[i]找到对应的物理 block。- 只有发生 prefix cache hit(命中历史满块)时,才会推进
seq.num_cached_tokens。 - 只有“整块满了”的块才会参与缓存:不满块的
h = -1,因为它未来还会被追加、内容不稳定,缓存价值与正确性都不可靠。
把循环逻辑按语义读一遍会更清晰:每次取出 token_ids = seq.block(i),如果这是一个满块,就计算链式 hash h,再用 hash_to_block_id 查有没有历史块命中。命中时还会用 self.blocks[block_id].token_ids == token_ids 做二次校验,避免碰撞或陈旧映射。未命中(或二次校验失败)就从 free_block_ids 分配一个新物理块;命中则共享该物理块(必要时增加 ref_count),并把 seq.num_cached_tokens 按 block_size 推进。
命中时 block.update(h, token_ids) 之所以同时写 hash 与 token_ids,是因为 BlockManager 维护的是“物理块视角”的内容:在 miss 或“命中但该物理块当前并未处于 used”时,这个 block 可能刚被 reset,内部没有 token_ids;只有当命中且被其它序列共享使用时,物理块上才已经存着正确的 token_ids。统一用 update() 写一遍,可以把两种来源的状态收敛到一致。
这也带来一个很有用的性质:num_cached_tokens 只按整块累计,因此 num_cached_blocks = num_cached_tokens // block_size 永远是整数。这让 Runner 在 prefill 时能安全地跳过缓存前缀,而不会出现“块内对齐”被打乱的问题。
allocate() 与 prepare_prefill() 之间还有一条非常关键的“跨模块契约”:为什么 Runner 构造的 slot_mapping 一定能指到正确的 KV 槽位?答案是它依赖两件事同时成立:seq.block_table 给出了逻辑块到物理块的页表,seq.num_cached_tokens 则告诉 Runner “前缀有多少 token 已经拥有可复用的 KV”。
因此 prefill 打包时会出现两个对齐动作:
- 输入侧:只把未缓存的 token 送进模型(
seq[seq.num_cached_tokens:]),缓存前缀不再作为 query 参与计算。 - 写入侧:只为未缓存区间生成
slot_mapping,并且从第一个未缓存块开始(range(seq.num_cached_blocks, seq.num_blocks)),把每个物理 block 的线性槽位区间串起来。
如果你把“逻辑 token 下标 p”映射到“线性槽位 slot”的规则写成公式,会发现它非常简单:
block_id = seq.block_table[p // block_size]offset = p % block_sizeslot = block_id * block_size + offset
slot_mapping 做的事情就是:把“未缓存区间”的这些 slot 按 token 顺序拼成一个数组,交给 attention/kv 写入逻辑使用。也因此,num_cached_tokens 必须按整块推进:只要缓存前缀在块边界对齐,slot 的拼接顺序就不会错位。
当存在 prefix cache 时,Runner 还会额外准备 block_tables(pad 成矩阵传给模型),让 attention 在读取历史 KV 时能通过页表寻址。你在 prefill 路径里看到的 cu_seqlens_k > cu_seqlens_q 判断,本质就是在检测“key/value 的上下文长度比本轮 query 更长”,也就是存在一段来自缓存的历史 KV。
4. decode 中如何将新 token 正确添加到 block 中?
decode 路径里最容易让人迷惑的一点是:为什么 Scheduler 要在调用 ModelRunner 之前先做 block_manager.may_append(seq),以及 prepare_decode 的 slot_mapping 如何保证永远指向“本轮要写 KV 的那个槽位”?
你可以把每一轮有 prefix caching 的 decode 理解成:对每条 seq,把它“当前最后一个 token”喂进模型,得到“下一个 token”,然后把新 token append 到 seq。因此:
- 本轮模型输入的 token 是最后一个 token:
input_ids.append(seq.last_token) - 本轮需要写 KV 的位置,正是这个
last_token对应的位置(也就是 position =len(seq) - 1的那一格)
于是 prepare_decode 里:
positions.append(len(seq) - 1):当前最后 token 的位置slot_mapping.append(seq.block_table[-1] * block_size + seq.last_block_num_tokens - 1):seq.block_table[-1]取最后一个逻辑块对应的物理块 idseq.last_block_num_tokens - 1就是“最后一个 token 在最后一个块内的偏移”- 两者合成“最后 token 的线性槽位”
那么 may_append(seq) 在 decode 前做什么?它的作用是维护两个不变量:
def can_append(self, seq: Sequence) -> bool:
return len(self.free_block_ids) >= (len(seq) % self.block_size == 1)
def may_append(self, seq: Sequence):
block_table = seq.block_table
last_block = self.blocks[block_table[-1]]
if len(seq) % self.block_size == 1:
assert last_block.hash != -1
block_id = self.free_block_ids[0]
self._allocate_block(block_id)
block_table.append(block_id)
elif len(seq) % self.block_size == 0:
assert last_block.hash == -1
token_ids = seq.block(seq.num_blocks-1)
prefix = self.blocks[block_table[-2]].hash if len(block_table) > 1 else -1
h = self.compute_hash(token_ids, prefix)
last_block.update(h, token_ids)
self.hash_to_block_id[h] = last_block.block_id
else:
assert last_block.hash == -1把 may_append() 放在 Runner 之前,本质是在维护两个“寻址不变量”:
1)当我们即将写入某个 token 的 KV 时,seq.block_table[-1] 必须指向当前应写入的物理块;否则 slot_mapping 会写错块。
2)当某个物理块被“写满”后,它必须被固化为可缓存单元(计算 hash、写入索引),否则后续 prefix cache 就无从谈起。
代码里用 len(seq) % block_size 这一个模运算就把块边界状态机分成三种情况:
- 余数为 1:序列刚跨过整块边界,进入一个新块的第一个 token。此时必须先分配一个新物理块并
block_table.append(new_block_id),保证后续slot_mapping能写到新块上。也正因如此,can_append(seq)只在这个时刻要求 free list 至少有 1 个块(这里利用了 Python 的布尔值可当作 0/1 参与比较:(cond)在需要新块时为 1,否则为 0)。 - 余数为 0:刚好填满一个块。此时不需要新块,但需要把这个满块计算 hash 并写入
hash_to_block_id,使其未来可作为 prefix cache 复用;链式哈希会把前一块的 hash 混进来,保证“整段前缀一致”才会命中。 - 其它余数:块未满。此时不做结构性变化,只要求
last_block.hash == -1(不满块不应带 hash)。
换句话说:may_append() 是把“页表更新 + 缓存索引维护”提前到执行之前做完,从而让 Runner 能在不做任何资源决策的前提下,直接把 slot_mapping 算对、把 KV 写对。
5. 回收块
def _deallocate_block(self, block_id: int) -> Block:
assert self.blocks[block_id].ref_count == 0
self.used_block_ids.remove(block_id)
self.free_block_ids.append(block_id)
def deallocate(self, seq: Sequence):
for block_id in reversed(seq.block_table):
block = self.blocks[block_id]
block.ref_count -= 1
if block.ref_count == 0:
self._deallocate_block(block_id)
seq.num_cached_tokens = 0
seq.block_table.clear()Scheduler 在序列处理 FINISHED 或被抢占时会调用 BlockManager.deallocate(seq),它做三件事:
第一步是按 seq.block_table 逆序递减 refcount:只有当某个物理块的 ref_count 归零,它才真正回到 free_block_ids。这正是共享块能安全释放的必要条件——最后一个引用者离开之后才能回收。
第二步是把 Sequence 上与物理映射相关的字段清空:seq.block_table.clear() 与 seq.num_cached_tokens = 0。这里看似“粗暴”,但它体现了 serving 系统里常见的正确性优先策略:一旦物理块被回收,旧页表就可能指向已经被重用的块;一旦 KV 被释放,所谓“已缓存前缀”也就不再成立。把这两项清零,强制后续重新走 allocate/prefill 去建立 KV,宁可多算,也不允许写错 KV。
Sequence
Sequence 模块的定位是:把“一条生成请求”的所有可调度状态和与 KV-cache 分块相关的信息封装成一个轻量、可序列化(可跨进程传输)的对象。Scheduler 只需要操作 Sequence(入队、出队、抢占、结束),ModelRunner 只需要读取 Sequence(打包输入、计算 slot_mapping),BlockManager 只需要更新 Sequence 的两个关键字段(block_table、num_cached_tokens)来维护前缀复用与物理块映射。
class SequenceStatus(Enum):
"""seq的状态,通过auto自动分配枚举值
"""
WAITING = auto()
RUNNING = auto()
FINISHED = auto()
class Sequence:
block_size = 256
counter = count()
def __init__(self, token_ids: list[int], sampling_params = SamplingParams()):
self.seq_id = next(Sequence.counter)
self.status = SequenceStatus.WAITING
self.token_ids = copy(token_ids)
self.last_token = token_ids[-1]
self.num_tokens = len(self.token_ids)
self.num_prompt_tokens = len(token_ids)
self.num_cached_tokens = 0
self.block_table = []
self.temperature = sampling_params.temperature
self.max_tokens = sampling_params.max_tokens
self.ignore_eos = sampling_params.ignore_eosSequenceStatus 定义了请求的生命周期状态机:Scheduler 在 schedule() 中把 WAITING → RUNNING,在 postprocess() 中把 RUNNING → FINISHED,在 preempt() 中把 RUNNING → WAITING。Engine 本身不推进状态机,它只负责驱动 step;真正的推进与回收都发生在 Scheduler/BlockManager 侧。
而 Sequence 这个类之所以重要,是因为它把“请求的逻辑状态”与“KV 的物理映射”放在同一个可序列化对象里,让三个模块能用最小信息闭环:
- Engine/Scheduler 关心的是身份与生命周期(
seq_id/status)、停止条件(max_tokens/ignore_eos)以及长度进度(num_tokens/num_prompt_tokens)。 - Runner 关心的是打包输入所需的 token(prefill 用
token_ids[num_cached_tokens:],decode 用last_token)以及 KV 寻址元信息(block_table/last_block_num_tokens)。 - BlockManager 关心的是两项可被调度与复用的“资源口径”:
block_table与num_cached_tokens。
block_size的一致性非常关键这里把
Sequence.block_size写死成了256。如果你在 Config/BlockManager 里把kvcache_block_size改成别的值(例如 512),而Sequence的num_blocks/last_block_num_tokens/block(i)仍然按 256 计算,那么slot_mapping会系统性错位。工程上一般会让这些 block_size 都从同一份配置派生,避免出现“双口径”的隐蔽 bug。
Sequence 重要的派生属性:
def __len__(self):
return self.num_tokens
def __getitem__(self, key):
return self.token_ids[key]
@property
def is_finished(self):
return self.status == SequenceStatus.FINISHED
@property
def num_completion_tokens(self):
return self.num_tokens - self.num_prompt_tokens
@property
def prompt_token_ids(self):
return self.token_ids[:self.num_prompt_tokens]
@property
def completion_token_ids(self):
return self.token_ids[self.num_prompt_tokens:]
@property
def num_cached_blocks(self):
return self.num_cached_tokens // self.block_size
@property
def num_blocks(self):
return (self.num_tokens + self.block_size - 1) // self.block_size
@property
def last_block_num_tokens(self):
return self.num_tokens - (self.num_blocks - 1) * self.block_size
def block(self, i):
assert 0 <= i < self.num_blocks
return self.token_ids[i*self.block_size: (i+1)*self.block_size]
这些派生属性的价值在于:把“token 序列的逻辑视图”包装成一个对调度与执行都友好的接口。
__len__/__getitem__ 让 Sequence 像列表一样可切片(例如 seq[seq.num_cached_tokens:]),这正是 prefill 打包时最自然的写法。is_finished 则是 Engine 层收割完成序列时用到的语义封装。
输出相关的属性(prompt_token_ids/completion_token_ids、num_completion_tokens)把 prompt 与生成部分分开,让 Engine 返回时不需要再写重复的切片逻辑。
更关键的是分块相关的属性:num_blocks/last_block_num_tokens/num_cached_blocks/block(i) 把“块边界”这件事统一到一个口径上。只要这些口径与 BlockManager 的 block_size 一致,那么:
- BlockManager 在
allocate/may_append中不需要重复推导块内偏移; - Runner 也只需要信任
block_table与这些派生属性,就能把 token 正确映射到 KV cache 槽位。
Sequence 状态推进的最小原子操作:
def append_token(self, token_id: int):
self.token_ids.append(token_id)
self.last_token = token_id
self.num_tokens += 1append_token() 把本轮 ModelRunner.run() 采样得到的 token_id 加入序列。Scheduler 的 postprocess() 逐个 (seq, token_id) 调用 seq.append_token(token_id)。
为什么放在 Sequence 内:确保 token_ids、last_token、num_tokens 三者始终一致(避免外部忘改某个字段)。
这一步之后,Sequence 的 num_blocks、last_block_num_tokens 可能发生变化,从而触发 BlockManager 在 decode 前的 may_append(seq) 行为(是否需要新块、是否需要固化 hash 等)。
为多进程的共享内存传输优化:
def __getstate__(self):
return (self.num_tokens, self.num_prompt_tokens, self.num_cached_tokens, self.block_table,
self.token_ids if self.num_completion_tokens == 0 else self.last_token)
def __setstate__(self, state):
self.num_tokens, self.num_prompt_tokens, self.num_cached_tokens, self.block_table = state[:-1]
if self.num_completion_tokens == 0:
self.token_ids = state[-1]
else:
self.last_token = state[-1]这部分是理解 “为什么 worker 进程能拿到 Sequence” 的关键——ModelRunner 的 rank0 会把要执行的方法名与参数通过 pickle 写进共享内存,worker 读取后 pickle.loads(...) 得到 seqs 并执行 run(seqs, ...)。因此 Sequence 必须可 pickle。
Sequence 在这里做了一个非常现实的工程优化:自定义 pickling 行为,把“跨进程要传的状态”压缩到最小。__getstate__ 返回一个五元组:
(num_tokens, num_prompt_tokens, num_cached_tokens, block_table, payload)。
其中 payload 会随阶段变化:如果还没进入 decode(num_completion_tokens == 0),就把完整 token_ids 传过去,让 worker 能正确打包 prefill 的 ragged 输入;一旦进入 decode,就只传 last_token,因为 decode 每步只需要一个 token,继续传整段 token_ids 会让 IPC 成本线性膨胀。
对应地,__setstate__ 会按同样规则恢复对象:prefill 阶段恢复完整 token 列表,decode 阶段只恢复 last token。你可以把它理解为:prefill 保真、decode 极致减载——这是 nano-vLLM 用共享内存广播“重对象参数”时,最关键的一个成本控制点。