对照 Nano-vLLM 实现:读懂 vLLM 调度

llminferencevllmpaged-attentionschedule

Nano-vLLM 是一个轻量级的大语言模型(LLM)推理引擎实现,代码非常简洁,仅有 1200 行的 Python 和 Triton 代码,旨在从头开始重建著名推理框架 vLLM 的核心架构。它的主要定位并非替代生产级的 vLLM,而是作为一个教育和学习工具,帮助开发者深入理解 vLLM 推理引擎的内部工作原理。

1 Nano-vLLM 调度机制

Nano-vLLM 的调度实现在 nanovllm/engine/scheduler.py 和 nanovllm/engine/block_manager.py 中。

1.1 基本原理

调度由两个角色分工完成,读代码时把它们分开看会清楚很多:

  • Scheduler 决定谁这轮跑、跑多少 token。它维护 waitingrunning 两个队列,只做排队决策,自己不碰一寸显存。
  • BlockManager 决定显存装不装得下。KV cache 被切成固定大小的块(默认 256 个 token 一块),它管着这些块的分配、释放、共享和回收。

调度器每想排一条序列,都得先问 BlockManager 一句「它的 KV 放得下吗」。放得下就排上,放不下就只能等,或者牺牲别人。

schedule() 的主干。 一次调度要么全是 prefill,要么全是 decode,两者不混批。关键在 Scheduler.schedule() 里 prefill 循环结束后那句提前 return——只要抓到了哪怕一条序列,就立刻返回,decode 分支根本不执行。

                        schedule()


            ┌───────────────────────────────┐
            │  prefill 循环                  │
            │  从 waiting 队头依次取序列       │
            │  受 max_num_seqs 和            │
            │     max_num_batched_tokens 限制 │
            └───────────────┬───────────────┘

                  ┌─────────┴─────────┐
        抓到了 ≥1 条              一条都没抓到
                  │                   │
                  ▼                   ▼
          return (seqs, True)  ┌──────────────────────┐
          ↑                    │  decode 循环          │
          │                    │  从 running 队头取     │
     prefill 有绝对优先权,      │  每条只算 1 个 token   │
     会打断正在进行的 decode     │  块不够 → 抢占队尾     │
                               └──────────┬───────────┘

                                 return (seqs, False)

两者之间的接口。 BlockManager 对外只有六个方法,调度的一轮就是按下面这个顺序把它们串起来:

  Scheduler                                       BlockManager
 ─────────────────────────────────────────────────────────────────────────────
  prefill 循环
    这条序列的 KV 装得下吗?     ──can_allocate(seq)──►  数要几个新块,
    顺带问:前缀有缓存吗?        ◄── -1 / 命中的块数 ───  同时查 hash_to_block_id
    装得下 → 分配               ──allocate(seq, n)───►  填 seq.block_table,
                                                        命中的块 ref_count += 1
    装不下 → break,继续等

  decode 循环
    再吐 1 个 token 还够吗?     ──can_append(seq)────►  只在跨块边界时才要
                               ◄──── True / False ───   新块,看 free_block_ids
    够 → 分配                   ──may_append(seq)────►  需要时补 1 个块
    不够 → 抢占队尾那条          ──deallocate(受害者)─►  归还它的全部块

  每轮算完 (postprocess)
    登记这轮填满的块            ──hash_blocks(seq)───►  写进 hash_to_block_id,
                                                        供后来者做 prefix cache

「装得下吗」在两个阶段问法不同,因为需求量差得远:prefill 要一次性安置整条 prompt,可能是几十个块;decode 每次只多算 1 个 token,最多只需要 1 个新块,而且只在正好跨过块边界时才需要。所以 can_allocate 返回的是 int(-1 表示装不下,否则是 prefix cache 命中的块数),can_append 返回的是 bool。

失败时的应对也不同。prefill 装不下就 break,序列继续在 waiting 里等下一轮——反正它还没开始算,不亏。decode 装不下却不能等,序列已经在跑了,所以调度器会从 running 队尾往前抢占别的序列,把它们的块全释放掉腾给当前这条。

BlockManager 的四个字段。 它一共只维护四份状态:

BlockManager

├── blocks: list[Block]           定长,创建后不增不删,下标就是 block_id
│      Block(i) {
│        ref_count   有几条序列在用这块 (0 = 空闲)
│        hash        这块对应的"前缀哈希",-1 表示未哈希
│        token_ids   这块装的 token,只用于防哈希碰撞
│      }

├── free_block_ids: deque[int]    空闲块号。popleft 取 / append 还 → FIFO

├── used_block_ids: set[int]      在用块号。只为 O(1) 回答"这块有人在用吗"

└── hash_to_block_id: dict        前缀哈希 → block_id,prefix cache 的索引

free_block_idsused_block_ids 严格互补,一个块在 used_block_ids 里当且仅当它的 ref_count > 0

后两个字段撑起了 prefix cache:ref_count 是块共享的基础——两条序列前缀相同就能指向同一个物理块,各自把 ref_count 加一,KV 在显存里只存一份;hash 则是找到这种共享机会的索引,它存的不是本块内容的哈希,而是「从头到本块」整段前缀的哈希(具体怎么串在 1.2.5 展开)。

这四份状态和调度的关系可以一句话概括:调度器看的是队列,BlockManager 看的是 free_block_ids 还剩几个。整篇文章后面的所有行为——为什么某轮只排得下三条、为什么一条刚跑起来的序列会被牺牲、为什么重算没想象中贵——归根到底都是这个数字在变。

1.2 演示例子

下面我们会通过演示一个例子说明进一步解释调度机制。

1.2.1 演示示例配置

真实默认值是 max_num_batched_tokens=16384max_num_seqs=512(见 Config),数字太大不好画。下面把它缩小成一组玩具参数,其余逻辑与代码完全一致:

max_num_batched_tokens = 1024      # 一个 batch 最多算多少 token
max_num_seqs           = 4         # 一个 batch 最多几条序列
kvcache_block_size     = 256       # 一个 KV block 装多少 token
num_kvcache_blocks     = 11        # 总共有多少物理块

六个请求,prompt 长度和它们各自需要的块数(ceil(len / 256),见 Sequence.num_blocks):

请求    prompt 长度    需要的块数
R1        300            2
R2        400            2
R3        255            1
R4       1200            5
R5        200            1      ← 第 5 轮才到达

1.2.2 调度过程轮次示例

队列状态是每一轮开始时的快照。空闲块 一列是这一轮结束后 free_block_ids 的剩余量。

轮次      waiting 队列          running 队列            本轮 batch
────────────────────────────────────────────────────────────────────────────────────
Round 1   [R1 R2 R3 R4]         [ ]                     prefill R1+R2+R3 = 955 tok
                                                        └ 停:预算只剩 69 < R4 的 1200,
                                                          且本轮已有序列 → 不给非首条切块
                                                          空闲块 11 → 6

Round 2   [R4]                  [R1 R2 R3]              prefill R4[0:1024]  ← chunked
                                                        └ 停:预算正好用尽 (remaining==0)
                                                          R4 没算完,留在 waiting 队头
                                                          空闲块 6 → 1  (一次预留全部 5 块)

Round 3   [R4]                  [R1 R2 R3]              prefill R4[1024:1200] = 176 tok
                                                        └ 停:waiting 空
                                                          R4 算完 → 转入 running
                                                          空闲块 1

Round 4   [ ]                   [R1 R2 R3 R4]           decode ×4 (各生成 1 token)
                                                        └ 停:running 取空 / 已达 max_num_seqs
                                                          空闲块 1  (无人跨块边界)

Round 5   [R5]                  [R1 R2 R3 R4]           prefill R5 = 200 tok
                                                        └ 新请求到达,插队做 prefill,
                                                          本轮 decode 被整体跳过
                                                          空闲块 1 → 0

Round 6   [ ]                   [R1 R2 R3 R4 R5]        decode R1 R2 R3 R4
                                                        └ R3 跨块边界但空闲块=0
                                                          → 抢占队尾的 R5 (详见图 3)
                                                          停:已达 max_num_seqs
                                                          空闲块 0 → 1 → 0

Round 7   [R5]                  [R1 R2 R3 R4]           decode ×4
                                                        └ R5 想重新 prefill 但块不够
                                                          (can_allocate 返回 -1) → 继续等
                                                          prefill 一条没抓到 → 落到 decode
                                                          空闲块 0

几个容易看漏的点:

Round 1 —— 为什么 R4 不切块? prefill 循环里的条件是 if remaining < num_tokens and scheduled_seqs: break。后半个条件意味着 chunked prefill 只对本轮第一条序列开放。R4 前面已经排了三条,所以宁可空着 69 的预算也不切。

Round 2 —— 块是按完整 prompt 一次性预留的。 R4 这轮只算了 1024 个 token,但 allocate() 直接按 seq.num_blocks(完整的 1200 token → 5 块)把 block_table 填满。所以空闲块一次就从 6 掉到 1,不是随 chunk 递增。

Round 2→3 —— 没算完的序列留在 waiting 队头。 只有 num_cached_tokens + num_scheduled_tokens == num_tokens 时才 popleft() 转入 running。同时 postprocess() 对没算完的序列会跳过 append_token,所以 chunked prefill 期间 num_tokens 不变,block_table 也就一直是完整的。

Round 5 —— prefill 插队。 这正是 prefill 优先带来的代价:一个新请求到达,所有正在 decode 的序列这一轮全部停摆。

Round 7 —— prefill 失败不会卡住 decode。 can_allocate 返回 -1 时只是 breakscheduled_seqs 仍为空,于是自然落到 decode 分支。


1.2.3 对 Round 6 抢占的详细说明

decode 循环碰到块不够时不是跳过这条序列,而是牺牲队尾的序列给它腾块

进入 Round 6:  running = [R1 R2 R3 R4 R5]   空闲块 = 0

  popleft R1  len=302, 302%256=46 ≠ 1  → 不需要新块 → 调度 ✓
  popleft R2  len=402, 402%256=146 ≠ 1 → 不需要新块 → 调度 ✓
  popleft R3  len=257, 257%256=1  → 需要 1 个新块,但空闲块=0

              │  while not can_append(R3):
              │      running 非空 → preempt(running.pop())
              │                              └─ 队尾是 R5,LIFO 牺牲最新的

        preempt(R5):  status → WAITING
                      is_prefill → True
                      deallocate() 释放它的 1 个块   空闲块 0 → 1
                      塞回 waiting 队头


        再查 can_append(R3): 1 ≥ 1 ✓ → 调度 R3,may_append 吃掉那个块  空闲块 1 → 0
  popleft R4  len=1203, 1203%256=179 ≠ 1 → 调度 ✓   此时已有 4 条 = max_num_seqs,退出

退出 Round 6:  running = [R1 R2 R3 R4]   waiting = [R5]

R5 刚在上一轮 prefill 完就被牺牲,KV 全丢,下轮得重算——这就是 recompute 式抢占。vLLM V1 的调度器里也只有这一条路径,_preempt_request() 同样是释放全部块 + num_computed_tokens = 0;swap 式抢占是 V0 才有的东西。

不过重算通常没那么贵:deallocate 只把块还回 free_block_ids不清 block.hashhash_to_block_id 里的条目也留着(只在块被 _allocate_block() 真正复用时才删)。只要这些块还没被别人覆盖,下次 can_allocate 走 prefix cache 匹配就能把前缀整段捡回来。


1.2.4 跟随上述例子走一遍块管理

下面每一格是一个物理块,Rx.n 表示”序列 x 的第 n 个逻辑块”。轮次与图 2 一一对应。

初始
块号     0     1     2     3     4     5     6     7     8     9    10
      ┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐
      │  ·  │  ·  │  ·  │  ·  │  ·  │  ·  │  ·  │  ·  │  ·  │  ·  │  ·  │
      └─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘
      free = [0,1,2,3,4,5,6,7,8,9,10]        hash_to_block_id = {}


Round 1 后    R1 拿 2 块、R2 拿 2 块、R3 拿 1 块 (各自 popleft 连号取走)
块号     0     1     2     3     4     5     6     7     8     9    10
      ┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐
      │R1.0 │R1.1 │R2.0 │R2.1 │R3.0 │  ·  │  ·  │  ·  │  ·  │  ·  │  ·  │
      └─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘
      free = [5,6,7,8,9,10]
      hash = { h(R1.0)→0, h(R2.0)→2 }
             R1.1/R2.1 是不满的尾块,R3.0 只有 255 token 也不满 → 都不哈希


Round 2 后    R4 一次性预留全部 5 块 (虽然这轮只算了 1024/1200 个 token)
块号     0     1     2     3     4     5     6     7     8     9    10
      ┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐
      │R1.0 │R1.1 │R2.0 │R2.1 │R3.0 │R4.0 │R4.1 │R4.2 │R4.3 │R4.4 │  ·  │
      └─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘
      free = [10]
      hash += { h(R4.0)→5, h(R4.1)→6, h(R4.2)→7, h(R4.3)→8 }
             这轮刚好算满前 4 块 → 立刻哈希;R4.4 还空着 → 不哈希


Round 3 后    块无变化。R4.4 填到 176/256,仍不满 → 仍不进哈希表
      free = [10]


Round 4 后    块无变化 (四条序列都没跨块边界)
      free = [10]
      hash += { h(R3.0)→4 }
             ← R3 这轮 decode 把 255 填成 256,尾块变满,补哈希


Round 5 后    R5 拿走最后 1 块
块号     0     1     2     3     4     5     6     7     8     9    10
      ┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐
      │R1.0 │R1.1 │R2.0 │R2.1 │R3.0 │R4.0 │R4.1 │R4.2 │R4.3 │R4.4 │R5.0 │
      └─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘
      free = []                    ← 满了
      hash 不变 (R5.0 只有 200 token,不满)


Round 6 中    R3 跨块边界,抢占 R5 腾出块 10,R3 立刻把它拿走
      ① preempt(R5) → deallocate:  块 10 ref_count 1→0,  free = [10]
      ② can_append(R3) 复查通过 → may_append → _allocate_block popleft 10
                                                 free = []
块号     0     1     2     3     4     5     6     7     8     9    10
      ┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐
      │R1.0 │R1.1 │R2.0 │R2.1 │R3.0 │R4.0 │R4.1 │R4.2 │R4.3 │R4.4 │R3.1 │
      └─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘

                                              同一个块,一轮之内 R5 → R3 易主
      free = []
      hash 不变。块 10 的 hash 一直是 -1(R5.0 从没满过),
             所以 _allocate_block 里那句"删除过期哈希条目"没触发


Round 7 后    块无变化。R5 想重新 prefill 要 1 块,free 为空
             → can_allocate 返回 -1 → 继续在 waiting 等
      free = []

从这条轨迹能读出 BlockManager 的三条设计:

只有填满的块才进哈希表。 hash_blocks() 用整除算出本轮填满了哪几块,start == end 就直接 return。不满的尾块内容还会变,拿它当 cache key 是错的。所以 R3.0 要等到 Round 4 被 decode 填满才补上哈希,而 R4.4 从头到尾都没进过哈希表。

释放块不清哈希。 deallocate() 只把块号 append 回 free_block_ids,block.hashhash_to_block_id 里的条目都留着。过期条目只在这块被 _allocate_block() 真正复用时才删。这就是被抢占的序列还能靠 prefix cache 把前缀捡回来的原因。

空闲队列是 FIFO,这是有意的。 popleft 取、append 还,意味着刚释放的块排在队尾、最晚被复用——给了它的哈希最长的存活窗口,近似一个 LRU。Round 6 是个极端反例:free 恰好空了,刚还回去的块立刻被 popleft 拿走,缓存价值归零。


1.2.5 prefix cache 命中时的块分配路径

先把 1.1 里欠下的那笔补上:块的 hash 为什么是「整段前缀」的哈希。compute_hash() 把上一块的哈希值当种子链进来:

  逻辑块 0              逻辑块 1                逻辑块 2
  [256 个 token]        [256 个 token]          [256 个 token]
        │                     │                       │
        ▼                     ▼                       ▼
   xxh64(tokens)      xxh64(h0 ‖ tokens)      xxh64(h1 ‖ tokens)
        │                     │                       │
        ▼                     ▼                       ▼
       h0 ────────────────►  h1 ──────────────────►  h2

   prefix.to_bytes(8, "little") 把上一块的 64 位哈希编成 8 字节喂进去

必须串前缀的理由是 KV 依赖全部前文。如果只哈希本块内容,两条前缀不同、但恰好第 3 块内容相同的序列就会错误地共享 KV。串上之后,h2 标识的是 token[0:768] 这整段,而不只是第三块那 256 个 token。

上面那条轨迹里六个请求互不相同,can_allocate 一次都没命中缓存。补一个命中的例子:假设 Round 1 之后来了个 R7,600 token(3 块),前 256 个 token 和 R1 完全一样

can_allocate(R7)                                num_new_blocks = 3

  i=0  h = xxh64(R7.block(0))          = h(R1.0)
       hash_to_block_id[h]             → 块 0
       blocks[0].token_ids == R7.block(0) ?  ✓ 逐 token 复核,防哈希碰撞
       → num_cached_blocks = 1
       → 块 0 在 used_block_ids 里      → num_new_blocks -= 1  → 2

  i=1  h = xxh64(h0 ‖ R7.block(1))     ← 链式,和 R1.1 的哈希不同
       未命中 → break

  len(free) >= 2 ?  → 返回 num_cached_blocks = 1


allocate(R7, num_cached_blocks=1)

  逻辑块 0 → 复用块 0:  ref_count 1 → 2
  逻辑块 1 → _allocate_block()  新块
  逻辑块 2 → _allocate_block()  新块
  num_cached_tokens = 1 * 256 = 256


  块 0  ┌──────────────┐
        │  ref_count=2 │◄──── R1.block_table = [0, 1]
        │              │◄──── R7.block_table = [0, x, y]
        └──────────────┘      两条序列指向同一份 KV,物理上只存一份

回到调度器:prefill 循环据此算出 num_tokens = 600 - 1*256 = 344,R7 这轮只需要 prefill 344 个 token,前 256 个直接白拿。

allocate() 对命中的块分两种情况处理,区别在于块当前在哪:

命中的块在哪里?

├─ 在 used_block_ids —— 别人正在用
│    ref_count += 1
│    不消耗新块 → 所以 can_allocate 里 num_new_blocks -= 1

└─ 在 free_block_ids —— 已释放但还没被覆盖
     ref_count = 1
     free_block_ids.remove(block_id)     ← deque 的 O(n) 删除
     used_block_ids.add(block_id)
     这是被抢占的序列"捡回自己旧块"的路径。
     注意它仍占着一个 free 名额,所以 can_allocate 不减 num_new_blocks——
     两处口径一致,不会算错。

  两种情况都不能调 block.reset(),因为 reset 会清掉 hash 和 token_ids,
  而那正是复用的依据。reset() 只在 _allocate_block 里用,
  它里面的 ref_count = 1 就是"这块刚被分配出去"的意思。

2 vLLM 的 调度机制

看完了 Nano-vLLM 的调度,我们来看一下生产级应用 vLLM 的调度机制。

2.1 vLLM schedule() 伪代码

现在看真的 vLLM 的调度(vllm/v1/core/sched/scheduler.py)。它的 schedule() 有七百多行,但把多模态 encoder 预算、LoRA 上限、投机解码、KV connector(P/D 分离与 offload)、Mamba 对齐、DP 负载均衡这些正交的维度全剥掉之后,骨架其实不长:

def schedule():
    token_budget = max_num_batched_tokens
    scheduled    = {}            # req_id → 本轮要算的 token 数
    preempted    = []

    # ── 第一段:先服务 running 里的请求 ───────────────────────────
    i = 0
    while i < len(running) and token_budget > 0:
        req = running[i]

        # 这条请求还差多少 token 没算。注意这里不区分 prompt 和已生成:
        # 纯 decode 时它 = 1,prefill 没做完时它 = 剩余的整段
        num_new = req.num_tokens_with_spec - req.num_computed_tokens
        num_new = min(num_new, long_prefill_token_threshold)   # 单条限流
        num_new = min(num_new, token_budget)                   # 全局预算
        num_new = min(num_new, max_model_len - req.num_computed_tokens - 1)

        if num_new == 0:
            i += 1;  continue        # ← continue 而不是 break,
                                     #   故意放弃严格 FCFS,让后面的请求能插上来

        # 试着为这 num_new 个 token 分配 KV slot,不够就抢占别人再试
        while (blocks := kv_cache_manager.allocate_slots(req, num_new)) is None:
            if policy == PRIORITY:
                victim = max(running, key=lambda r: (r.priority, r.arrival_time))
                running.remove(victim)
            else:                          # FCFS
                victim = running.pop()     # 队尾,LIFO——和 nano-vllm 一样
            preempt(victim)
            preempted.append(victim)
            if victim is req:              # 没人可抢了,把自己也牺牲掉
                break
        if blocks is None:
            break

        scheduled[req.id] = num_new
        token_budget -= num_new
        i += 1

    # ── 第二段:预算还有剩,就从 waiting 里捞新请求 ─────────────────
    if not preempted:                      # ← 这一轮抢占过,就不再收新的
        while waiting and token_budget > 0:
            if len(running) >= max_num_seqs:
                break
            req = waiting.peek()

            if req.num_computed_tokens == 0:              # 第一次被调度
                cached_blocks, num_computed = \
                    kv_cache_manager.get_computed_blocks(req)   # prefix cache 查询
            else:                                         # 被抢占后恢复的
                num_computed = req.num_computed_tokens

            num_new = req.num_tokens - num_computed
            if not enable_chunked_prefill and num_new > token_budget:
                break
            num_new = min(num_new, long_prefill_token_threshold)
            num_new = min(num_new, token_budget)

            blocks = kv_cache_manager.allocate_slots(req, num_new, cached_blocks)
            if blocks is None:
                break                      # 块不够 → 直接停,这里不抢占

            waiting.pop()
            running.append(req)
            req.status              = RUNNING
            req.num_computed_tokens = num_computed
            scheduled[req.id]       = num_new
            token_budget           -= num_new

    return SchedulerOutput(scheduled, ...)


def preempt(req):
    kv_cache_manager.free(req)       # 释放全部块
    req.status              = PREEMPTED
    req.num_computed_tokens = 0      # 全部重算
    req.num_preemptions    += 1
    waiting.prepend(req)             # 回到 waiting 队头

源码里 schedule() 开头那段注释把设计意图说得很直白:

There’s no “decoding phase” nor “prefill phase” in the scheduler. Each request just has the num_computed_tokens and num_tokens_with_spec. At each step, the scheduler tries to assign tokens to the requests so that each request’s num_computed_tokens can catch up its num_tokens_with_spec.

调度器里根本没有 prefill 和 decode 的概念。 每条请求只有两个数——“已经算了多少 token”和”总共该算多少 token”——调度器每一步就是发放 token 配额让前者去追后者。prompt 阶段差得多,一次追一大段;生成阶段每次只差 1,就追 1。chunked prefill、prefix caching、投机解码全都自然落在这一个抽象里,不需要为谁开分支。

这正是和 nano-vllm 最根本的分歧:nano-vllm 把 prefill / decode 写成了两个物理上分开的循环,中间那句提前 return 是硬隔离。


2.2 一个 batch 里装了什么

上面那点差异,落到实际发出去的 batch 上是这样:

nano-vllm —— 二选一,绝不混
                                                   Round 1
  ┌────────────────────────────────────────────┐
  │  R1[0:300]   R2[0:400]   R3[0:255]         │   955 个 token,全是 prompt
  └────────────────────────────────────────────┘
     prefill                                       (schedule 返回 is_prefill=True)

                                                   Round 4
  ┌────────────────────────────────────────────┐
  │  R1×1   R2×1   R3×1   R4×1                 │   4 个 token,全是新生成
  └────────────────────────────────────────────┘
     decode                                        (schedule 返回 is_prefill=False)

  新请求一到,decode 整轮停摆 → 所有在跑的请求都被卡一拍(图 2 的 Round 5)


vLLM —— 一个 batch 混着装
  ┌──────────────────────────────────────────────────────────────────┐
  │  R1×1    R2×1    R4[1024:1200]        R5[0:800]                  │
  │  decode  decode   prefill 尾块          prefill 首块(被预算切断)    │
  └──────────────────────────────────────────────────────────────────┘
    └──── 第一段:从 running 拿 ────┘  └── 第二段:预算有剩,再从 waiting 捞 ──┘

  新请求只吃掉 decode 之后剩下的预算 → 正在跑的请求不会被饿死

vLLM 的顺序是先 running 后 waiting:先保证已经在跑的请求继续推进(这里面既有纯 decode,也有上一轮没切完的 prefill 尾巴),预算还有剩才去 waiting 里捞新的。nano-vllm 反过来,waiting 优先,而且只要捞到一条就整轮不做 decode。

一句话概括:nano-vllm 让新请求插队,vLLM 让在跑的请求优先。 前者的 TTFT(首 token 延迟)更好看,代价是 ITL(token 间延迟)会被新请求打出毛刺;后者反过来,用一点 TTFT 换稳定的 ITL。


Nano-vLLM 和 vLLM 差异对照

nano-vllmvLLM V1
核心抽象显式的 prefill / decode 两个阶段只有 num_computed_tokensnum_tokens_with_spec,无阶段概念
两个循环的顺序先 waiting(prefill),后 running(decode)先 running,后 waiting
能否混批不能。抓到一条 prefill 就 return,decode 整轮跳过能。同一个 batch 里 decode 和 prefill chunk 并存
token 预算只约束 prefill;decode 完全不看一个 token_budget 贯穿两段,decode 也扣(每条扣 1 或 1+spec)
谁能被切块只有 batch 里第一条(remaining < num_tokens and scheduled_seqs 才 break)任何一条都能切;只有显式关掉 enable_chunked_prefill 才不切
单条限流无。一条长 prompt 能吃满整个预算long_prefill_token_threshold 给单条封顶
块的分配粒度首次调度就按完整 prompt 预留全部 num_blocksallocate_slots() 只分配本 chunk 需要的量(除非开 scheduler_reserve_full_isl
“装得下吗”的接口can_allocate 返回 -1/缓存块数,can_append 返回 bool,查完再单独 allocate一个 allocate_slots(),成功返回块、失败返回 None,查与分配合一
某条排不上时一律 break,不区分原因分两类:「暂时轮不到这条」跳过继续(running 循环 continue,waiting 循环挪进 skipped_waiting),「资源真耗尽」才 break。源码注释明说前者是有意放弃严格 FCFS
抢占挑谁只有 running.pop(),队尾 LIFOFCFS 下同样是队尾;PRIORITY 策略下挑 (priority, arrival_time) 最大的
抢占方式recompute(释放全部块,is_prefill=True,回 waiting 队头)一样是 recompute(num_computed_tokens=0,回 waiting 队头)
抢占后本轮无特殊处理if not preempted_reqs —— 这轮抢占过就不再收新请求,避免刚腾出的块立刻被新人吃掉
max_num_seqs 检查位置两个循环都按 len(scheduled_seqs)只在 waiting 循环按 len(running)
等待队列一个 dequewaiting + skipped_waiting 两个队列,可插拔 FCFS / PRIORITY

除此之外 vLLM 的 schedule() 里还塞着一堆 nano-vllm 完全没有的维度,它们各自都会再削一刀 num_new_tokens 或者让某条请求这轮直接出局:多模态 encoder 的独立算力预算与 encoder cache、LoRA 的 max_loras 上限、投机解码的 lookahead slot 与 draft token、KV connector(P/D 分离、KV offload,请求会进 WAITING_FOR_REMOTE_KVS 状态挂起)、Mamba 混合模型的块对齐切分、数据并行下的 prefill 节流。骨架是上面那六十行,剩下的七百行基本都是这些维度的交织。

两处值得单独拎出来的细节:

抢占后本轮不再收新请求。 vLLM 第二段外面套着 if not preempted_reqs。理由很实际:既然刚刚已经因为块不够牺牲了别人,那这一轮就别再放新请求进来抢那些刚腾出来的块,否则会来回抖动。nano-vllm 因为 prefill 和 decode 天然不在同一轮,碰不到这个问题——但代价是图 2 Round 6 那种”R5 刚 prefill 完下一轮就被牺牲”的浪费它防不住。

块的分配粒度。 nano-vllm 首次调度时 allocate() 就按完整 seq.num_blocks 把块全预留了(图 2 的 Round 2,空闲块一次从 6 掉到 1),好处是后续 chunk 不会中途缺块,坏处是一条长 prompt 一进来就锁住大量显存。vLLM 的 allocate_slots() 只按本 chunk 的 token 数分配,长 prompt 的显存是随 chunk 逐步吃进去的——更省,但也意味着一条正在 prefill 的请求可能算到一半才发现块不够,于是被抢占、前功尽弃。要 nano-vllm 那种行为得显式开 scheduler_reserve_full_isl

← All posts