夜雨聆风学习资料网

ARTICLE · 1301187

《Nano-vLLM源码逐行解析》第2篇 sequence.py、scheduler.py

《Nano-vLLM源码逐行解析》第2篇 sequence.py、scheduler.py

前文图文版讲清了nano-vllm的架构和概念,但参数为什么这么设、张量形状怎么变、流程如何串起来,只有逐行读源码才能理解。这个系列贴出逐行中文注释的源码,建议和图文版对照阅读。

注释版源码:https://github.com/llm-infer/nano-vllm-annotated

nano-vllm 代码结构

  • • nanovllm/:
    • • llm.py:对外接口LLM,直接继承LLMEngine
    • • config.py:引擎级配置(调度上限、显存比例、KV block等)
    • • sampling_params.py:请求级采样参数(temperature、最大生成长度、是否忽略 EOS)
  • • engine/:推理引擎核心,负责调度、kv cache管理和模型执行
    • • llm_engine.py:推理入口,自己不做计算,把 tokenizer、scheduler、model_runner 串起来
    • • scheduler.py:调度器,决定每个step计算哪些序列、各算多少token👈 本篇
    • • sequence.py:一个请求对应一条序列,记录token、状态和 block table👈 本篇
    • • block_manager.py:在 CPU 上管理 KV block 的分配、回收和前缀缓存
    • • model_runner.py:每张 GPU 一个,负责加载模型、分配 KV cache、执行前向和采样
  • • layers/:模型的基础组件,大多支持张量并行
    • • attention.py:用 Triton kernel把K/V写入KV cache,再调用 FlashAttention
    • • linear.py:按列、按行切分的并行线性层
    • • embed_head.py:词表并行的Embedding和LM Head
    • • rotary_embedding.py:旋转位置编码RoPE
    • • layernorm.py:RMSNorm
    • • activation.py:SwiGLU 激活
    • • sampler.py:按温度从 logits 中采样下一个token
  • • models/:具体的模型结构
    • • qwen3.py:Qwen3 模型定义
  • • utils/:工具
    • • context.py:一次前向所需的注意力元数据,避免层层传参
    • • loader.py:从 safetensors 文件加载权重到模型

sequence.py

from copy import copyfrom enum import Enum, autofrom itertools import countfrom nanovllm.sampling_params import SamplingParams# 序列的生命周期状态# auto():自动给枚举成员分配值(1、2、3...),只关心名字不关心值时使用# 状态流转图:##   add_request#       │#       ▼#   ┌─────────┐   prefill算完整个prompt     ┌─────────┐   生成EOS或达到max_tokens     ┌──────────┐#   │ WAITING │ ─────────────────────────> │ RUNNING │ ──────────────────────────> │ FINISHED │#   └─────────┘                            └─────────┘                             └──────────┘#     ▲   │ chunked prefill没算完,                │#     │   └─ 留在WAITING,下一步继续                │#     └──────────────────────────────────────────┘#           KV cache不够,被抢占(preempt),释放所有block,回到WAITING队头,之后重新prefillclassSequenceStatus(Enum):# 在等待队列中,还没做完prefill(请求刚进来、chunked prefill做了一半、资源被抢占)    WAITING = auto()# prefill已完成,在运行队列中逐token decode    RUNNING = auto()# 遇到EOS或达到max_tokens,KV block已释放    FINISHED = auto()# 一条请求在引擎内部的表示:保存token、状态、KV block映射和采样参数# 各计数字段变化示例(block_size=256, prompt长600,前缀缓存命中1个block,num_max_batched_tokens=200)#   初始化:            num_tokens=600,num_prompt_tokens=600,num_cached_tokens=0,block_table=[]#   第1次调度:          num_cached_tokens=256(命中1个block),block_table=[5,9,12](三个block分别存放256+256+88=600个token)#                      num_scheduled_tokens=200(预算只有200,chunked prefill)#   第1次postprocess:  num_cached_tokens=456,num_scheduled_tokens=0,还没prefill完成,丢弃采样结果#   第2次调度:          num_scheduled_tokens=144(剩余600-456),状态变为RUNNING#   第2次postprocess:  num_cached_tokens=600,追加第1个生成token -> num_tokens=601#   之后每次decode:     num_scheduled_tokens=1,num_cached_tokens+1,num_tokens+1classSequence:# 类变量,所有实例共享# 读取self.block_size时,实例上没有,就去类上找    block_size = 256# 全局自增器,为每条序列分配唯一的id    counter = count()# 默认SamplingParams()只在函数定义时创建一次,所有未传参的调用共享一个SamplingParams()# 因为是只读sampling_params的字段,所以共享没有问题def__init__(self, token_ids: list[int], sampling_params = SamplingParams()):# 从0开始自增,也代表提交顺序self.seq_id = next(Sequence.counter)# 新序列状态为waitingself.status = SequenceStatus.WAITING# 全部的token id:prompt+已生成的token# 浅拷贝:后续append_token不影响调用方传进来的listself.token_ids = copy(token_ids)# 最后一个token,decode时作为输入,计算KVself.last_token = token_ids[-1]# 当前总token数(prompt + 已生成)self.num_tokens = len(self.token_ids)# prompt的token数,区分prompt和生成部分self.num_prompt_tokens = len(token_ids)# 已算好kv并写入kv cache的token数# 来源:前缀缓存命中的部分+本序列step计算过的部分(prefill/decode)self.num_cached_tokens = 0# 本次step需要计算的token数# prefill:本次处理的prompt长度(chunked prefill时可能小于prompt剩余长度)# decode:固定为1self.num_scheduled_tokens = 0# 是否处在prefill阶段self.is_prefill = True# kv 逻辑block -> kv 物理block映射表# block_table[i]代表第i个逻辑block对应的物理block id# 类似操作系统的页表,物理block在显存中不必连续# 例如:block_table=[5,9,12],表示第0~255个kv在物理block 5,第256~511个kv在物理block 9self.block_table = []# 请求可配置采样参数,将其拷贝到序列上,便于ModelRunner批量读取self.temperature = sampling_params.temperature# 此序列允许生成token的上限,达到上限后直接结束self.max_tokens = sampling_params.max_tokens# 是否忽略EOS继续生成self.ignore_eos = sampling_params.ignore_eos# 以双下划钱开头和结尾的方法叫‘魔法方法’,会在特定语法下自动调用# len(seq) 等价于 seq.__len__(),返回token总数def__len__(self):returnself.num_tokens# 获取某个或某段token,例如:seq[3] -> self.token_ids[3];seq[0:5] -> self.token_ids[0:5]# key可以是下标或切片对象def__getitem__(self, key):returnself.token_ids[key]# @property把方法变成‘只读属性’# 适用于由其他字段计算得到的值,每次访问都重新计算    @propertydefis_finished(self):returnself.status == SequenceStatus.FINISHED# 已生成的token数,判断是否达到max_tokens    @propertydefnum_completion_tokens(self):returnself.num_tokens - self.num_prompt_tokens    @propertydefprompt_token_ids(self):returnself.token_ids[:self.num_prompt_tokens]# 已生成的token,用于detokenizer生成文本,返回客户端    @propertydefcompletion_token_ids(self):returnself.token_ids[self.num_prompt_tokens:]# 当前token数需要占用多少kv block,向上取整# 新请求需占用block = num_blocks - 命中缓存的block数    @propertydefnum_blocks(self):return (self.num_tokens + self.block_size - 1) // self.block_size# 最后一个block存了多少token = 总token数 - 前num_blocks-1 个满block存的token数# 用于计算当前token存储在物理block上的偏移量    @propertydeflast_block_num_tokens(self):returnself.num_tokens - (self.num_blocks - 1) * self.block_size# 取第i个逻辑block对应的token ids,用于计算前缀缓存的hash# 超出长度时自动截断,例如block_size=256,num_tokens=600# block(2) -> token_ids[512,768],实际只有88个元素,即token_ids[512,599]defblock(self, i):assert0 <= i < self.num_blocksreturnself.token_ids[i*self.block_size: (i+1)*self.block_size]# decode每生成一个token,就追加到序列末尾defappend_token(self, token_id: int):self.token_ids.append(token_id)self.last_token = token_idself.num_tokens += 1# 自定义序列化字段# python序列化库pickle默认序列化所有字段(self.__dict__)# 定义了__getstate__后,pickle只序列化它的返回值;还原时把这个值传给__setstate__# 传输路径:rank 0负责调度指挥,把seq派发给其他rank#    rank 0:pickle.dumps(seq) -> 调用 seq.__getstate__() -> 字符串 -> 共享内存#    rank 1:共享内存 -> 字符串 -> pickle.loads -> 调用 __setstate__ -> seqdef__getstate__(self):# prefill:需要完整的token_ids来计算下一个token# decode:历史token的kv存在kv cache里,只需要最后一个token,避免每个step都传输整条序列,节省序列化开销        last_state = self.last_token ifnotself.is_prefill elseself.token_ids# rank只做前向计算,所以只发送前向需要的数据即可return (self.num_tokens, self.num_prompt_tokens, self.num_cached_tokens, self.num_scheduled_tokens, self.block_table, last_state)# 反序列化def__setstate__(self, state):self.num_tokens, self.num_prompt_tokens, self.num_cached_tokens, self.num_scheduled_tokens, self.block_table, last_state = state# prefill阶段last_state是完整的token_idsifisinstance(last_state, list):self.token_ids = last_stateself.last_token = self.token_ids[-1]else:# decode阶段last_state是上一个step生成的token idself.token_ids = []self.last_token = last_state

scheduler.py

from collections import dequefrom nanovllm.config import Configfrom nanovllm.engine.sequence importSequence, SequenceStatusfrom nanovllm.engine.block_manager import BlockManager# 调度器,决定每step计算哪些seq,各计算多少token# 策略:#   1. prefill优先:只要等待队列里有能调度的请求,本step就只做prefill#   2. 没有prefill可做时,对所有running的序列做一次decode,各生成1个token#   3. 同一个step里不会混合prefill和decode#   4. KV cache不够时,抢占最晚加入running的序列,释放其block,放回waiting队列等待重新prefill# 流程图:#          add()                    prefill算完                     结束(EOS/max_tokens)#   新请求 ───────>  ┌─────────┐  ────────────────────> ┌─────────┐  ──────────────────> 释放block,移出#                   │ waiting │                        │ running │#                   └─────────┘  <──────────────────── └─────────┘#                     ▲      │   preempt:放回waiting队头(appendleft)#                     └──────┘#                   chunked prefill未完成:留在waiting队头,下一步继续# 决策流程:#   ┌────────────────────── (1) prefill 阶段 ──────────────────────┐#   │ while waiting非空 且 未达max_num_seqs:                        │#   │     seq = waiting队头                                        │#   │     剩余token预算为0?是->跳出循环                              │#   │     首次调度 且 KV block不够?是->跳出循环                       │#   │     预算放不下整个seq 且 不是第一条?是->跳出循环                  │#   │     分配block(仅首次)                                        │#   │     本次计算min(剩余要算的token数,剩余预算)个token               │#   │     prompt全部算完?是->移入running队列                         │#   │     加入scheduled_seqs                                       │#   └──────────────────────────────────────────────────────────────┘#                      │#          scheduled_seqs非空? ── 是 ──> return (scheduled_seqs, True)#                      │ 否(没有能做的prefill)#                      ▼#   ┌────────────────────── (2) decode 阶段 ───────────────────────┐#   │ while running非空 且 未达max_num_seqs:                        │#   │     seq = 从running队头取出                                   │#   │     while KV block不够追加1个token:                           │#   │         running还有序列? 是 -> 抢占running队尾的序列            │#   │                          否 -> 抢占seq自己,跳过它             │#   │     (block够了)加入scheduled_seqs                           │#   └─────────────────────────────────────────────────────────────┘#                      │#                      ▼#   scheduled_seqs放回running队头,return (scheduled_seqs, False)classScheduler:def__init__(self, config: Config):# 单step最多调度的序列数,主要影响decode性能#   相较于prefill,decode持续时间长,并发度很容易超过max_num_seqs#   decode step阶段读取一次权重,增加max_num_seqs,可以把权重读取成本摊薄#   max_num_seqs存在上限,由可用的kv block决定self.max_num_seqs = config.max_num_seqs# 单step最多计算的token数(主要约束prefill;decode每条序列1个token)self.max_num_batched_tokens = config.max_num_batched_tokens# 结束符的token id,由tokenizer提供self.eos = config.eos# 每个kv block可容纳的token数self.block_size = config.kvcache_block_size# num_kvcache_blocks在ModelRunner初始化时,根据剩余显存自动计算得到self.block_manager = BlockManager(config.num_kvcache_blocks, config.kvcache_block_size)# 等待prefill 的序列(新请求、chunked prefill未完成的、被抢占的)self.waiting: deque[Sequence] = deque()# 已完成prefill,处于decode阶段的序列self.running: deque[Sequence] = deque()# 空容器在条件判断中被当做Falsedefis_finished(self):returnnotself.waiting andnotself.runningdefadd(self, seq: Sequence):self.waiting.append(seq)# 返回本step需要计算的seq,以及是否为prefilldefschedule(self) -> tuple[list[Sequence], bool]:        scheduled_seqs = []# 记录本step消耗的token预算        num_batched_tokens = 0# prefill# 从waiting中取序列# prefill优先调度:每次调度优先看waiting队列whileself.waiting andlen(scheduled_seqs) < self.max_num_seqs:# 只看队头,不取出;能算完时才真正取出            seq = self.waiting[0]# 本step剩余的token预算            remaining = self.max_num_batched_tokens - num_batched_tokensif remaining == 0:break# 第一次被调度,或者被抢占后重新调度,此时还没申请block_tableifnot seq.block_table:# 检查block是否足够,并查询缓存命中情况                num_cached_blocks = self.block_manager.can_allocate(seq)# 返回-1,代表空闲block不够;队列后面的seq也不调度了,保持FIFO,防止队头饿死if num_cached_blocks == -1:break# 本次step需要计算的token = 总数 - 前缀缓存命中的token数                num_tokens = seq.num_tokens - num_cached_blocks * self.block_sizeelse:# 已经分配过block,说明是chunked prefill的后续块,继续计算剩余部分                num_tokens = seq.num_tokens - seq.num_cached_tokens# 剩余token预算不够用来调度此seq,而且前面已经有序列被选中,则把此seq留到下一个step# 为啥不用chunked prefill调度此seq?scheduled_seqs不为空,if remaining < num_tokens and scheduled_seqs:  # only allow chunked prefill for the first seqbreak# 一次性为整条序列分配所有block(包括本次还没算到的chunk)# 传入num_cached_blocks,不再为缓存的token申请blockifnot seq.block_table:self.block_manager.allocate(seq, num_cached_blocks)# 本次实际计算的token数,预算不足时只算一部分            seq.num_scheduled_tokens = min(num_tokens, remaining)            num_batched_tokens += seq.num_scheduled_tokens# 本次能把prompt全部算完,则移入running队列,下一个step开始decode# 否则继续留在waiting队头,下一个step继续算下一个chunkif seq.num_cached_tokens + seq.num_scheduled_tokens == seq.num_tokens:                seq.status = SequenceStatus.RUNNINGself.waiting.popleft()self.running.append(seq)            scheduled_seqs.append(seq)# 如果有可调度的prefill请求,则先执行prefill。prefill优先体现在这里if scheduled_seqs:return scheduled_seqs, True# decode# 每个running的序列各计算1个token# 抢占示例(running=[A,B,C,D],空闲block为0,每条序列刚好都需要新block)#   取A:block不够 -> 抢占队尾D(释放D的block)-> block够了 -> A被调度#   取B:block不够 -> 抢占队尾C -> 够了 -> B被调度#   running为空,退出循环,本轮调度A、B;C和D回到waiting队头等待重新prefillwhileself.running andlen(scheduled_seqs) < self.max_num_seqs:# 从队头中取出            seq = self.running.popleft()whilenotself.block_manager.can_append(seq):ifself.running:# 空闲block不够,不断抢占running队尾的seq,直到block够用self.preempt(self.running.pop())else:# 没有别的seq可以抢了,只能抢占自己self.preempt(seq)break# block足够,本序列可以参与本次decodeelse:                seq.num_scheduled_tokens = 1                seq.is_prefill = False# 检查是否需要申请新的block,追加到block_table# 当前block存满时,才需要追加self.block_manager.may_append(seq)                scheduled_seqs.append(seq)assert scheduled_seqs# 放回running队列,默认它们还会继续decode# 当seq遇到EOS或达到max_tokens时,会调用self.running.remove(seq)把它移除self.running.extendleft(reversed(scheduled_seqs))return scheduled_seqs, False# 抢占running状态的seq,释放kv block,放回waiting队列队头,下次schedule优先考虑它# 这里是重计算式抢占,而不是把KV换到CPU# 由于block释放后,hash仍保留,如果这些block没有被覆盖,重新prefill时仍有可能命中缓存defpreempt(self, seq: Sequence):        seq.status = SequenceStatus.WAITING        seq.is_prefill = Trueself.block_manager.deallocate(seq)self.waiting.appendleft(seq)# 模型forward和sampling之后的后处理:更新seq状态,追加新token,判断是否结束# seqs:本step调度的序列;token_ids:本step生成的token,与seqs一一对应defpostprocess(self, seqs: list[Sequence], token_ids: list[int], is_prefill: bool):# zip把两个列表按位置配对:(seqs[0], token_ids[0]), (seq[1], token_ids[1]), ...for seq, token_id inzip(seqs, token_ids):# 把本次计算填满的block登记到前缀缓存self.block_manager.hash_blocks(seq)# 本次计算的token的KV已写入cache            seq.num_cached_tokens += seq.num_scheduled_tokens            seq.num_scheduled_tokens = 0# chunked prefill还没算完整个prompt,此时计算出的token没有意义,直接丢弃if is_prefill and seq.num_cached_tokens < seq.num_tokens:continue            seq.append_token(token_id)# 结束条件:生成了EOS,或者达到最大长度# 释放kv block、从running队列中移除if (not seq.ignore_eos and token_id == self.eos) or seq.num_completion_tokens == seq.max_tokens:                seq.status = SequenceStatus.FINISHEDself.block_manager.deallocate(seq)self.running.remove(seq)

回首页,搜索你想要的文档

在首页搜索框输入关键词,快速定位资料

文库首页搜索指引 立即搜索

相关学习资料