AkiYang推理工程手记

SGLang 投机解码调度请求进度、设备状态与跨轮交接

· 源码核对 2026-09-20
版本与源码依据
系列阅读 · DeepSeek-V4 投机解码展开篇目
本页目录

Scheduler 通常把一轮投机解码作为一次 DECODE 工作交给 Spec Worker;Draft、Verify、Accept 和提交在 Worker 内部展开。返回后,Scheduler 再更新请求状态,并安排下一轮。

开启 Overlap 后,设备已经确定的下一轮输入可能领先于 CPU 完成的结果处理。本文跟随同一条请求说明这种错开如何由序列长度、KV 预留、publish 和 FutureMap 管理,再把 Mixed Tail 的特殊交接放到进阶部分。

源码版本与阅读范围

源码基线:本文基于 sgl-project/sglang @ 5f017ffabb6ab8d214f6a4616ee8bd98a376034a,核对日期为 2026-09-20。重点覆盖当前 Spec V2 Scheduler、EAGLE / EAGLE3、DSpark、DFLASH、UNO、NGRAM 及 overlap relay。

本篇关注 Scheduler ownership、跨轮时钟、KV watermark、forward transaction 与 overlap relay。

一、Scheduler 与 Spec Worker 的分工

Scheduler 决定请求何时执行、预留多少空间;Spec Worker 决定本轮怎样提出和验证候选。两者维护的状态范围不同,需要分别看。

第一层:Scheduler Macro State

Scheduler 关心的是:

这个 Request 现在能不能跑?

属于 Prefill 还是 Decode?

这一轮需要多少 KV capacity?

可以和哪些 Request 组 Batch?

有没有 finished / retracted?

下一轮还要不要留在 running_batch?

所以 Scheduler 眼里的 Request 生命周期更接近:

WAITING
   │
   ▼
PREFILL / EXTEND
   │
   ▼
RUNNING / DECODE
   │
   ├───────────────┐
   │               │
   ▼               ▼
FINISHED         RETRACT

这里最核心的数据结构是:

Req
ScheduleBatch

第二层:Spec Worker Micro State

当 Scheduler 已经决定:这一轮跑 DECODE

以后,Spec Worker 才进一步展开:

Draft / Proposal
       ↓
Target Verify
       ↓
Accept
       ↓
Device-side state commit
       ↓
Draft Extend / next-state preparation

例如 EAGLE V2 的入口:

EAGLEWorkerV2.forward_batch_generation()

Decode 分支会在同一个 Scheduler iteration 内完成:

draft()
  ↓
verify()
  ↓
eagle_sample()
  ↓
accepted-path commit
  ↓
_draft_extend_for_decode()

而 DSpark 则是:

proposer.propose()
        ↓
resolve_verify_token_budget()
        ↓
schedule_layout()
        ↓
Target Verify
        ↓
accept_and_finalize()
        ↓
commit_hidden / state

对应:

DSparkWorkerV2._forward_decode()

所以同样一个:ScheduleBatch.forward_mode = DECODE

进入 Spec Worker 后,内部可能创建多个不同用途的 ForwardBatch:

ScheduleBatch(DECODE)
        │
        ├─ Draft ForwardBatch
        ├─ TARGET_VERIFY ForwardBatch
        └─ DRAFT_EXTEND_V2 ForwardBatch

这两层的职责分别是:

ScheduleBatch(DECODE) 是 Scheduler 的宏观 transaction;Draft / Verify / Accept 是这个 transaction 内部的执行阶段。


为什么 model_worker 是这个边界的关键

Scheduler 初始化 Worker 时:

if self.spec_algorithm.is_none() or self.draft_worker is None:
    self.model_worker = self.tp_worker
else:
    self.model_worker = self.draft_worker

固定源码:

Scheduler.init_model_worker()

因此普通生成:

Scheduler
   ↓
TpModelWorker
   ↓
Target Model

Speculative Decoding:

Scheduler
   ↓
Spec Worker
   ├─ Draft side
   └─ Target Worker

Scheduler 调用的仍然只是:

self.model_worker.forward_batch_generation(batch)

但 Spec Worker 会把这一轮展开成完整的 speculative pipeline。


ForwardMode 也说明了两层状态

当前源码中:

EXTEND
DECODE
MIXED
IDLE

TARGET_VERIFY
DRAFT_EXTEND_V2

其中:DECODE

主要是 Scheduler 层的一轮 decode transaction。

而:

TARGET_VERIFY
DRAFT_EXTEND_V2

则是 Worker / ModelRunner 执行某个内部 forward 时的设备执行模式。

因此不要把:ScheduleBatch.forward_mode == DECODE

理解成:

“这一轮 GPU 只做了一次普通 Decode。”

在 Spec V2 中完全不是。

二、CPU、设备与下一轮状态怎样错开

下面先列出参与跨轮交接的六个对象:

Req
ReqKvInfo
ScheduleBatch
ForwardBatch
GenerationBatchResult
FutureMap

源码自己对前两层数据流有非常明确的说明:

ScheduleBatch -> ForwardBatch

ScheduleBatch:
  managed by Scheduler
  high-level scheduling data

ForwardBatch:
  managed by ModelRunner
  low-level tensor data

固定源码:

schedule_batch.py

启用投机解码与 Overlap 后,几项状态会错开推进:

这些对象不再保证在任意瞬间都显示同一个 sequence length。

这些长度分别服务于 CPU 结果处理、设备执行和下一轮交接,更新时刻也不同。检查状态时,需要先确认读到的是哪个阶段的进度。


Req.seqlen:用户逻辑序列时钟

定义非常直接:

@property
def seqlen(self) -> int:
    return len(self.origin_input_ids) + len(self.output_ids)

所以它回答:

CPU Request 目前已经正式接受了多少逻辑 token?

注意它依赖:req.output_ids

而 output_ids 是在 BatchResultProcessor 处理本轮结果时才增加的。

因此 overlap 下它天然是一个 host-settled clock。


kv_committed_len:Host KV Ledger 的 committed boundary

ReqKvInfo 中:

kv_committed_len: int = 0
kv_allocated_len: int = 0

源码注释:

kv_committed_len:
KV content committed up to here,
<= kv_allocated_len

固定入口:

ReqKvInfo

它回答:

Host bookkeeping 认为当前正式 committed 的 KV boundary 在哪里?

Spec V2 里一个极其重要的 ownership rule 是:

Draft Worker
不得自己推进 kv_committed_len

仓库甚至有专门的 AST-level regression test:

test_decode_bookkeeping_ownership.py

其中明确规定:

spec v2:
no pre-claim;
resolve commits the full accepted run uniformly.

kv_committed_len 的正常 Spec V2 owner 是:SchedulerBatchResultProcessor._resolve_spec_v2_tokens()

而不是 Draft Worker。


ScheduleBatch.seq_lens:本轮 Device Forward Frontier

前两个长度描述请求记录,这里的长度用于本轮设备执行。Spec V2 的约定是:

batch.seq_lens
=
当前这一轮 forward 能安全认为已经 KV-ready 的 prefix boundary

在 EAGLE / DSpark decode 中,Verify 完成后会生成:

new_seq_lens = old_seq_lens + accept_lens

然后通过 FutureMap.publish() 让下一轮 device-side scheduling 提前看到这个新 frontier。

因此在 overlap 下:ScheduleBatch.seq_lens

可以已经是:Verify N 的新 frontier

而 CPU 上:

Req.seqlen
Req.kv.kv_committed_len

还没有处理完 Verify N 的结果。


允许错开“最多一个 Verify iteration”,不是“一个 Token”

这点源码里有非常直接的注释:

kv_committed_len lags device seq_lens
by up to one verify under overlap;
the 2x window absorbs it.

固定源码:

mamba_lazy_spec_in_window()

关键是:

one verify,不是 one token。

如果一次 Verify 接受:accept_lens = 4

那么 overlap 窗口里完全可能出现:

Device frontier:
ScheduleBatch.seq_lens = C + 4

Host ledger:
Req.kv.kv_committed_len = C

差距是:4 tokens

但仍然只是:1 个尚未 CPU settle 的 verify iteration

这和 event_loop_overlap() 的结构完全一致。

它最多保留一个尚未处理的上一批结果:

run current batch
    ↓
append result
    ↓
process previous batch result

所以标准 overlap pipeline 是 一轮 forward lookahead。


为什么 reserve 要做 2x?

答案就在:

get_alloc_reserve_per_decode()

源码直接写:

def get_alloc_reserve_per_decode() -> int:
    """
    The 2x is a double-buffer that absorbs
    the kv_committed_len lag in overlap mode.
    """
    return 2 * get_alloc_len_per_decode()

这是非常漂亮的一条设计关系。

因为 Scheduler N+1 轮做 memory planning 时:Host kv_committed_len

可能还只到:C

但 Device 实际已经通过 Verify N 前进到了:C + accepted_N

如果 reserve 只按一轮最大 speculative footprint:1 × alloc_len

就可能因为 host committed clock 落后一轮而低估下一轮 workspace。

所以当前设计使用:2 × alloc_len

吸收:

one-verify host lag
+
current speculative working set

Req.seqlen 与 kv_committed_len 为什么经常差 1?

这是和前一篇 root / bonus frontier 文章直接连接的地方。

在一个已经稳定进入 speculative decode 的 request 上,常见状态是:

KV-ready committed prefix
|======================|

                       B
                       ▲
                 pending terminal bonus

其中 B:已经存在于 req.output_ids

但它自己的 KV 还要在下一轮作为 root 被 materialize。

所以一个典型 steady-state 关系是:

Req.seqlen
≈
kv_committed_len + 1

这一格就是 pending bonus。

例如:

kv_committed_len = P

Req logical tokens:
[0 ... P-1] + bonus_at_P

Req.seqlen = P + 1

下一轮 Target Verify 把 bonus 当 root 写入位置 P。

如果本轮又接受 4 个输出:D1 D2 D3 B2

Device materialize 的 Verify rows 是:old_bonus D1 D2 D3

也是 4 个。

所以 CPU settle 时:

req.kv.kv_committed_len += 4
req.output_ids.extend([D1, D2, D3, B2])

结果仍然是:

Req.seqlen
=
kv_committed_len + 1

新的那 +1 就是 B2。

上式对应这里的活跃投机请求:已确认输出比已物化 KV 多一枚待输入的 bonus。进入 Transition、Mixed 或 Finished 状态后,再按各自处理阶段读取长度。

三、预留 KV 空间并保留提交边界

当 running batch 准备进入下一轮 decode 时:

batch.prepare_for_decode()

普通 Decode 和 Speculative Decode 从这里分叉。

普通路径:

alloc 1 token
   ↓
写 req_to_token
   ↓
kv_allocated_len += 1
kv_committed_len += 1
seq_lens += 1

Speculative 路径则直接交给:

spec_prepare_for_decode(batch)

固定源码:

ScheduleBatch.prepare_for_decode()


EAGLE 做的是 speculative reserve

spec_prepare_for_decode() 对 EAGLE 最终调用:

eagle_prepare_for_decode(batch)

其中:

reserve = get_alloc_reserve_per_decode()

cur_kv_lens, nxt_kv_lens, num_needed_tokens =
    page_aligned_decode_alloc_lens(
        batch.reqs,
        reserve=reserve,
        ...
    )

随后:

alloc_for_spec_decode(...)

固定源码:

eagle_prepare_for_decode()

以及:

alloc_for_spec_decode()

更新的是:

req.kv.kv_allocated_len =
    max(req.kv.kv_allocated_len, nxt_kv_len)

没有:kv_committed_len += speculative width

Prepare
=
Reserve Future Capacity

而不是:

Prepare
=
Declare Future Correct

两个 watermark 的职责完全不同

kv_allocated_len

回答:

这一轮 speculative working set 最多可以写到哪里?

而:kv_committed_len

回答:

Target 已经正式确认到哪里?

因此:allocation

发生在 Draft / Verify 之前。

而:host commit

必须等 Accept 结果 settle 以后。

这就是 speculative memory bookkeeping 最核心的安全边界。


一个典型状态

0
│
├──────── committed ────────┤──────── reserve ────────┤
│                           │                          │
                            C                          A
                            ▲                          ▲
                    kv_committed_len          kv_allocated_len

Draft / Verify 可以在:[C, A)

里临时写 future。

但只有 Accept 决定的那一段最终会成为下一轮 committed prefix。

四、on_publish 发布哪一部分进度

现在进入一轮运行。

Scheduler 主循环大致是:

get_next_batch_to_run()
        ↓
prepare_for_decode()
        ↓
run_batch()
        ↓
process_batch_result()

开启 Spec V2 后:

run_batch()
   ↓
self.model_worker.forward_batch_generation()

这里的 model_worker 已经是 speculative orchestrator。


EAGLE:一次 Scheduler DECODE 包含 Draft → Verify → Accept → Draft Extend

Decode 分支:

ScheduleBatch(DECODE)
        ↓
draft()
        ↓
EagleVerifyInput
        ↓
verify()
        ↓
eagle_sample()
        ↓
accept_lens / accept_index / new_seq_lens
        ↓
_draft_extend_for_decode()
        ↓
GenerationBatchResult

Verify 内部会临时把 forward 变成:TARGET_VERIFY

并创建 Target Model 用的 ForwardBatch。

Scheduler Macro State:
DECODE

Worker Micro State:
DRAFT
→ TARGET_VERIFY
→ ACCEPT
→ DRAFT_EXTEND

Scheduler 管理请求与批次,Worker 在一次执行内推进 Draft、Verify 与 Accept;两者通过返回结果交接状态。


DSpark:内部算法完全不同,但 Scheduler Contract 一样

DSpark _forward_decode():

Proposal
  ↓
Confidence
  ↓
Verify Budget
  ↓
schedule_layout()
  ↓
Target Verify
  ↓
accept_and_finalize()
  ↓
commit_hidden / Mamba state

最后仍然返回:

GenerationBatchResult(
    next_token_ids=...,
    accept_lens=...,
    next_draft_input=...,
    new_seq_lens=...,
)

这就是 Scheduler abstraction 的价值。

Scheduler 不需要知道:EAGLE Tree 是怎么长的

也不需要知道:DSpark Planner 怎么决定 verify token budget

它只要求算法最后回答:

本轮接受了什么?
下一轮 frontier 在哪?
下一轮 draft state 是什么?

所有 on_publish(new_seq_lens) 的语义边界是一致的,但物理位置不完全一致

下面对比各算法的 publish 位置。

EAGLE / Multi-layer EAGLE / Frozen-KV MTP

它们都是:

Target Verify
    ↓
Accept / new_seq_lens known
    ↓
on_publish(new_seq_lens)
    ↓
Draft Extend

源码注释甚至直接写:

Publish before draft_extend
so the fence is at verify-end.

因此 publish 边界是:

Verify / Accept 已经确定 next frontier。

不是:

Draft side 已经全部追平。


DSpark

DSpark 更能说明这个 distinction。

顺序是:

accept_and_finalize()
        ↓
on_publish(accept.new_seq_lens)
        ↓
_commit_target_mamba_states_after_verify()
        ↓
commit_hidden()

也就是说:

publish 甚至发生在部分 target-side post-verify state commit 之前。

固定源码:

DSparkWorkerV2._forward_decode()

on_publish 将下一轮所需的长度先交给调度链路,部分 Device Commit 则继续按原依赖执行。


DFLASH

DFLASH 又稍微不同:

Verify / Accept
      ↓
必要时更新 target Mamba state
      ↓
on_publish(new_seq_lens)
      ↓
把 accepted target hidden materialize 到 draft KV

因此 publish 仍然位于:next frontier 已经确定

和:全部后处理都做完

之间。


NGRAM / UNO

NGRAM 在 accepted target KV move 完成后 publish,然后再更新 host-side ngram corpus。

UNO 在 acceptance 算出 new_seq_lens 后直接 publish。

它们虽然具体 placement 不同,但共同 contract 一样:

on_publish(new_seq_lens) 表示“下一轮 Scheduler 已经可以安全知道 sequence frontier 是多少”,而不是“当前 Worker 已经完全 return”。


为什么这样仍然安全?

因为 publish 主要允许:schedule_stream

提前做下一轮 host / allocation planning。

它并不会让下一次 model forward 穿越当前 forward stream 中尚未完成的:

Draft Extend
Mamba commit
hidden materialization

这些操作仍然按 forward stream 的程序顺序执行。

所以时序可以是:

Forward Stream:
Verify N
   ↓
Accept N
   ↓
publish(frontier N)
   ↓
Draft/State Commit N
   ↓
Forward N+1


Schedule Stream:
             publish fence
                   ↓
          Prepare Schedule N+1

overlap 的是:CPU / schedule preparation

和:当前 Worker 剩余 device work

而不是让两个依赖状态的 model forward 乱序。


Prefill 的 publish 也遵守同一个思想

EAGLE / DFLASH / DSpark prefill 路径中:

Target Prefill
      ↓
new_seq_lens = batch.seq_lens
      ↓
publish()
      ↓
Draft prefill / hidden injection

为什么这里没有 +1?

因为 Spec V2 convention 下:

batch.seq_lens
=
当前已经 KV-ready 的 prompt boundary

Target Prefill 采样出来的 next token 只是:下一轮 pending root / bonus

它还不属于 KV-ready prefix。

所以下一轮 Verify 的 frontier 仍然就是:batch.seq_lens

这和 Decode 中 terminal bonus 的 frontier 语义完全一致。

五、隔离临时字段与保护共享对象

这里要区分字段替换和共享对象修改。

Spec Worker 在一轮内部会临时修改大量 ScheduleBatch 字段:

forward_mode

input_ids

seq_lens
seq_lens_cpu
seq_lens_sum

spec_info

out_cache_loc

sampling_info
...

例如:

DECODE
  ↓
TARGET_VERIFY

或者临时:batch.spec_info = EagleVerifyInput

如果这些 worker-internal mutation 泄漏回 Scheduler,下一轮就可能被错误地识别为:

EXTEND
TARGET_VERIFY

甚至造成重复 merge / 错误 allocation。


_forward_isolation() 恢复字段绑定,不深拷贝整个 Batch

Scheduler._forward_isolation() 当前实现:

snapshot_v2_full = not batch.spec_algorithm.is_none()

sched_snapshot = {
    f.name: getattr(batch, f.name)
    for f in dataclasses.fields(batch)
}

退出时:

for name, value in sched_snapshot.items():
    setattr(batch, name, value)

固定源码:

Scheduler._forward_isolation()

这里保存的是每个字段原来指向的对象,退出时再用 setattr 恢复。它是一份浅快照:

Worker 内的操作 退出 isolation 后
batch.seq_lens = new_tensor 字段重新指向原 Tensor
batch.seq_lens.add_(1) 原 Tensor 的内容已改变,恢复引用不能撤销它
修改字段所引用的列表或嵌套对象 不会因 setattr 自动恢复内部内容

隔离区保存并恢复 dataclass 字段的绑定。共享 Tensor、列表和嵌套对象的内容则由各自的拷贝与同步方式保护,下文 sampling_info.copy_for_forward() 就负责其中一部分。

这条边界隔离的是临时字段替换。


sampling_info 为什么还要额外换成 forward-only copy?

Isolation 进入前会:

batch.sampling_info =
    sched_sampling_info.copy_for_forward()

原因是一个 Spec iteration 内部可能多次:ForwardBatch.init_new()

如果直接复用 Scheduler 的 sampling state,penalty 等状态可能被重复 accumulate。

因此:Scheduler sampling state

和:forward-local sampling view

也被隔离开了。


Overlap 下还要保活两轮 tensor reference

record_batch_in_overlap() 会把:

batch
+
attr_snapshot

保存在两槽 ring 中。

原因不是状态语义,而是 tensor lifetime。

Spec Worker 可能:

rebind batch.input_ids
rebind batch.out_cache_loc
rebind batch.spec_info

旧 tensor Python 引用如果提前丢掉,Caching Allocator 可能在 forward stream 还没读完时复用那块显存。

所以:semantic rollback

和:GPU tensor lifetime

这里同时被 isolation 管住。


那么哪些字段最终必须显式 re-commit?

关键就在这里。

Isolation 的设计是:先恢复快照中的字段绑定

然后:只重新提交属于 next iteration contract 的状态

Overlap 路径

Worker 返回以后:

batch.input_ids = None

batch.spec_info =
    batch_result.next_draft_input

batch.spec_info.future_indices =
    future_indices

但:batch.seq_lens

不会直接在这里赋成 new_seq_lens。

因为它已经通过:FutureMap.publish(new_seq_lens)

进入 device relay。

下一轮:

future_map.resolve_seq_lens_cpu(batch)

再把 fresh frontier resolve 回来。

所以 overlap 的显式 commit 是:

ScheduleBatch object:
next_draft_input / relay handle

FutureMap:
new_seq_lens
bonus/topk/hidden...

Non-overlap 路径

没有 FutureMap overlap relay,所以 isolation 结束后 Scheduler 必须显式:

batch.spec_info = batch_result.next_draft_input

batch.seq_lens = batch_result.new_seq_lens

batch.seq_lens_cpu =
    batch_result.new_seq_lens.to("cpu")

batch.seq_lens_sum =
    int(batch.seq_lens_cpu.sum())

batch.input_ids = None

这就是同步路径中的 cross-iteration commit。


哪些字段必须保持 rollback?

最典型的有:forward_mode

Scheduler 外部仍然应该看到:DECODE

而不是 Worker 内部临时的:

TARGET_VERIFY
DRAFT_EXTEND_V2

还有:

verify-time input_ids
verify-time out_cache_loc
temporary EagleVerifyInput
temporary verify seq_lens

这些临时字段绑定随当前 Forward 结束而恢复,下一轮输入由返回结果重新接入。


字段隔离与显式提交的边界

Worker 执行时可以临时替换本轮 forward_mode、out_cache_loc、spec_info 或 seq_lens 的字段绑定;退出隔离区后,再从返回结果显式接入下一轮所需状态。

对象变化 隔离区退出后的处理
ScheduleBatch 字段被重新绑定 恢复进入时保存的字段值或对象引用
原 Tensor 或嵌套对象被原地修改 浅快照不会自动撤销,需要调用方单独保护
下一轮 Draft 输入、长度与 bonus 通过对应结果和交接路径显式发布或提交

因此,调试时既要检查字段最终指向谁,也要检查共享对象是否发生原地写入。只比较退出前后的字段绑定,无法覆盖第二类问题。

普通投机 Decode 的状态交接已经在前五节说明。Mixed Tail 还要把待处理的边界 Token 作为 EXTEND 输入重建,下面单独展开这条路径。

六、进阶:Mixed Tail 的 FutureMap 重建

展开 Mixed Tail 的字段变化与 +1 来源

局部代码看起来的偏移,需要放回 mixed-tail 的执行语境。

FutureMap.resolve_mixed_spec_tails() 中有:

fresh = self.new_seq_lens_buf[idx]

seq_lens = batch.seq_lens.clone()
seq_lens[-n:] = fresh + 1
batch.seq_lens = seq_lens

同时:

out_cache_loc[-n:] =
    req_to_token[idx, fresh]

这里为什么使用 new_seq_lens + 1?

前面明明一直说:

new_seq_lens
=
next KV-ready frontier

先看 Mixed Chunk 在做什么

当 chunked prefill 开启 mixed mode 时:

new prefill requests
+
running decode requests

会被塞进同一个:ForwardMode.MIXED

batch。

running decode request 此时不是按 speculative Verify 的形态运行,而是被临时改写成:1-token EXTEND tail

mix_with_running() 明确写:a tail's prefix is its row length - 1

Spec 路径还写:Spec rows sit at the committed base


对 speculative request 来说,那个“1 token tail”是什么?

就是当前 pending bonus。

假设 FutureMap fresh frontier 是:C

代表:[0, C)

已经 KV-ready。

而:bonus

位于:position C

还没有 materialize KV。

Mixed EXTEND 要做的正是:

prefix:
[0, C)

extend token:
bonus at C

所以 Extend 语义下的 full sequence length 必须是:C + 1

于是:

seq_lens = fresh + 1

完全正确。

与此同时:

out_cache_loc =
    req_to_token[idx, fresh]

就是 bonus 在位置 C 的写入 slot。

可以画成:

FutureMap new_seq_lens = C

KV-ready:
|====================|
0                    C

                     B
                     ▲
               pending bonus


Mixed 1-token EXTEND:

prefix_len = C

input = B

write position = C

full seq_len = C + 1

这里将同一个 KV-ready Frontier 转换为单 Token EXTEND 写入后的长度,因此使用 C+1。


为什么 Result Processor 还要专门 kv_committed_len += 1?

因为这次 Mixed EXTEND 真的把 pending bonus 的 KV materialize 了。

process_batch_result_prefill() 有唯一的特殊 owner:

if (
    not req.finished()
    and batch.decoding_reqs
    and req in batch.decoding_reqs
    and not batch.spec_algorithm.is_none()
):
    req.kv.kv_committed_len += 1

源码注释:

A mixed spec tail committed its pending bonus token;
advance so the next spec prepare_for_decode
reserves from the right base.

固定源码:

SchedulerBatchResultProcessor.process_batch_result_prefill()

这再次证明:+1

是 “pending bonus 被当前 EXTEND commit” 的局部语义。


为什么不是所有 FutureMap resolve 都 +1?

普通 speculative decode 下一轮仍然要把 pending bonus 当:Verify root

它需要的 prefix frontier 就是:C

而不是:C + 1

如果你提前把:ScheduleBatch.seq_lens = C + 1

那等于宣称 bonus 的 KV 已经存在。

但实际上它还没 forward。

所以普通 next-spec iteration 必须:seq_lens = new_seq_lens

只有:Mixed batch 把 bonus 直接变成一枚 EXTEND input

时才需要:new_seq_lens + 1


这个 +1 也不是 DP Attention 的通用规则

这里还要区分 Mixed Tail 与普通 DP Attention 的处理。

当前 Scheduler 在:spec + DP Attention

下明确写:

make sure prefill and decode batches
will not be mixed

在 get_next_batch_to_run() 中,Spec + DP-attention 会在 merge 前先协调 prefill/decode mode,避免把它们走成普通 mixed chunk。

固定源码:

Scheduler.get_next_batch_to_run() 附近

DP Attention 还有另一条路径:

maybe_convert_decode_to_extend()

当别的 DP rank 正在跑 extend、而当前 rank 是 decode 时,为了保持 mode-homogeneous、复用 extend graph,它会把整个 decode batch 临时转换成:1-token EXTEND view

对应:

SchedulerDPAttnAdapter.maybe_convert_decode_to_extend()

这条路径调用:

batch.convert_decode_to_extend()

它直接把当前 prepared seq_lens 解释成 full sequence length:

prefix = seq_len - 1
extend_len = 1

它不是:FutureMap.resolve_mixed_spec_tails()

因此更准确的结论是:

new_seq_lens + 1 是“overlap mixed-chunk speculative tail late-binding”的局部转换规则,不是 FutureMap 的通用 seq_len 定义,也不是 DP Attention 的通用规则。


为什么要 late-bind?

因为 mixed batch 在 schedule time 构造时:CPU Req clock

可能还落后于 GPU 最新 verify。

所以 mix_with_running() 只能先拿 host request state 做一个 provisional tail。

到了 forward entry:

resolve_forward_inputs()

才有资格等待 publish_ready 并读取:FutureMap.new_seq_lens_buf

于是重新绑定:

fresh prefix
fresh out_cache_loc
fresh seq_lens

这就是:late binding

存在的原因。

七、把跨轮状态交还 Scheduler

现在可以把全文压缩成一张图。

flowchart TD
    A[Req / ReqKvInfo] --> B[ScheduleBatch.prepare_for_decode]

    B --> C{Speculative?}

    C -->|No| D[alloc_for_decode: one token]
    C -->|Yes| E[spec_prepare_for_decode]

    E --> F[Reserve speculative KV capacity]
    F --> G[ScheduleBatch DECODE]

    G --> H[Spec Worker]

    H --> I[Draft / Proposal]
    I --> J[Target Verify]
    J --> K[Accept]

    K --> L[new_seq_lens]
    K --> M[accept_lens / accepted output]
    K --> N[next bonus / next draft state]

    L --> O[FutureMap.publish]
    N --> P[Draft / post-verify state commit]
    P --> Q[GenerationBatchResult]

    Q --> R[FutureMap.stash]
    Q --> S[BatchResultProcessor]

    S --> T[req.output_ids += retained accepted run]
    S --> U[req.kv.kv_committed_len += retained length]
    S --> V[finish / grammar / metrics]

    O --> W[Next scheduling iteration]
    R --> W

    W --> X[resolve_seq_lens]
    W --> Y[resolve_forward_inputs]

    X --> B
    Y --> B

这张图里存在三类“时间”。

Past:CPU 已经 settle 的事实

Req.output_ids

Req.kv.kv_committed_len

这是:Committed Past


Present:这一轮 Scheduler Transaction

ScheduleBatch

ForwardBatch

temporary TARGET_VERIFY / DRAFT_EXTEND states

这是:Executing Present


Future:CPU 还没 settle,但下一轮 GPU 已经可以依赖的结果

FutureMap.new_seq_lens

FutureMap.output_tokens_buf

next_draft_input

topk / hidden / confidence ...

这是:Safe Future

Spec V2 Scheduler 的核心价值,就是允许:

Past
Present
Future

短暂不同步,但每一个 owner 都只能修改自己的那只时钟。


四个跨轮状态边界

第一,Overlap 下 ScheduleBatch.seq_lens 可以领先 Req.kv.kv_committed_len 最多一个 Verify iteration,而不是一个 token。

一次 Verify 可能接受多个 token,所以数值差可以大于 1。2x speculative reserve 正是为了吸收这一轮 host committed lag。

第二,on_publish(new_seq_lens) 的统一语义是“next forward frontier 已经确定”,不是“所有 device-side post-verify commit 已结束”。

不同算法 publish 后仍可能继续 Draft Extend、Mamba commit、hidden → draft KV materialization。

第三,_forward_isolation() 对 Spec V2 保存并恢复全部 dataclass 字段的引用。

字段替换在退出时恢复,需跨轮保留的新绑定通过 GenerationBatchResult / FutureMap / explicit recommit 交接。Tensor 原地修改和嵌套对象修改不在这份浅快照的撤销能力之内。

第四,new_seq_lens + 1 只属于 mixed speculative tail 的 1-token EXTEND 语义。

new_seq_lens 本身仍是 KV-ready base;+1 表示当前 mixed forward 正在把 base 上的 pending bonus 作为一枚 extend token materialize。它既不是普通 spec decode 规则,也不是 DP Attention 的统一规则。


Debug Scheduler 时建议同时打印这些字段

Debug Spec V2 Scheduler 时,可以把下列字段按请求与轮次并排记录:

rid

len(origin_input_ids)
len(output_ids)
Req.seqlen

req.kv.cache_protected_len
req.kv.kv_committed_len
req.kv.kv_allocated_len

batch.forward_iter
batch.forward_mode
batch.seq_lens
batch.seq_lens_cpu

type(batch.spec_info)
future_indices

result.accept_lens
result.new_seq_lens

FutureMap.new_seq_lens_buf[req_pool_idx]
FutureMap.output_tokens_buf[req_pool_idx]

然后先问四个问题:

1. 现在看的是 Host clock 还是 Device clock?

2. 这个 field 的 owner 是 Scheduler、
   Worker、ResultProcessor 还是 FutureMap?

3. 这个值代表 committed boundary,
   allocated boundary,还是 pending bonus?

4. 当前是在普通 Decode、
   TARGET_VERIFY、
   Mixed 1-token Extend,
   还是 DP decode→extend view?

只要这四个问题先回答,绝大多数“为什么差 1 / 为什么多一轮 / 为什么 seq_len 不一样”的问题都会变得清楚。

源码阅读入口

固定版本:

sgl-project/sglang
5f017ffabb6ab8d214f6a4616ee8bd98a376034a

推荐按下面顺序阅读:

目标 固定版本源码
Req / ReqKvInfo / ScheduleBatch schedule_batch.py
ForwardMode / ForwardBatch.init_new() forward_batch_info.py
Scheduler Event Loop / run_batch() scheduler.py
Spec Worker dispatch Scheduler.init_model_worker()
_forward_isolation() scheduler.py
Spec Decode Preparation spec_utils.py::spec_prepare_for_decode()
EAGLE reserve eagle_utils.py::eagle_prepare_for_decode()
Spec KV sizing / double reserve allocation_sizing.py
Spec KV allocation allocation.py::alloc_for_spec_decode()
EAGLE V2 micro-state machine eagle_worker_v2.py
Shared EAGLE Verify eagle_worker_common.py
DSpark decode state machine dspark_worker_v2.py
DFLASH publish / draft-KV materialization dflash_worker_v2.py
UNO publish contract uno_worker_v2.py
NGRAM publish contract ngram_worker.py
GenerationBatchResult managers/utils.py
Spec host commit batch_result_processor.py::_resolve_spec_v2_tokens()
Mixed spec tail commit batch_result_processor.py
FutureMap.publish / stash / resolve overlap_utils.py
DP decode→extend conversion dp_attn.py
Bookkeeping ownership regression test test_decode_bookkeeping_ownership.py

阅读跨轮日志时,先标明每个长度属于 CPU 请求记录、设备执行进度,还是尚未由 Host 结算的下一轮结果。再沿 publish、resolve 和结果处理检查它何时被消费。这样既允许准备与执行重叠,也能判断某次长度差异是预期时差,还是漏提交、重复提交或共享对象被意外修改。

返回阅读地图 →
读到这里

从一条请求,看见整个系统。

返回文章目录

本页目录

搜索文章与 Skills

↑ ↓ 选择 · Enter 打开 · Esc 关闭 · Ctrl / ⌘ K 搜索

工程图