AkiYang推理工程手记

投机解码 KV 管理接受路径怎样提交、整理与回收

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

Target 验证过一批候选后,候选 KV 可能都已写进缓存,但单条请求只能沿接受路径继续。接受结果既要选对输入行,也要写到正确的物理槽位;被拒绝的尾部还可能保留为预留空间,不能看到显存没降就认定回收失败。

下面用旧 root R、接受的 A/C 和新 bonus X,逐步核对索引、地址和释放边界。Commit 确认有效输入历史,Compact 把选中行整理成后续模块可消费的布局,Rollback 则要阻止未接受状态影响下一轮;它不总是恢复旧快照或立即 free。输出追加 A/C/X 时,已计算的输入 KV 对应 R/A/C,这个偏移关系会一直带到物理地址映射。

源码版本与阅读范围

源码与适用范围:本文基于 sgl-project/sglang @ 6880a4795533640f41ebb3db9e4ae0af5a371a1f,核对日期为 2026-09-20。主线覆盖 Spec V2 的 EAGLE accepted-path KV compaction、经典 rejection sampling、Target-only sampling,以及 Ascend DeepSeek-V4 的 C4/C128 KV/state 生命周期。当前 DeepSeek-V4 + DSpark 仍是 topk=1 的 linear chain;因此文中的 tree-specific accept_index compaction 主要解释通用 EAGLE Spec V2,DSpark 部分会单独标出。

一、Token、Verify 行、逻辑位置与物理槽位

普通 Decode 很容易形成一个过度简化的心智模型:

第 4096 个 token
        ↓
KV Cache 第 4096 行

token ID、逻辑位置和物理 KV slot 是不同对象。下面的数字只用于推演地址,不是一次运行的内存快照;后续代码也只保留关键调用与关系,不能当作完整可执行脚本。

Logical Sequence Position

假设请求当前已缓存的有效输入前缀长为 seq_len=4096,按零起始编号,下一个待执行输入位于逻辑位置 4096,后续是 4097、4098、...。长度 4096 是位置边界,不能理解成 token 的词表编号,也不能直接当成池中的物理行号。

req_to_token:Logical Position → Physical KV Slot

SGLang 用 request-to-token table 把逻辑位置映射到真实 KV pool slot:

request #7

logical pos     physical KV slot
-----------     ----------------
4094        ->  18320
4095        ->  18321
4096        ->  42080
4097        ->  42081
4098        ->  42082
req_to_token[req_pool_idx, logical_position]

读取结果是物理 KV slot。示意中,位置 4096 对应 slot 42080,前一位置 4095 却在 slot 18321;同一条逻辑序列跨到了另一段物理存储。

这也是 Paged KV 能做到:

logical sequence 连续
≠
physical memory 连续

的基础。

out_cache_loc:这次 Forward 的 K/V 应该写到哪里

Target Verify 一次会处理多个 speculative candidate。模型 Forward 产生新的 K/V 后,需要知道每一行应该写到哪个 physical slot。

这就是:

batch.out_cache_loc

的职责。

EAGLE 在 eagle_prepare_for_verify() 中把:

batch.input_ids = verify_input.draft_token

batch.out_cache_loc = assign_extend_cache_locs_uniform_func(
    req_pool_indices=batch.req_pool_indices,
    req_to_token=req_to_token_pool.req_to_token,
    start_offset=batch.seq_lens,
    batch_size=bs,
    draft_token_num=verify_input.draft_token_num,
    device=device,
)

绑定到 Target Verify。

所以 Verify Forward 并不是只生成 logits:

Draft candidates
      │
      ▼
Target Model
      │
      ├── logits
      ├── hidden states
      └── candidate KV
               │
               ▼
         out_cache_loc

这些行要先经过 Target Forward,Accept 才能根据结果决定保留哪条路径。因此缓存里“写过这个候选”不等于请求已经“接受这个候选”。前者是一次物理写入,后者决定下一轮允许读取的有效历史。

accept_index:不是 KV slot,而是 accepted verify-row path

当 topk > 1 时,candidate 是 tree:

                 root
              /        \
             A          B
           /   \      /   \
          C     D    E     F

如果最终 accepted path 是:root → B → E

Tree 的接受行可能不连续,因此按 accept_index 收集选中的行。

accept_index 解决的是:

tree / verify-row space
        ↓
accepted path

而不是:physical KV address

链路是:

accept_index
    │
    ▼
verify row
    │
    ▼
out_cache_loc[row]
    │
    ▼
source physical KV slot

再通过 committed logical positions 找到 destination slot:

logical committed continuation
        │
        ▼
req_to_token
        │
        ▼
destination physical KV slot

accept_index 先选 Verify 行,out_cache_loc[row] 再给出该行的源 KV slot,req_to_token 则按正式逻辑位置查目标 slot。要判断搬移是否正确,至少要保留这三步转换;只看到一个整数相同,不能证明它们指向同一个对象。

固定源码入口:

二、接受结果怎样索引输出

这里先固定接受索引与预测行的对应关系。

如果只列本轮新增输出,通常会写成:

draft1
draft2
draft3
bonus

由此容易误把接受索引写成:

accept_index
=
[draft1_node, draft2_node, draft3_node, bonus_node]

问题在最后一格:新 bonus 来自某个已执行输入行的预测,本轮不必额外构造一条以它为输入的 Verify 行。

Greedy Tree Verify 的真实写法

verify_tree_greedy_kernel_triton() 一开始会把 root 对应的 retrieve index 写到:accept_index[0]

然后每接受一个 draft child:

  1. 把这个 draft token 写到“上一个 accepted row”的 predicts;
  2. 把 child 的 tree/retrieve row index 追加进 accept_index;
  3. 把这个 child row 设为新的 last_accepted_global_idx。

伪代码可以压成:

accept_index[0] = root_row
last_row = root_row

for accepted_draft in path:
    predicts[last_row] = accepted_draft
    accept_index[next] = child_row
    last_row = child_row

predicts[last_row] = bonus

若接受的 Draft 是 d1、d2、d3,有效索引先包含 root,再包含这些候选对应的输入行:

accept_index valid prefix
=
[root_row, row_after_d1, row_after_d2, row_after_d3]

与此同时:

predict[accept_index[:4]]
=
[d1, d2, d3, bonus]

最后一行有两种读法:读取它保存的 KV,得到最后一个有效输入节点的状态;读取它的 predict,得到这个输入节点预测出的新 bonus。accept_index 选的是承载这些结果的 Verify 行,不是输出 token 身份的列表。

accept_lens 为什么等于 correct drafts + 1

eagle_sample() 返回前明确保持:

num_correct_drafts

只统计 draft,然后返回:

return predict, num_correct_drafts + 1, accept_index
accept_lens
=
num_correct_drafts + 1

其中 +1 就是 terminal target/bonus token。

取得 bonus 时,fill_bonus_tokens 按每请求 block 的 stride 定位,再取有效长度的末尾:

bonus_token_idx
=
accept_stride * pid
+
accept_len
-
1

有效的 accept_index 前缀长为 accept_lens,最后一项对应的 predict 值是 bonus。buffer 宽度可能更大,尾部还可能填有无效索引;消费方必须按有效长度读取,不能把固定宽度等同于本轮真实产出。

Rejection Sampling 也保持相同契约

经典 rejection sampling 的 Triton kernel 同样:accept_index[0] = root_row

每接受一个 draft:

append current row into accept_index
update last_accepted_global_idx

最后 residual / target sampling 得到 terminal token,再写到:

Predicts[last_accepted_global_idx] = final_token

因此它与 Greedy 路径保持同一输出 contract:

predict[accept_index[:accept_lens]]
=
accepted drafts + terminal token

Simulated accept 也遵循同一个布局

generate_simulated_accept_index() 会:

sim_accept_index[:, :simulate_acc_len] = ...
num_correct_drafts.fill_(simulate_acc_len - 1)

real-draft-token 模式下,最后又显式:

bonus_node_indices = sim_accept_index[:, simulate_acc_len - 1]
predict[bonus_node_indices] = target_predict[...]

模拟路径也维持同一索引与输出布局,但这里的长度由模拟参数指定,不是模型实际接受长度。它可以帮助检查数据契约,不能据此报告真实接受率或生成质量。

NPU Sampling 的输入与结果消费

非 Greedy NPU path 会从:sgl_kernel_npu.sample

导入 tree_speculative_sampling_target_only / chain_speculative_sampling_triton。

这条 NPU 分支的结果按以下方式交给后续模块:

  • SGLang 给 NPU kernel 传入同样的 predict / accept_index / accept_token_num contract;
  • 随后的 TP broadcast 同步这三项;
  • downstream fill_bonus_tokens 与 KV compaction 仍按“有效 accept_index 前缀长度 = accept_lens”消费它们。

设备核位于 sgl_kernel_npu,SGLang 按上述输入输出关系衔接接受、广播与缓存整理。

固定源码:

三、把接受路径映射到正式 KV

Target Verify 得到:

predict, accept_lens, accept_index

之后,tree path finalize 会进入:

move_accept_tokens_to_target_kvcache(
    batch,
    accept_index,
    accept_lens - 1,
    token_to_kv_pool_allocator,
)

接下来把输入行、源 slot 和目标 slot 对到同一条接受路径上,先核对长度,再核对搬移方向。

输出增加多少 Token,和哪些 Token 已经有 KV,是两件事

move_accept_tokens_to_target_kvcache() 的第三个参数是 num_correct_drafts,因此调用方传入 accept_lens - 1;函数内部再按 num_correct_drafts + 1 创建目标区间。这个 +1 对应本轮作为输入的旧 root,而本轮新采样出的 terminal bonus 要等下一次 forward 才有自己的 KV。

例如,旧 root 是 R,接受的 Draft 是 A、C,最后采样得到 X:

本轮 Verify 行 该行已计算的 KV 属于 该行选中的输出
root 行 R A
A 行 A C
C 行 C X(新 bonus)

本轮输出追加 [A, C, X],KV 则提交 [R, A, C]。两者长度都是 accept_lens=3,Token 身份相差一位。accept_index 选中的是 Verify 行;用它取 predict 得到输出,用它搬缓存得到这些行的输入 KV。

调用方减 1 时排除新输出 X,KV mover 加 1 时补上旧输入 R。两次运算数值上相消,对应的 token 却不同;若把这两个 1 当成同一枚 bonus,就会要求搬移一个本轮尚不存在的 X 的 KV。

move_kv_cache() 的参数方向已经从实现确认

move_accept_tokens_to_target_kvcache() 会先得到两组位置。

第一组是 destination:

tgt_cache_loc

它来自正式 sequence continuation:[seq_len, seq_len + accept_lens)

对应的 req_to_token physical slots。

第二组是 source:

accept_out_cache_loc

它来自:

accept_index
    ↓
batch.out_cache_loc
    ↓
accepted candidate physical slots

最后调用:

move_kv_cache(
    tgt_cache_loc,
    accept_out_cache_loc,
)

memory_pool.py 的实现给出了明确的参数顺序:

move_kv_cache(tgt_loc, src_loc)

实际赋值也是:KV[tgt] = KV[src]

例如 paged KV implementation 的核心操作是:

kb[pages_t, ..., offs_t, :] = kb[pages_s, ..., offs_s, :]
vb[pages_t, ..., offs_t, :] = vb[pages_s, ..., offs_s, :]

Unified MLA 路径同样是:

env[tgt_pages] = env[src_pages]

所以这条链应该明确画成:

accept_index
    │
    ▼
accept_out_cache_loc
    │
    │ source
    ▼
candidate KV
    │
    │ move_kv_cache(tgt, src)
    ▼
tgt_cache_loc
    │
    ▼
committed KV

延续前面的地址教学例子,假设 Verify 输入 [R,A,B,C] 的 out_cache_loc 是 [42080,42081,42082,42083],接受行是 [0,1,3]。源 slot 由行索引选出 [42080,42081,42083];正式逻辑位置 4096、4097、4098 对应的目标 slot 则是 [42080,42081,42082]。最后一项要把 slot 42083 的 C 状态写到 slot 42082,覆盖那里未接受的 B,而不是反过来覆盖 C。R、A 的源和目标可以恰好相同,不能由此省略其余行的映射。

固定源码:

为什么 Tree Path 还要 Compact predict / hidden_states

KV 搬完以后,tree node layout 仍然可能是:[root, A, B, C, D, E, F]

但 accepted path 是:[root, B, E]

下一轮 Draft Extend 希望消费的却是一个 linear chain。

所以 _finalize_accept_tree_path() 继续:

predict = _compact_accept_to_front(...)

logits_output.hidden_states =
    _compact_accept_to_front(...)

_compact_accept_to_front() 会按 accept_index gather accepted rows,再放到每个 request block 的前面。

因此 Accept 后其实同时发生三件事:

1. path selection
   tree rows → accepted rows

2. KV relocation
   candidate physical slots → committed physical slots

3. tensor compaction
   tree-node predict/hidden layout → contiguous chain layout

其中 KV relocation 处理池地址,Tensor compaction 处理预测与 hidden 的排列;改了其中一种,不会自动修好另一种。例如 KV 已整理为正确路径,但 hidden 仍按原树排列,下一轮 Draft Extend 依然可能把正确 token 配到错误分支的特征上。

当前 DeepSeek-V4 + DSpark 为什么不走这套 Tree Compact

当前 _handle_dspark() 会把:

speculative_num_steps = 1
speculative_eagle_topk = 1

而 candidate window 由 gamma 控制:verify_window = gamma + 1

dspark_worker_v2.py 也明确写着:

Chain layout only:
step index = commit_lens - 1

A tree (topk > 1) layout
would need the accept-index mapping

EAGLE 的树路径需要用 accept_index 找回非连续行;当前 DeepSeek-V4 DSpark 是 linear chain,前缀长度就能确定有效位置。两者都要维护正确边界,但不能因为输出长度都叫 accept length,就认定都执行了同一套树搬移。

DSpark 按连续前缀提交,EAGLE Tree 则额外按接受索引收集非连续行。

四、被拒绝候选与预留空间的生命周期

如果只看算法,reject 好像意味着:

candidate 被拒绝
    ↓
删掉对应 KV

这个动作还涉及空间归谁持有、是否跨页,以及是否要留给下一轮复用。Accept 先排除错误历史,allocator 不必在同一时刻逐个清掉候选 slot。

kv_committed_len 与 kv_allocated_len 是两条不同的线

每个 request 至少维护:

kv_committed_len
kv_allocated_len

可以画成:

0                                              kv_allocated_len
|----------------------------------------------------|
|             committed              |   overshoot   |
|====================================|...............|
                                     ▲
                              kv_committed_len

kv_committed_len 表示有效输入 KV 的逻辑边界,kv_allocated_len 表示请求持有的容量上界。区间 [kv_committed_len,kv_allocated_len) 是 speculative reserve / overshoot 的典型来源,里面可能有已写入但未提交的数据。数值存在不赋予这些行有效历史的身份,下次读取仍应受正式边界约束。

正常下一轮 Decode 不会因为 reject 自动缩小 allocated tail

page_aligned_decode_alloc_lens() 的核心是:

cur = r.kv.kv_allocated_len

nxt = max(
    cur,
    page_align(r.kv.kv_committed_len + reserve),
)

这里 nxt>=cur,正常迭代的分配路径先复用已有容量,只有不足时才扩展。它不会每轮都执行下面这套分配和释放:

每轮 Accept
→ 把 reject tail free
→ 下一轮再重新 alloc

另外:

get_alloc_reserve_per_decode()
=
2 * get_alloc_len_per_decode()

源码注释明确说明这个 double buffer 用来吸收 overlap scheduling 下 kv_committed_len 的滞后。

因此正常 reject 后:

candidate KV 被拒绝
        ↓
不进入 committed boundary
        ↓
physical slots 仍可能保持 allocated
        ↓
下一轮直接覆盖 / 复用

例如只看这条分配公式,假设已分配容量是 4352、committed 边界是 4099、reserve=16、allocator page_size=128,向上对齐 4099+16=4115 得到 4224,取 max(4352,4224) 后仍是 4352,不新增也不缩小分配。这个教学结果符合 reserve 策略;诊断泄漏仍要看请求结束或所有权转移后是否正确回收,不能只凭一次 reject 前后显存不变判断。

Overshoot 的实际释放时机

当前生命周期在以下位置回收预留空间:

生命周期点 实际动作
正常 speculative reject 通常不立即 free;只是不推进 committed ownership,tail 留作 reserve
Request finish / abort / normal release release_kv_cache() 在 cache-owned/owned range 处理后,释放 owned_kv_len → kv_allocated_len 的 overallocated tail
Decode retract / preempt release_req() 最终调用 release_kv_cache(..., is_insert=False),设备侧 request KV(包括 overshoot)被释放;需要恢复的路径先做 backup/offload
StreamingSession finish overshoot _trim_overshoot() 按 authoritative finished_len 释放超出下一轮真实输入的 tail
StreamingSession match/rewind _free_tail() 在重新 extend 前释放旧 session row 中 [prefix_len, kv_allocated_len) 的 orphaned tail
整个 session 释放 session-owned row 最终整体回收

普通迭代内的 reject 通常不逐候选 free,回收由表中的 request/session 生命周期转换触发。release_kv_cache() 还会先把适合保留的历史交给 cache 处理;释放请求持有范围,不等于所有历史 Prefix 都立刻归还设备 allocator。排查时应区分 cache 接管、session 继续持有与真正 free。

页分配下的回收边界

Paged allocator 按整页释放。若有效边界落在页中间,直接 free 第一个尾部 slot 可能连带释放同页仍活跃的行,因此回收要从下一条安全的整页边界开始。

_release_overallocated_kv_indices() 和 StreamingSession 的 _free_kv_aligned() 都会把 free start 向上对齐:ceil_align(boundary, page_size)

这里用的是实际 allocator 的 page_size,不能误用 Attention kernel 的较小页尺寸;有 DCP 扩宽时,两者可能不同。沿用教学边界 4099 与 page_size=128,下一整页边界是 4224,若 allocated=4352,本段 tail reclaim 可释放 [4224,4352)。[4099,4224) 共 125 个位置虽然已在有效边界外,仍与活跃位置共享页,不能在这一步单独归还。

因此要分别检查候选何时被排除、请求何时不再持有某段历史,以及整页何时真正归还。它们可能发生在不同阶段。

固定源码:

五、Ascend 的 KV 页与压缩状态清理

DeepSeek-V4 让“Reject 后清理什么”变得更复杂,因为它不只有 full KV。

Ascend DSV4 pool 至少要区分:

Full KV
SWA KV
C4 compressed KV
C128 compressed KV

C4 compressor state
C128 compressor state

KV 保存供 Attention 读取的历史表示,compressor state 保存继续压缩计算所需的中间状态;清理其中一项,不会自动处理另一项。它们的地址与所有权也不使用同一套规则。

C4 state 的 ownership 还取决于 NPU 架构

固定版本的 dsv4_memory_pool.py 区分两条路径:

路径 C4 compressor state 的寻址与注册
pre-A5 / EXPLICIT 跟随 SWA physical page/state index,纳入 SWA state buffers
A5 / CYCLE 使用 req_pool_idx 对应的独立环形状态,通过 get_c4_state_buf_infos() 单独注册

C128 state 使用请求与绝对位置管理的 ring。讨论 rollback 时,既要区分 compressed KV 与 compressor state,也要确认架构分支。pre-A5 的 C4 按页绑定,A5/CYCLE 则按其请求 Ring 管理。

clear_unaccepted_c128_draft_states() 清的是 state,不是 C128 KV pages

共享 DeepSeek-V4 pool 中存在:

clear_unaccepted_c128_draft_states(...)

其注释直接给出原因:

C128 compression can read rejected draft slots at a boundary;
C4 overwrites its draft slots before reading them.

kernel 对每个 rejected draft_offset:

if draft_offset < accept_len:
    keep

else:
    slot = (seq_len + draft_offset) % ring_size
    clear that C128 state row

清理动作是:

state first half  -> 0
state second half -> -inf

这一步在原有 state buffer 上写入规定的无效值,不改变分配容量,目的是防止后续压缩边界读到旧候选状态。在线性 Verify 窗口中,若 accept_len=3,保留的是 offset 0、1、2,offset 3 起才清理;这个长度包含 root 输入行,不能直接换成只统计 Draft 的接受数。

因此必须把两个动作分开:

C128 rejected state cleanup
≠
C128 KV page free

为什么 C4 没有同样的 rejected-state clear

固定源码给出的理由很明确:C4 overwrites its draft slots before reading them.

所以它不需要照抄 C128 的逐 rejected-offset clear。

这个结论只针对当前 rejected-draft stale-state 风险:C4 在读取相应位置前会覆盖旧数据,因此不需要照搬同一种 clear kernel。它不代表 C4 的页、请求 Ring 或其他生命周期可以不管理。

另外,pre-A5 的 C4 state 跟随 SWA physical pages;A5 的 CYCLE 路径采用独立请求状态。是否需要清理,要结合各自的寻址和覆盖顺序判断。

C128 KV 页的引用计数与释放

Ascend NPU 的 C128 KV 有独立 allocator 和:req_to_c128_sidecar

用来记录 request 当前引用的 C128 physical pages。

release_c128_pages() 维护 page refcount:

refcount -= 1
    ↓
refcount == 0
    ↓
真正归还 c128_attn_allocator

而 request row 释放时:

DSV4NPUReqToTokenPool.free(req)
        ↓
_dsv4_free(req)
        ↓
DSV4NPUTokenToKVPoolAllocator.free(
    req=req,
    req_to_token_pool=...
)
        ↓
release C128 sidecar pages
zero sidecar row
clear request-scoped C128 state

还有一个容易忽略的路径:当 request 的 C128 prefix sidecar 被替换时,replace_req_c128_prefix() 会对变化掉的旧 page reference 做 release,并对新 page retain。

用一个引用计数教学例子看,某页由两个持有者引用,计数为 2;其中一个请求释放引用后变成 1,页仍需保留,最后一个引用释放到 0 才能归还。前缀替换也要释放旧引用、保留新引用,不能仅按当前请求的接受长度直接 free 那一页。

因此 C128 页的回收依据是:

sidecar reference lifecycle
+
page refcount

而不是:accept_lens 直接决定 free 哪几页

SWA 释放与 C128 引用计数的关系

DSV4 allocator 自己把 free API 分成两种:

free(free_index)
→ full + SWA only

free(req=..., req_to_token_pool=...)
→ 有 request identity
→ 才能处理 C128 sidecar / request-scoped state

generic full-KV slot index 不包含完整的 request identity,无法单独说明独立 C128 sidecar 中哪一条引用应该释放。调用 free(free_index) 与带 req 的释放路径,检查的是两种不同层次的信息。

如果把释放概括成:

tree_cache.free_kv_row(...)
→ Full/SWA/C4/C128 全部同步释放

就会遗漏 sidecar 引用和请求状态的处理条件。检查实际 free 调用时,应同时核对参数形式、请求 row 是否仍有效,以及引用计数是否已经归零。

Ascend 还有一个重要例外:ONLINE_C128 根本不是当前 NPU 路径

NPU DSV4 pool 初始化时直接 assert:

ONLINE_C128
+
ratio == 128
→ 不允许

注释说明:

ONLINE_C128 is CUDA-only;
NPU fused compressor has no online mode.

因此 base implementation 里:

if ONLINE_C128:
    return

这条 cleanup bypass 属于 CUDA 的 ONLINE_C128 路径;本文固定版本的 Ascend NPU 使用另一条路径。

EAGLE Cleanup 与 DSpark 提交路径

clear_unaccepted_c128_draft_states(...) 的显式调用位于 EAGLE 共享 Verify 流程 run_eagle_verify(),清理未接受的 C128 Compressor State。

同版本 DSpark Worker / Verify 路径未找到同名直接调用。它通过 commit_lens 与 Target hidden 注入控制提交范围;这不能直接证明“不需要清理”,也不能据此认定“少调用导致了 bug”。分析 DSpark 的 compressor state,仍需沿它自己的写入、覆盖与提交路径核对是否存在读取未接受状态的机会。

固定源码:

六、串起 Commit、Compact 与 Rollback

到这里,可以把整轮 speculative KV 生命周期画成下面这样:

flowchart TD
    A[Committed Prefix] --> B[Reserve / Allocate Spec KV Capacity]
    B --> C[Draft Candidates]
    C --> D[Target Verify]
    D --> E[Candidate KV written]
    D --> F[Target logits]

    F --> G[Accept]
    G --> H[accept_index]
    G --> I[accept_lens / commit_lens]

    H --> J[Select accepted verify rows]
    J --> K[Resolve source KV slots]
    I --> L[Resolve committed logical positions]
    L --> M[Resolve target KV slots]

    K --> N["move_kv_cache(tgt, src)"]
    M --> N
    N --> O[Committed KV]

    H --> P[Compact predict / hidden]
    P --> Q[Next-round linear state]

    G --> R[Rejected candidates]
    R --> S[Do not advance committed ownership]
    S --> T[Keep as reusable overshoot]
    T --> U[Finish / retract / trim / rewind]
    U --> V[Page-safe physical reclaim]

    R --> W[DSV4 special state hygiene]

这条路径里的 Rollback 不要求先保存完整旧快照再逐字节恢复。它可以由下面这些机制共同完成:

1. logical non-commit
2. accepted-path remap / relocation
3. tensor compaction
4. speculative reserve reuse
5. lifecycle-driven physical reclaim
6. backend-specific stale-state cleanup

一轮结束时,后续可见的 token、KV、hidden 与压缩器状态必须对应同一条有效历史;allocator 容量则可以超过这个边界。检查“是否回滚正确”,应核对后续会读取什么,而不只是看尾部内存是否已经清零或显存是否下降。

Debug 时核对索引、长度与页归属

以后排查 Spec V2 KV bug,可以先检查下面这组关系。

accept_lens
=
num_correct_drafts + 1
valid_accept_index_count
=
accept_lens
predict[accept_index[:accept_lens]]
=
accepted drafts + terminal bonus
move_kv_cache(tgt, src)
=
KV[tgt] <- KV[src]
new_seq_lens
=
old_seq_lens + accept_lens
kv_committed_len
<=
kv_allocated_len

以及:

Reject
≠
immediate physical free

如果 DeepSeek-V4 / Ascend 还涉及 compressed state,再额外问三句:

我现在讨论的是 KV page 还是 compressor state?

这个 state 是 SWA/page-owned 还是 request-scoped?

cleanup 是为了防 stale read,还是为了把 physical page 还给 allocator?

这三项会把问题分到不同入口:接受路径错先看行索引,搬移错看 source/destination,容量不回收看 ownership 与页边界,压缩边界读旧值则继续看 state 的覆盖和清理。它们需要的证据不同,不能都归成“KV 没释放”。

七、源码入口与计数对照

下面按输入构造、接受路径、物理搬移与回收顺序列出源码入口。

目标 固定版本源码
Verify 输入与 candidate out_cache_loc eagle_prepare_for_verify()
Greedy accepted path / bonus placement spec_tree.py
Rejection sampling accepted path / terminal token reject_sampling.py
Bonus extraction contract eagle.py
Accepted KV relocation move_accept_tokens_to_target_kvcache()
move_kv_cache(tgt, src) 实现 memory_pool.py
Tree path tensor compaction eagle_worker_common.py
Spec V2 reserve sizing allocation_sizing.py
Finish/abort overshoot reclaim mem_cache/common.py
Retract reclaim schedule_batch.py
Streaming overshoot trim streaming_session.py
Ascend C128 sidecar/refcount dsv4_allocator.py
Ascend C4/C128 state ownership dsv4_memory_pool.py
Rejected C128 state sanitation c128_cleanup.py

核对计数时,要分别记录有效输出长度、已物化输入 KV 的提交边界和预留空间上界。它们相互关联,却不总是指向同一枚 Token 或同一个物理位置。前面的 R,A,C → A,C,X 示例可以作为检查索引、搬移与回收的最小对照。

返回阅读地图 →
读到这里

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

返回文章目录

本页目录

搜索文章与 Skills

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

工程图