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-specificaccept_indexcompaction 主要解释通用 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:
- 把这个 draft token 写到“上一个 accepted row”的
predicts; - 把 child 的 tree/retrieve row index 追加进
accept_index; - 把这个 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_numcontract; - 随后的 TP broadcast 同步这三项;
- downstream
fill_bonus_tokens与 KV compaction 仍按“有效accept_index前缀长度 =accept_lens”消费它们。
设备核位于 sgl_kernel_npu,SGLang 按上述输入输出关系衔接接受、广播与缓存整理。
固定源码:
verify_tree_greedy_kernel_triton()chain_speculative_sampling_triton()fill_bonus_tokensgenerate_simulated_accept_index()
三、把接受路径映射到正式 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 个位置虽然已在有效边界外,仍与活跃位置共享页,不能在这一步单独归还。
因此要分别检查候选何时被排除、请求何时不再持有某段历史,以及整页何时真正归还。它们可能发生在不同阶段。
固定源码:
allocation_sizing.pyrelease_kv_cache()/_release_overallocated_kv_indices()release_req()StreamingSession._trim_overshoot()/_free_tail()
五、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,仍需沿它自己的写入、覆盖与提交路径核对是否存在读取未接受状态的机会。
固定源码:
deepseek_v4_memory_pool.pyc128_cleanup.pydsv4_memory_pool.pydsv4_allocator.pydsv4_req_to_token_pool.py
六、串起 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 示例可以作为检查索引、搬移与回收的最小对照。