Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ class InklingShortConvMetadata(msgspec.Struct):
# [B, conv_kernel - 1] input positions whose conv window feeds the prefix
# cache. Extend only, and only when tracking is on.
track_conv_indices: Optional[torch.Tensor] = None
# Kernel-facing checkpoint slots. ForwardBatch retains the virtual source.
track_cache_indices: Optional[torch.Tensor] = None


class InklingShortConvAttnBackend(ShortConvAttnBackend):
Expand Down Expand Up @@ -144,7 +146,10 @@ def _alloc_graph_buffers(self):
self._graph_track_conv_indices = torch.zeros(
(max_bs, self.conv_state_len), dtype=torch.int64, device=dev
)
# Inert track fields for graph capture: a capture warmup batch that
# Prefill can capture before init_cuda_graph_state. Keep the translated
# checkpoint destinations at one address across both graph phases.
self._graph_track_indices = torch.empty(max_bs, dtype=torch.int64, device=dev)
# Inert prefill track fields (excluding draft extend): a warmup batch that
# carries no tracking metadata must still LAUNCH the track scatter
# (all rows masked off), or the python-level `if` specializes the
# scatter out of the captured graph.
Expand All @@ -155,8 +160,6 @@ def _alloc_graph_buffers(self):
self._graph_track_inert_seqlens = torch.zeros(
max_bs, dtype=torch.int64, device=dev
)
# Graph-static: captured track scatters read this address.
self._track_indices_buf = torch.zeros(max_bs, dtype=torch.int64, device=dev)
# Same address-stability requirement; the base only sizes this from
# init_cuda_graph_state, which the prefill graph never calls.
self._alloc_cache_indices_buf(max_bs)
Expand Down Expand Up @@ -250,6 +253,7 @@ def init_forward_metadata_capture_cpu_graph(self, *args, **kwargs):

def _prepare_slot_indices(self, forward_batch: ForwardBatch):
self._reset_step_state()
self._prepare_track_indices(forward_batch)
req_pool_indices = forward_batch.req_pool_indices
n = req_pool_indices.shape[0]
buf = self._cache_indices_buf
Expand All @@ -271,22 +275,22 @@ def _prepare_slot_indices(self, forward_batch: ForwardBatch):
return
self.forward_metadata = self._forward_metadata(forward_batch)
self._refresh_cache_indices()
if not self._slot_gather_recordable:
self._translate_track_indices(forward_batch)

def _translate_track_indices(self, forward_batch: ForwardBatch):
"""Translate virtual track ids to the physical slots the conv kernels write."""
track_indices = forward_batch.mamba_track_indices
buf = self._track_indices_buf
# Prep may re-run on the same batch; never translate twice.
if track_indices is None or track_indices.data_ptr() == buf.data_ptr():

def _prepare_track_indices(self, forward_batch: ForwardBatch):
# Every metadata view must explicitly provide virtual IDs or None.
indices = forward_batch.mamba_track_indices

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug] Unified _prepare_slot_indices now always calls _prepare_track_indices, which does a bare forward_batch.mamba_track_indices load. Decode/prefill replay views include that field (None when tracking is off). EAGLE draft-extend replay does not: EAGLEDraftExtendCudaGraphRunner.replay and MultiLayerEagleDraftExtendCudaGraphRunner pass a SimpleNamespace with req_pool_indices / seq_lens / spec_info but no mamba_track_indices (python/sglang/srt/speculative/eagle_draft_extend_cuda_graph_runner.py:609 and python/sglang/srt/speculative/multi_layer_eagle_draft_extend_cuda_graph_runner.py:483). Inkling's hybrid wrapper still forwards init_forward_metadata_out_graph to the sidecar on DRAFT_EXTEND_V2 (the draft runs its own convs). The new load therefore raises AttributeError on unified-memory draft-extend graph replay and aborts the rest of _prepare_slot_indices, so active-slot translation is skipped too. Decode capture already documents the intended contract: the registry slot is the virtual source and the backend copies into its own static buffer.

Suggestion: Treat a missing field as None so cache-index prep still runs, and add mamba_track_indices (virtual registry slice, or None) to both draft-extend replay views the same way decode_cuda_graph_runner._replay_forward_batch does. If draft-extend graphs are supposed to refresh checkpoint destinations, the capture batch needs that slot as well; today capture builds a ForwardBatch with the default None, so the track-save is specialized out of the graph independently of this PR.

@seokwoosong seokwoosong Sep 19, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Modifications.
1. Explicitly provide checkpoint inputs in both draft-extend runners : Added mamba_track_indices to capture, replay, and multi-layer staging. Disabled tracking uses explicit None; replay refreshes live IDs and padding, or clears the buffer to sink IDs while preserving its address.

2. Keep checkpoint destinations correct across translation and graph replay : Physical destinations now live in separate, fixed-address backend metadata, while ForwardBatch retains virtual IDs to prevent double translation. The draft accepted-window checkpoint path is preserved.

3. Additional fix found during validation: Propagate the lazy-allocation policy consistently.
Fixed a scheduler/request-pool policy mismatch that could send an unallocated -1 slot to cleanup. Added regressions for both two- and three-pool configurations.

Validation summary

  • 74 tests and 125 subtests passed, covering CUDA capture/replay, checkpoint readback, compaction, padding, disabled tracking, staging, and production buffer/pool construction.
  • Earlier serving validation: 41 requests across five reduced-Inkling/Qwen configurations, with no observed negative-slot frees or accounting failures.

if indices is None or self._slot_gather_recordable:
self.sconv_metadata.track_cache_indices = indices
return
n = track_indices.shape[0]
assert n <= buf.shape[0], (
f"track-index buffer too small: rows={n} vs bound {buf.shape[0]}"
n = indices.shape[0]
assert n <= self._graph_track_indices.shape[0], (
"checkpoint-index buffer too small for the forward batch"
)
buf[:n].copy_(self._translate_mamba_indices(track_indices))
forward_batch.mamba_track_indices = buf[:n]
# Keep the batch virtual across repeated eager metadata initialization
# and across draft backends. Captured consumers read this fixed buffer.
out = self._graph_track_indices[:n]
out.copy_(self._translate_mamba_indices(indices))
self.sconv_metadata.track_cache_indices = out
Comment on lines +279 to +293

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocker] Rebase onto #41144: the merged tree translates twice.

#41144 (on main as da3eb6d) fixes the same bug differently. At the end of _prepare_slot_indices it rebinds forward_batch.mamba_track_indices to a physical buffer (_translate_track_indices / _track_indices_buf). This branch merges onto main with no conflict, but the result keeps both paths, and _prepare_track_indices assumes the field is still virtual:

After any compaction, checkpoints then land in another request's slot or in a free one.

This PR's convention (ForwardBatch stays virtual, physical ids live in backend metadata) is the one that holds up across multiple backends. So I'd delete #41144's _translate_track_indices / _track_indices_buf during the rebase rather than keep two translations. Keep the translate in commit_conv_state_after_mtp_verify: #41144 explicitly left it as a follow-up, so it isn't covered on main.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the in-place rebind and its translation buffer. ForwardBatch retains virtual IDs, and each backend prepares its own physical destinations. A focused multi-backend test reproduces the upstream failure and passes with this fix.


def _refresh_sconv_metadata(
self, forward_batch: ForwardBatch, *, on_graph_path: bool
Expand Down Expand Up @@ -451,6 +455,11 @@ def _refresh_track_conv_indices(
``batch_size`` rows while the track lengths cover only live requests, and
every row it may read must index inside *this* replay's token buffer.
"""
if forward_batch.forward_mode.is_draft_extend_v2():
# Draft extend snapshots the accepted window in its own cache update.
# Inert prefill tracking would overwrite its checkpoint destinations.
self.sconv_metadata.track_conv_indices = None
return
Comment on lines +458 to +462

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this early return deliberately skips the inert track scatter for draft extend. That contradicts the __init__ comment at L152-155, which says a capture batch without tracking metadata must still launch it. Please scope that comment to exclude draft extend, so nobody later "fixes" this return and brings back the overwrite you're avoiding.

Comment on lines +458 to +462

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] No end-to-end run exercises the new draft-extend checkpoint write.

On the static pool, this early return plus the capture batch now carrying mamba_track_indices in both draft-extend runners is the only change with a visible effect on the device. On main, the draft-extend capture batch has no track indices, so the inert branch below rebinds forward_batch.mamba_track_indices to the zero buffer and _update_sconv_cache_for_draft_extend bakes those zeros into the graph. Every graph replay then writes the draft's boundary conv window into reserved slot 0, while eager draft extend writes the real ping-pong slot. With this PR the graph writes the real slot too.

Where it shows up: static pool + Inkling MTP (EAGLE or multi-layer EAGLE) + extra_buffer + draft-extend CUDA graph. A boundary crossed during decode is cached, and a later request that hits that prefix restores the draft conv state from the cached slot (multi-layer EAGLE copies the draft pools in _apply_deferred_mamba_init_to_draft_pools). On main that request restores a stale window, so accept length on cache hits should go up with this PR. The posted runs can't show it: the KL checks only read target logprobs, and accept length is 2.736 on both sides.

Could you add a shared-prefix accept-length comparison, main vs this PR? For example, a multi-turn run where turn 2 reuses turn 1's generated tokens past a mamba_track_interval boundary. If that's too costly, a line in the description naming test_draft_runners_capture_and_replay_checkpoint_destinations as the coverage for this path works too.

@seokwoosong seokwoosong Oct 6, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ch-wan

Thanks for pointing out the coverage gap. I added a shared-prefix comparison between the merge parent (1e49077) and the merged fix (1d02a36), exercising draft-extend graph replay and restoration into both draft pools.

  • Across 12 varied inputs, aggregate draft acceptance changed from 72.67% to 73.06%. One proof case consistently required 29 rather than 30 verify calls for the same 64 output tokens, reproduced in five additional flush/reseed repetitions.
  • Results were not uniformly positive: a separate 8-token comparison had unchanged verify counts and one fewer accepted draft overall. I added the setup and limitations to the description and explicitly named test_draft_runners_capture_and_replay_checkpoint_destinations as the focused regression coverage.

if forward_batch.mamba_track_mask is None:
if not on_graph_path:
self.sconv_metadata.track_conv_indices = None
Expand All @@ -460,6 +469,9 @@ def _refresh_track_conv_indices(
rows = forward_batch.batch_size
forward_batch.mamba_track_mask = self._graph_track_inert_mask[:rows]
forward_batch.mamba_track_indices = self._graph_track_inert_indices[:rows]
self.sconv_metadata.track_cache_indices = self._graph_track_inert_indices[
:rows
]
forward_batch.mamba_track_seqlens = self._graph_track_inert_seqlens[:rows]
rows = forward_batch.batch_size
query_start_loc = self.sconv_metadata.query_start_loc
Expand Down Expand Up @@ -532,6 +544,8 @@ def commit_conv_state_after_mtp_verify(
slot_ids = self._translate_mamba_indices(
pool.get_mamba_indices(req_pool_indices)
)
if mamba_track_indices is not None:
mamba_track_indices = self._translate_mamba_indices(mamba_track_indices)
scatter_mamba_states_after_mtp_verify(
pool.get_speculative_mamba2_params_all_layers(),
slot_ids,
Expand Down Expand Up @@ -603,6 +617,7 @@ def init_forward_metadata_in_graph(self, forward_batch: ForwardBatch):
and self.supports_draft_extend_metadata_staging
):
self.short_conv_backend._reset_step_state()
self.short_conv_backend._prepare_track_indices(forward_batch)
self.short_conv_backend._refresh_sconv_metadata(
forward_batch, on_graph_path=True
)
Expand Down
2 changes: 1 addition & 1 deletion python/sglang/srt/models/inkling_common/attn.py
Original file line number Diff line number Diff line change
Expand Up @@ -725,7 +725,7 @@ def _fused_attn_prologue_decode(self, q, k, v, forward_batch, log_scaling_tau=No
activation=self.k_sconv.activation,
use_residual=self.k_sconv.use_residual,
track_mask=forward_batch.mamba_track_mask,
track_indices=forward_batch.mamba_track_indices,
track_indices=self.k_sconv._conv_state(forward_batch).track_cache_indices,
do_store=do_store,
mxfp8_quant=do_mxfp8_store,
sfk=sfk,
Expand Down
4 changes: 2 additions & 2 deletions python/sglang/srt/models/inkling_common/kernels/comm.py
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,7 @@ def ar_sconv_norm_fused(
activation=sconv.activation,
use_residual=sconv.use_residual,
track_mask=forward_batch.mamba_track_mask,
track_indices=forward_batch.mamba_track_indices,
track_indices=sconv._conv_state(forward_batch).track_cache_indices,
shared=shared,
)
res.v5_cur = 1 - cur
Expand Down Expand Up @@ -834,7 +834,7 @@ def ar_scattered_sconv_fused(
if fm.is_decode() and forward_batch.mamba_track_mask is not None:
b = forward_batch.batch_size
track_mask = forward_batch.mamba_track_mask[:b]
track_dst = forward_batch.mamba_track_indices[:b]
track_dst = sconv._conv_state(forward_batch).track_cache_indices[:b]
track_from_cache = True

esz = comm.buffer.element_size()
Expand Down
8 changes: 4 additions & 4 deletions python/sglang/srt/models/inkling_common/sconv.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ def _apply_decode_sconv_kernel(
activation=self.activation,
use_residual=self.use_residual,
track_mask=forward_batch.mamba_track_mask,
track_indices=forward_batch.mamba_track_indices,
track_indices=self._conv_state(forward_batch).track_cache_indices,
)

def _prepare_extend_sconv_cache(
Expand All @@ -216,7 +216,7 @@ def _prepare_extend_sconv_cache(
sconv_cache=sconv_cache,
track_conv_indices=track_conv_indices,
mask=forward_batch.mamba_track_mask,
dst_indices=forward_batch.mamba_track_indices,
dst_indices=self._conv_state(forward_batch).track_cache_indices,
)

def _save_intermediate_conv_windows(
Expand Down Expand Up @@ -269,7 +269,7 @@ def _update_sconv_cache_for_draft_extend(
else:
draft_token_num = hidden_states.shape[1]

mamba_track_indices = getattr(forward_batch, "mamba_track_indices", None)
mamba_track_indices = self._conv_state(forward_batch).track_cache_indices
do_tracking = (
mamba_track_indices is not None
and get_exec().mamba.enable_mamba_extra_buffer
Expand Down Expand Up @@ -349,7 +349,7 @@ def extend_fused_ar_inputs(self, forward_batch: ForwardBatch):
if meta.track_conv_indices is not None:
track_rows = meta.track_conv_indices
track_mask = forward_batch.mamba_track_mask
track_dst = forward_batch.mamba_track_indices
track_dst = meta.track_cache_indices
else:
w1 = self.kernel_size[0] - 1
track_rows = torch.empty((0, w1), dtype=torch.int64, device=dev)
Expand Down
27 changes: 27 additions & 0 deletions python/sglang/srt/speculative/draft_checkpoint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from typing import Optional

import torch


def track_indices(buffer: Optional[torch.Tensor], bs: int):
return None if buffer is None else buffer[:bs]


def refresh_track_indices(buffer, src, *, raw_bs, bs, copy_dsts=None, copy_srcs=None):
"""Refresh virtual destinations without clearing rows that will be copied.

Ordinary EAGLE joins its existing grouped copy. The multi-layer runner
uses a fused preparation kernel for other inputs and copies here instead.
"""
if buffer is None:
return
if src is None:
buffer[:bs].zero_()
return
if raw_bs < bs:
buffer[raw_bs:bs].zero_()
if copy_dsts is None:
buffer[:raw_bs].copy_(src)
else:
copy_dsts.append(buffer[:raw_bs])
copy_srcs.append(src)
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
get_parallel,
get_spec,
)
from sglang.srt.speculative.draft_checkpoint import refresh_track_indices, track_indices
from sglang.srt.speculative.eagle_info import EagleDraftExtendInput
from sglang.srt.speculative.eagle_utils import get_draft_input_from_target_hidden_dim
from sglang.srt.speculative.spec_utils import resolve_num_tokens_per_req
Expand Down Expand Up @@ -74,6 +75,7 @@ def resolve_draft_extend_seq_len_fill_value(
class EagleDraftExtendInputBuffers(ForwardInputBuffers):
input_ids: torch.Tensor
req_pool_indices: torch.Tensor
mamba_track_indices: Optional[torch.Tensor]
out_cache_loc: torch.Tensor
positions: torch.Tensor
mrope_positions: torch.Tensor
Expand Down Expand Up @@ -268,6 +270,11 @@ def __init__(
self.buffers = EagleDraftExtendInputBuffers(
input_ids=input_ids,
req_pool_indices=req_pool_indices,
mamba_track_indices=(
torch.zeros(self.max_bs, dtype=torch.int64, device=self.device)
if get_exec().mamba.enable_mamba_extra_buffer
else None
),
out_cache_loc=out_cache_loc,
positions=positions,
mrope_positions=mrope_positions,
Expand Down Expand Up @@ -421,6 +428,7 @@ def capture_one_shape(
batch_size=bs,
input_ids=input_ids,
req_pool_indices=req_pool_indices,
mamba_track_indices=track_indices(buffers.mamba_track_indices, bs),
seq_lens=seq_lens,
seq_lens_cpu=seq_lens_cpu,
next_token_logits_buffer=next_token_logits_buffer,
Expand Down Expand Up @@ -572,6 +580,14 @@ def execute(self, forward_batch: ForwardBatch, select_index: torch.Tensor):
copy_srcs.append(forward_batch.spec_info.num_accept_tokens)
copy_dsts.append(buffers.select_index[:raw_bs])
copy_srcs.append(select_index)
refresh_track_indices(
buffers.mamba_track_indices,
forward_batch.mamba_track_indices,
raw_bs=raw_bs,
bs=bs,
copy_dsts=copy_dsts,
copy_srcs=copy_srcs,
)
_grouped_foreach_copy_(copy_dsts, copy_srcs)

# hidden_states is large + contiguous: copy_() uses the cudaMemcpyAsync
Expand Down Expand Up @@ -633,6 +649,8 @@ def execute(self, forward_batch: ForwardBatch, select_index: torch.Tensor):
encoder_lens=None,
out_cache_loc=buffers.out_cache_loc[:num_tokens],
out_cache_loc_dsv4=getattr(forward_batch, "out_cache_loc_dsv4", None),
# Virtual input stays separate from the backend's physical buffer.
mamba_track_indices=track_indices(buffers.mamba_track_indices, bs),
spec_info=forward_batch.spec_info,
)
self.draft_extend_attn_backend.init_forward_metadata_out_graph(fb_view)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
get_parallel,
get_spec,
)
from sglang.srt.speculative.draft_checkpoint import refresh_track_indices, track_indices
from sglang.srt.speculative.eagle_info import EagleDraftExtendInput
from sglang.srt.speculative.eagle_utils import get_draft_input_from_target_hidden_dim
from sglang.srt.speculative.multi_layer_eagle_utils import (
Expand Down Expand Up @@ -116,6 +117,7 @@ class MultiLayerEagleDraftExtendInputBuffers(ForwardInputBuffers):
seq_lens: torch.Tensor
seq_lens_cpu: torch.Tensor
req_pool_indices: torch.Tensor
mamba_track_indices: Optional[torch.Tensor]
num_correct_drafts: torch.Tensor
num_accept_tokens: torch.Tensor
extend_seq_lens: torch.Tensor
Expand Down Expand Up @@ -335,6 +337,7 @@ def get_forward_batch(self, bs: int) -> ForwardBatch:
batch_size=bs,
input_ids=input_ids,
req_pool_indices=req_pool_indices,
mamba_track_indices=track_indices(buffers.mamba_track_indices, bs),
seq_lens=seq_lens,
seq_lens_cpu=seq_lens_cpu,
next_token_logits_buffer=next_token_logits_buffer,
Expand Down Expand Up @@ -493,6 +496,8 @@ def replay(
encoder_lens=None,
# per-step write target; out_cache_loc is frozen at prepare() time.
out_cache_loc=buffers.out_cache_loc[:num_tokens],
# Virtual input stays separate from the backend's physical buffer.
mamba_track_indices=track_indices(buffers.mamba_track_indices, bs),
spec_info=spec_info,
)
if (
Expand Down Expand Up @@ -702,6 +707,11 @@ def _allocate_buffers(self) -> MultiLayerEagleDraftExtendInputBuffers:
seq_lens=seq_lens,
seq_lens_cpu=seq_lens_cpu,
req_pool_indices=req_pool_indices,
mamba_track_indices=(
torch.zeros(self.max_bs, dtype=torch.int64, device=self.device)
if get_exec().mamba.enable_mamba_extra_buffer
else None
),
num_correct_drafts=num_correct_drafts,
num_accept_tokens=num_accept_tokens,
extend_seq_lens=extend_seq_lens,
Expand Down Expand Up @@ -755,6 +765,7 @@ def _stage_metadata(self, bs: int, raw_bs: int):
seq_lens=buffers.seq_lens[:bs],
extend_seq_lens=buffers.extend_seq_lens[:bs],
out_cache_loc=buffers.out_cache_loc[: bs * self.captured_req_width],
mamba_track_indices=track_indices(buffers.mamba_track_indices, bs),
)
for backend in backends:
backend.init_forward_metadata_out_graph(batch)
Expand All @@ -773,6 +784,13 @@ def prepare(self, forward_batch: ForwardBatch, *, staged: bool = False):
else:
bs = self.get_runner(0)._pad_to_bucket(raw_bs, self.capture_bs)

refresh_track_indices(
buffers.mamba_track_indices,
forward_batch.mamba_track_indices,
raw_bs=raw_bs,
bs=bs,
)

fill_draft_extend_prepare_buffers(
buffers.input_ids,
buffers.positions,
Expand Down
Loading
Loading