Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
ae912f8
feat(model_engine): return per-request MoE routed experts from genera…
sudhu2k Sep 16, 2026
0dd2de0
feat(model_engine): harden routed-experts capture for PP, CUDA graphs…
sudhu2k Sep 16, 2026
c94c0b2
feat(model_engine): harden routed-experts return against disagg/topol…
sudhu2k Sep 16, 2026
fe8c0b9
feat(model_engine): harden routed-experts return against DP-attention…
sudhu2k Sep 16, 2026
ed12c55
feat(model_engine): move routed-experts export to a CPU slot buffer w…
sudhu2k Sep 17, 2026
6a8d086
docs(model_engine): revise routed-experts CPU store_step docstring to…
sudhu2k Sep 17, 2026
ec8239d
Apply ruff and black formatter fixes
sudhu2k Sep 17, 2026
e6a574b
Merge branch 'main' into sudhu/enable_rollout_router_replay
sudhu2k Sep 17, 2026
62332e6
Black formatting fix
sudhu2k Sep 17, 2026
6586c2c
fix(rollout/model): reset routed-experts capturer on KV cache release
sudhu2k Sep 17, 2026
96fbcf2
Merge remote-tracking branch 'origin/main' into sudhu/enable_rollout_…
sudhu2k Sep 23, 2026
888abcd
feat(moe): incremental routed-expert export and deferred async storage
sudhu2k Sep 30, 2026
52cb3d2
fix(moe): fence async routed-expert D2H copies and record source streams
sudhu2k Oct 1, 2026
51bcc30
fix(moe): snapshot slot_mapping before async routed-expert D2H reuse
sudhu2k Oct 1, 2026
57cd818
fix(moe): cache per-forward dest slots and avoid full block-table cop…
sudhu2k Oct 1, 2026
3372d12
Merge remote-tracking branch 'origin/main' into sudhu/enable_rollout_…
sudhu2k Oct 1, 2026
10added
Merge branch 'main' into sudhu/enable_rollout_router_replay
sudhu2k Oct 5, 2026
a1850df
Revert request dataclass
sudhu2k Oct 6, 2026
b340b8f
Merge remote-tracking branch 'origin/main' into sudhu/enable_rollout_…
sudhu2k Oct 14, 2026
49144bc
Formatter fixes
sudhu2k Oct 14, 2026
ab2bb23
fix(routed_experts): return routes in batch token order with prefix-c…
sudhu2k Oct 14, 2026
ce4b61d
fix(routed_experts): move route ownership from capturer ticket to bat…
sudhu2k Oct 14, 2026
53f46b7
fix(routed_experts): gate capture on attention metadata slot support …
sudhu2k Oct 15, 2026
95a7e6b
Merge remote-tracking branch 'origin/main' into sudhu/enable_rollout_…
sudhu2k Oct 16, 2026
1696e67
test(mono): add `enable_return_routed_experts=False` to GLM-5.2 and K…
sudhu2k Oct 16, 2026
b542967
Merge remote-tracking branch 'origin/main' into sudhu/enable_rollout_…
sudhu2k Oct 27, 2026
18430bb
fix formatting
sudhu2k Oct 27, 2026
c271f13
fix(routed-experts): harden capture ownership, prefix routing, and pl…
sudhu2k Oct 28, 2026
ecac39b
Merge remote-tracking branch 'origin/main' into sudhu/enable_rollout_…
sudhu2k Oct 28, 2026
9606dcd
fix(model_runner): reorder imports to satisfy linter/dependency const…
sudhu2k Oct 28, 2026
b02deb7
fix(scheduler): always clamp routed_forwarded_end to delivered rows
sudhu2k Oct 28, 2026
af142eb
Merge remote-tracking branch 'origin/main' into sudhu/enable_rollout_…
sudhu2k Nov 2, 2026
0e63603
Address PR comments
sudhu2k Nov 3, 2026
cddc323
Merge branch 'main' into sudhu/enable_rollout_router_replay
sudhu2k Nov 3, 2026
329b855
Merge branch 'main' into sudhu/enable_rollout_router_replay
sudhu2k Nov 3, 2026
9a12cc8
Merge remote-tracking branch 'origin/main' into sudhu/enable_rollout_…
sudhu2k Nov 4, 2026
fc666e6
fix(routed-experts): support DP attention, Qwen4Exp, and middle-chunk…
sudhu2k Nov 4, 2026
ad10b75
Merge branch 'main' into sudhu/enable_rollout_router_replay
sudhu2k Nov 4, 2026
9d4242c
fix(routed-experts): update help text to state plugin mode restrictio…
sudhu2k Nov 4, 2026
dbd0631
fix(routed-experts): fix dummy prefill route patching, clean up impor…
sudhu2k Nov 4, 2026
c9e710c
ci: retrigger unit tests
sudhu2k Nov 5, 2026
e12acf1
fix(routed-experts): silently drop routed expert rows for requests no…
sudhu2k Nov 5, 2026
3cdd10b
fix(routed-experts): per-runner capture lifecycle and unified routing…
sudhu2k Nov 5, 2026
daeaaf7
fix(routed-experts): replace global capturer singleton with per-runne…
sudhu2k Nov 5, 2026
f869895
fix(routed-experts): remove DP-attention restriction and rename captu…
sudhu2k Nov 5, 2026
1105f69
fix(routed-experts): model-specific mono refusals, live route views, …
sudhu2k Nov 5, 2026
b79d1c1
Merge branch 'main' into sudhu/enable_rollout_router_replay
sudhu2k Nov 5, 2026
e61a333
fix(routed-experts): add missing _return_routed_experts flag to sched…
sudhu2k Nov 5, 2026
a5ff585
Merge remote-tracking branch 'origin/main' into sudhu/enable_rollout_…
sudhu2k Nov 5, 2026
6d1929c
Merge branch 'main' into sudhu/enable_rollout_router_replay
sudhu2k Nov 6, 2026
078cfc5
Merge branch 'main' into sudhu/enable_rollout_router_replay
sudhu2k Nov 6, 2026
3783481
Merge branch 'main' into sudhu/enable_rollout_router_replay
sudhu2k Nov 6, 2026
014f72f
Merge remote-tracking branch 'origin/main' into sudhu/enable_rollout_…
sudhu2k Nov 9, 2026
b258308
Merge branch 'main' into sudhu/enable_rollout_router_replay
sudhu2k Nov 10, 2026
9ea1bd6
Merge remote-tracking branch 'origin/main' into sudhu/enable_rollout_…
sudhu2k Nov 10, 2026
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
23 changes: 23 additions & 0 deletions atom/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1728,6 +1728,11 @@ class Config:
kv_cache_dtype: str = "bf16"
index_cache_dtype: str | None = None
enable_prefix_caching: bool = True
# Return per-request MoE routes from generate() as int16
# [seq_len - 1, num_layers, top_k]. Scatter is CUDA-graph-safe (device
# write inside fused MoE). Requires DCP=PCP=PP=1, no DP-attention, and
# no KV transfer / offload / RapidServe P/D; prefix cache stays on.
Comment thread
Copilot marked this conversation as resolved.
Resolved
enable_return_routed_experts: bool = False
Comment thread
Copilot marked this conversation as resolved.
Resolved

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.

[15] Not in docs/configuration_guide.md (Config / EngineArgs tables) or docs/scheduling_kv_cache_guide.md. This comment and the CLI help list only the config-time refusals; dense models, V4/V4.1 builders and "mono turns off" are found only after weight load.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Added docs.

enable_chunked_prefill: bool = True
enable_log_stats: bool = True
# Seconds between engine-status lines. Validated > 0 by EngineStats.
Expand Down Expand Up @@ -2142,6 +2147,24 @@ def __post_init__(self):

self.kv_transfer_config = ast.literal_eval(self.kv_transfer_config)

if self.enable_return_routed_experts:
from atom.model_ops.fused_moe.routed_experts_capturer import (
check_return_routed_experts,
)

check_return_routed_experts(

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.

The same check is duplicated in atom/model_engine/scheduler.py:679 and atom/model_engine/model_runner.py:4723, each passing slightly different inputs (direct attributes vs getattr, explicit RapidServe flag). These will drift; please funnel all three through one helper that resolves the effective config. Also note plugin mode (vLLM/SGLang) is not refused here: the flag is accepted, but the ATOM ModelRunner path that implements capture never runs, so it silently becomes a no-op. Mono is already refused in atom/mono/runtime/deployment.py:120.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done. All three sites now call one helper, check_return_routed_experts_config(config), which reads the effective topology (including the combined RapidServe / disagg_is_decode / runner-class check) in one place. The RapidServe runner only adds rapidserve_runner=True. The scheduler and runner checks remain because they see the config again in their own processes. Plugin mode is now refused, and since the plugin builders never carried the flag, the vLLM and SGLang config translations now forward the host's enable_return_routed_experts so Config sees it and refuses, instead of the host flag silently doing nothing. Tests cover every RapidServe signal and the plugin refusal.

self.decode_context_parallel_size,
self.prefill_context_parallel_size,
self.pipeline_parallel_size,
kv_transfer_config=self.kv_transfer_config,
enable_rapidserve=(
self.enable_rapidserve
or self.disagg_is_decode
or "RapidServeModelRunner" in self.runner_qualname
),
enable_dp_attention=self.enable_dp_attention,
)

if self.speculative_config is not None:
num_spec = self.speculative_config.num_speculative_tokens
is_dspark = getattr(self.speculative_config, "use_dspark", lambda: False)()
Expand Down
10 changes: 10 additions & 0 deletions atom/model_engine/arg_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class EngineArgs:
data_parallel_base_port: int | None = None
enforce_eager: bool = False
enable_prefix_caching: bool = True
enable_return_routed_experts: bool = False
port: int = 8006
kv_cache_dtype: str = "bf16"
index_cache_dtype: str | None = None
Expand Down Expand Up @@ -207,6 +208,15 @@ def add_cli_args(parser: argparse.ArgumentParser) -> argparse.ArgumentParser:
help="Enable prefix caching (default: enabled). "
"Use --no-enable_prefix_caching to disable.",
)
parser.add_argument(
"--enable-return-routed-experts",
dest="enable_return_routed_experts",
action="store_true",
help="Return per-request MoE expert ids on generate() as "
"routed_experts int16 [seq_len-1, num_layers, top_k]. "
"Requires DCP=PCP=PP=1, no DP-attention, and no KV transfer, "
"offload, or RapidServe.",
)
parser.add_argument(
"--port",
type=int,
Expand Down
2 changes: 2 additions & 0 deletions atom/model_engine/llm_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -916,6 +916,8 @@ def postprocess(self, reqs: list[Sequence]):
"ttft": ttft, # Time to first token in seconds
"tpot": tpot, # Time per output token in seconds
}
if req.routed_experts is not None:
outputs[req.id]["routed_experts"] = req.routed_experts
return outputs

def has_pending_requests(self):
Expand Down
191 changes: 187 additions & 4 deletions atom/model_engine/model_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
Pool,
PoolPlan,
SubPoolSpec,
page_pool,
plan_pools,
)
from atom.model_ops.decode_input_ids import (
Expand Down Expand Up @@ -1446,6 +1447,18 @@ def _sub_pool_specs(self) -> list[SubPoolSpec]:
specs = list(self.attn_metadata_builder.sub_pool_specs())
if hasattr(self, "draft_kv_builder"):
specs += self.draft_kv_builder.sub_pool_specs()
geo = self._routed_experts_geometry()
if geo is not None:
from atom.model_ops.fused_moe.routed_experts_capturer import (
capture_bytes_per_kv_block,
)

num_layers, top_k = geo
specs.append(
page_pool(
capture_bytes_per_kv_block(self.block_size, num_layers, top_k)
)
Comment on lines +1792 to +1794

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The geometry concern is right, though DeepSeek-V4 can't hit it.

DeepSeek-V4 / V4.1: R3 refuses these builders before any surcharge is added. Both inherit CommonAttentionBuilder, where supplies_token_slots = False, so _routed_experts_geometry() raises at startup.

Where it does cause an issue: Kimi's MLA+GDN builders (kimi_linear, glm5_next_text). They supply token slots, use PAGE checkpoints, and don't override checkpoint_page_unit_bytes(). R3 refuses pipeline parallelism and RapidServe, which are the only cases where Kimi turns checkpoints off, so with R3 on, page_unit_bytes took the merged "kv" charge including the route rows. PageUnitGeometryMixin._page_unit_regions then failed the equality check. Qwen GDN was unaffected because its override already narrows the unit to its MHA regions.

Fix: I kept the route rows in "kv". They grow with the block count, so charging them per block is what keeps the capture buffer inside --gpu-memory-utilization. What changed is where the checkpoint unit comes from: the new checkpoint_page_unit_bytes(builder) in page_unit_checkpoint.py sums only the target builder's own PAGE specs, and a backend override can still narrow that further. Sizing still uses the merged charge.

Why not refuse R3 when state_transfer().copies is on: that would also rule out R3 on Qwen3-Next and Qwen3.5-MoE, which work.

)
return specs

def _state_pool_names(self) -> list[str]:
Expand Down Expand Up @@ -1594,6 +1607,16 @@ def get_num_blocks(self) -> dict[str, object]:

torch.set_default_device("cpu")

geo = self._routed_experts_geometry()
if geo is not None:
from atom.model_ops.fused_moe.routed_experts_capturer import (
capture_pad_row_bytes,
)

available_for_kv = max(
0, int(available_for_kv) - capture_pad_row_bytes(*geo)
)

specs = self._sub_pool_specs()

# Sub-pool sizing is pure arithmetic over the byte budget — see
Expand Down Expand Up @@ -1972,6 +1995,11 @@ def allocate_kv_cache(self, num_kvcache_blocks):
num_blocks=num_kvcache_blocks,
)

# Capture buffer is part of the PAGE budget (per-slot int32 routes).
# Allocate it before the expected-vs-actual check so the measurement
# includes those bytes rather than warning on a false mismatch.
self._maybe_init_routed_experts_capturer(num_kvcache_blocks)
Comment thread
sudhu2k marked this conversation as resolved.
Resolved
Comment thread
Copilot marked this conversation as resolved.
Resolved

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.

[A4] Capture only where the result is used. Every TP rank allocates the [num_slots+1, layers, top_k] int32 capture buffer and does an index_put per MoE layer, but _store_routed_experts_step returns immediately when rank != 0, so 7/8 of that work at TP8 is discarded (~61×8×4 B per token for DeepSeek-class models, ~5% of FP8 MLA KV) — part of the +8.6% decode span the PR reports. Install the capturer only on rank 0 (or let select_experts write directly into the capture buffer, removing the per-layer scatter). Also, the buffer is int32 but exported as int16; storing int16 halves its memory.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Capturing only on rank 0 wouldn't gain anything. Every TP rank builds its KV cache with rank 0's block count, so the memory saved on the other ranks couldn't become extra KV. The ranks also sync at every all-reduce and rank 0 still does the scatter, so the step wouldn't get shorter. The overhead numbers in the PR were measured at TP1 anyway. Writing directly from select_experts would need an AITER kernel change. int16 would work and halve the buffer, but it shifts the KV memory budget, so I'd rather do it as a separate follow-up.


# Cross-validate: compare estimated vs actual KV cache allocation.
# `actual_kv_bytes` includes BOTH the unified pool tensors (counted by
# `block_bytes × num_blocks`) AND the per-request cache tensors (state
Expand Down Expand Up @@ -2012,6 +2040,122 @@ def allocate_kv_cache(self, num_kvcache_blocks):
torch.distributed.barrier()
return True

def _routed_experts_geometry(self) -> tuple[int, int] | None:
"""``(num_layers, top_k)`` for the capture buffer, or None if unused.

Assigns ``moe_capture_layer_id`` on each FusedMoE. Cached so pool
planning and capturer init share one walk.
"""
if hasattr(self, "_routed_experts_geo"):
return self._routed_experts_geo
if not getattr(self.config, "enable_return_routed_experts", False):
self._routed_experts_geo = None
return None
if not hasattr(self, "model"):
return None
import re

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

function-local import.. move re to module level

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed.


from atom.model_ops.fused_moe.routed_experts_capturer import fused_moe_modules

moes: list = fused_moe_modules(self.model)
if not moes:
raise ValueError(
"enable_return_routed_experts requires at least one FusedMoE "
"module; this model has none (dense checkpoints cannot "

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The current code already handles this comment.

"return routed_experts)"
)
layer_ids: list[int] = []
for ordinal, moe in enumerate(moes):
prefix = getattr(moe, "prefix", "") or ""
match = re.search(r"layers\.(\d+)", prefix)

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.

[A9] Reuse the existing layer-index helper. _routed_experts_geometry derives the layer number with its own regex layers\.(\d+) → layer_id → ordinal fallback (and imports re for it), and is_fused_moe_module hand-unrolls LazyMoEWrapper.__bases__. The repo already has extract_layer_index (atom/models/utils.py:409), and FusedMoE already receives layer_id / prefix at construction; the three-level fallback silently absorbs naming inconsistencies. Please reuse the helper or fix the layer index when FusedMoE is built.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done for the layer index. _routed_experts_geometry now uses extract_layer_index(moe.prefix), with no regex and no layer_id or ordinal fallback.

if match:
idx = int(match.group(1))
elif getattr(moe, "layer_id", None) is not None:
idx = int(moe.layer_id)
else:
idx = ordinal
moe.moe_capture_layer_id = idx
layer_ids.append(idx)
self._routed_experts_geo = (

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.

The layer axis is max(MoE layer index) + 1, not the model's layer count. For checkpoints with dense layers (mlp_only_layers / decoder_sparse_step > 1, both supported in atom/models/qwen3_moe.py:309), trailing dense layers make this dimension shorter than num_hidden_layers, so the exported shape no longer matches the documented [seq_len-1, num_layers, top_k] contract and downstream consumers will misindex layers. Dense layers in the middle read back as -1. Two MoE modules resolving to the same id would also silently overwrite each other. Please derive the axis from the model config, and assert that the resolved ids are unique.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Agreed. The layer axis is now hf_text_config.num_hidden_layers, so the export is [seq_len-1, num_hidden_layers, top_k] regardless of mlp_only_layers or decoder_sparse_step. Dense layers read -1. Resolved ids must be unique and within [0, num_hidden_layers), or startup raises.

max(layer_ids) + 1,
max(int(m.top_k) for m in moes),
)
Comment on lines +2531 to +2534

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed this.

return self._routed_experts_geo

def _maybe_init_routed_experts_capturer(self, num_kvcache_blocks: int) -> None:
geo = self._routed_experts_geometry()
if geo is None:
return
from atom.model_ops.fused_moe.routed_experts_capturer import (
RoutedExpertsCapturer,
)

num_layers, top_k = geo
num_slots = int(num_kvcache_blocks) * int(self.block_size)
RoutedExpertsCapturer.init(
num_slots=num_slots,
num_layers=num_layers,
top_k=top_k,
device=self.device,
)

def _store_routed_experts_step(
self,
batch: ScheduledBatch,
*,
stream: torch.cuda.Stream | None = None,
wait_event: torch.cuda.Event | None = None,
slot_mapping: torch.Tensor | None = None,
) -> bool:
if not getattr(self.config, "enable_return_routed_experts", False):
return False
if getattr(batch, "is_dummy_run", False) or self.rank != 0:
return False
from atom.model_ops.fused_moe.routed_experts_capturer import (
RoutedExpertsCapturer,
)

capturer = RoutedExpertsCapturer.get()
if capturer is None:
return False
return capturer.store_step(slot_mapping, stream=stream, wait_event=wait_event)

def _commit_routed_experts(self, *, keep_last: bool = False) -> None:
from atom.model_ops.fused_moe.routed_experts_capturer import (
RoutedExpertsCapturer,
)

capturer = RoutedExpertsCapturer.get()
if capturer is not None:
capturer.commit_pending(keep_last=keep_last)

def _attach_routed_experts(
self, batch: ScheduledBatch, output: ScheduledBatchOutput
) -> None:
"""CPU gather in this runner process. The capturer is not visible to
the EngineCore scheduler (runner is an ``AsyncIOProcManager`` child).
"""
if not getattr(self.config, "enable_return_routed_experts", False):
return
if getattr(batch, "is_dummy_run", False) or self.rank != 0:

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.

[5] (also A4) Only rank 0 reads, but every rank allocates the buffer and scatters every MoE layer. Keep the PAGE charge on all ranks so block counts match, and create the capturer on rank 0 only; the hook is already a no-op without one.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done. The capturer is now created on TP rank 0 only, at startup and on wake, so the other ranks' MoE hook does nothing and they skip the buffer and the per-layer scatter. Every rank still charges the capture rows to its KV pool, so block counts match across ranks. store_step's separate rank check is gone, since "no capturer" already covers it. Added test_only_tp_rank0_allocates_the_capture_buffer.

return
from atom.model_ops.fused_moe.routed_experts_capturer import (
RoutedExpertsCapturer,
)

capturer = RoutedExpertsCapturer.get()
if capturer is None:
return
block_tables = getattr(batch, "block_tables", None)
if not block_tables or len(block_tables) != len(batch.req_ids):
return
output.routed_experts = capturer.export_batch(
batch.req_ids,
block_tables,
batch.context_lens,
self.block_size,
)

def get_dp_padding(self, num_tokens: int) -> tuple[int, torch.Tensor | None]:
dp_size = self.config.parallel_config.data_parallel_size
dp_rank = self.config.parallel_config.data_parallel_rank
Expand Down Expand Up @@ -3080,11 +3224,21 @@ def postprocess(
sampled_logprobs = get_tp_group().broadcast(sampled_logprobs, src=0)

self.forward_done_event.record()
# Capture before prepare_sampled_ids(), which advances self.prev_batch to current batch.
deferred = self.tokenID_processor.is_deferred_out
# Capture before prepare_sampled_ids(), which advances self.prev_batch.
prev_batch = self.tokenID_processor.prev_batch
from atom.model_ops.fused_moe.routed_experts_capturer import (
_current_slot_mapping,
)

slots = _current_slot_mapping()
token_id_dict, logprobs_map = self.tokenID_processor.prepare_sampled_ids(
batch, sampled_tokens, self.forward_done_event, sampled_logprobs
)
# Blocking D2H after recv: previous copy_done already waited, so this
# memcpy does not absorb the current decode. cpu_buffer is complete
# before the next step attaches this batch as prev_batch.
self._store_routed_experts_step(batch, slot_mapping=slots)
# Extract req_ids and token_ids from dict (key -1 is the is_deferred_out flag)
req_ids_out = [k for k in token_id_dict if k != -1]
token_ids_out = [token_id_dict[k] for k in req_ids_out]
Expand Down Expand Up @@ -3151,7 +3305,7 @@ def postprocess(
if verify_scheduler is not None:
dspark_ell = verify_scheduler.ell_nonblocking()

return ScheduledBatchOutput(
output = ScheduledBatchOutput(
req_ids=req_ids_out,
token_ids=token_ids_out,
draft_token_ids=draft_token_ids,
Expand All @@ -3161,6 +3315,10 @@ def postprocess(
logprobs=logprobs_map,
dspark_ell=dspark_ell,
)
attach_batch = prev_batch if deferred and prev_batch is not None else batch
if not (deferred and prev_batch is None):
self._attach_routed_experts(attach_batch, output)
return output

def _record_kv_cache_ready(self, batch: ScheduledBatch) -> None:
"""Publish a GPU event for final prefill chunks to transfer connectors."""
Expand Down Expand Up @@ -3250,17 +3408,21 @@ def forward(self, batch: ScheduledBatch) -> ScheduledBatchOutput:
None, # nothing verified -> segment's last row
align_only=True,
)
self.forward_done_event.record()

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.

[A3] Don't block on the chunked-prefill middle-chunk path. Here _store_routed_experts_step(batch) runs without a stream, i.e. a blocking rows.cpu(), so with R3 on every middle chunk of a long prompt stalls the CPU until the GPU finishes it — chunked prefill degrades to serial. [A2] Also remove this self.forward_done_event.record(): it runs even with R3 off, and nothing on this path waits on the event (it's only consumed in postprocess at ~L3594/3615/3632).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks, both are valid.

A2: Removed. That record() was left over from an earlier version and nothing on this path reads it.

A3: Fixed. Middle chunks now store their routes on the async copy stream, so the CPU no longer waits for the chunk's forward. The routes are handed to the scheduler with the next output, merged in step order with that output's own routes. The scheduler already handles a one-step delay like this on the deferred path. A small correction on the impact: GPU work wasn't serialized, but every middle chunk did lose the CPU/GPU overlap, so the GPU sat idle while the next chunk was being scheduled. I added tests for the ordering in both deferred and non-deferred modes.

# No token copy_done on this path; blocking store is fine.
self._store_routed_experts_step(batch)
Comment thread
sudhu2k marked this conversation as resolved.
Resolved
reset_forward_context()
# Mark this slot's GPU work (attention consumed its metadata) done.
self._record_forward_vars_event()
self._record_kv_cache_ready(batch)
return ScheduledBatchOutput(
output = ScheduledBatchOutput(
req_ids=list(batch.req_ids),
token_ids=[],
num_rejected=None,
num_bonus=None,
draft_token_ids=None,
)
return output

fwd_output = self.postprocess(
batch,
Expand Down Expand Up @@ -4213,9 +4375,29 @@ def _kv_budget_extra_reserve(self, total_bytes: int) -> int:
safety_margin = int(total_bytes * 0.02)
return 4 * safety_margin

def _refuse_routed_experts_capture(self) -> None:
if not getattr(self.config, "enable_return_routed_experts", False):
return
from atom.model_ops.fused_moe.routed_experts_capturer import (
check_return_routed_experts,
)

check_return_routed_experts(
getattr(self.config, "decode_context_parallel_size", 1),
getattr(self.config, "prefill_context_parallel_size", 1),
getattr(self.config, "pipeline_parallel_size", 1),
kv_transfer_config=getattr(self.config, "kv_transfer_config", None),
enable_rapidserve=True,
enable_dp_attention=bool(
getattr(self.config, "enable_dp_attention", False)
),
)

def get_num_blocks(self) -> dict[str, object]:
# Decode in disagg mode owns no GPU memory — kvcache is imported from
# prefill.
# prefill. That also skips `_maybe_init_routed_experts_capturer`, so
# capture cannot be served from this process.
self._refuse_routed_experts_capture()
if self.config.disagg_is_decode:
transfer = self.attn_metadata_builder.state_transfer()
if transfer.copies:
Expand All @@ -4231,6 +4413,7 @@ def get_num_blocks(self) -> dict[str, object]:

def allocate_kv_cache(self, num_kvcache_blocks):
# Decode in disagg mode: kvcache is imported from prefill, not allocated.
self._refuse_routed_experts_capture()
if self.config.disagg_is_decode:
logger.info("decode skipping kv cache allocation")
return True
Expand Down
9 changes: 5 additions & 4 deletions atom/model_engine/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,17 @@
# Copyright (C) 2024-2025, Advanced Micro Devices, Inc. All rights reserved.

from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from typing import Any

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.

[A2] Please revert unrelated formatting changes: List / Optional / Dict → builtin generics here, the extra local variable split out of _mark_finished(seq, 'aborted', ...) in scheduler.py, and the rewritten "Capture before prepare_sampled_ids()" comment in postprocess. They enlarge the diff and muddy blame without changing behaviour.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Reverted the extra local in the aborted _mark_finished call and restored the original "Capture before prepare_sampled_ids()" comment. The typing change inrequest.pyhas to stay. Our ruff config enables the pyupgrade rules (UP006/UP035/UP045), and CI's reviewdog reports issues near changed lines. Adding the routed_experts field to RequestOutput touches that import and dataclass, so with List/Dict/Optional left in, the ruff check fails on exactly those lines. I kept that change to the lines the new field touches.



@dataclass
class RequestOutput:
"""Output structure passed to stream callback."""

request_id: int
output_tokens: List[int]
output_tokens: list[int]
finished: bool
finish_reason: Optional[str] = None
kv_transfer_params_output: Optional[Dict[str, Any]] = None
finish_reason: str | None = None
kv_transfer_params_output: dict[str, Any] | None = None
num_cached_tokens: int = 0
routed_experts: Any | None = None

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.

Any hides the contract that downstream code depends on: np.ndarray of shape [seq_len-1, num_layers, top_k], dtype int16, only present on the final output. It is also only surfaced through the generate() dict (atom/model_engine/llm_engine.py:972) and not through the OpenAI HTTP response, which is fine but worth stating explicitly. Please tighten the type to np.ndarray | None and document both the shape and the "final output only" rule.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done: routed_experts is now np.ndarray | None, and the field documents the contract. It's read-only int16 [seq_len - 1, num_hidden_layers, top_k] with -1 on dense layers. It's set only on the final output (None on streamed ones) and only exposed through generate(), not the OpenAI HTTP response.

Loading