Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
6096a31
[rollout] FP8 online weight updates, RMSNorm opt-in, and graph-safe s…
ZhangDanyang-AMD Sep 12, 2026
988d52b
[test] Cover the fan-out that add_request already performs
xysheng-AMD Sep 9, 2026
6a34d27
[rollout] Decide a weight's shuffle layout once, for load and for sync
xysheng-AMD Sep 9, 2026
2ba1602
[rollout] Route routed-expert weights through the sync, unquantized only
xysheng-AMD Sep 9, 2026
cf2aca3
[rollout] Accept a trainer's fused expert tensors by their own names
xysheng-AMD Sep 9, 2026
dce5265
[rollout] Take true_vocab_size from the config, not a downstream env var
xysheng-AMD Sep 9, 2026
afb2cab
[model_ops] Call AITER's RMSNorm and moe_shuffle_scale directly
xysheng-AMD Sep 9, 2026
3380b06
[rollout] Drop the CUDA graphs when the KV pool is released
xysheng-AMD Sep 9, 2026
bf835be
[rollout] Make the sleep that keeps memory resident an opt-in
xysheng-AMD Sep 9, 2026
31bf697
[scheduler] Strip the deferred-output placeholder when preempting, no…
xysheng-AMD Sep 12, 2026
169d372
[scheduler] End a chunked prefill at the admitted length, not the pro…
xysheng-AMD Sep 12, 2026
44054b3
[rollout] Wait for a weight's readers before the update overwrites it
xysheng-AMD Sep 14, 2026
f65e56f
[scheduler] Strip every placeholder preempt() left, not mtp_k of them
xysheng-AMD Sep 15, 2026
f8c1503
[test] Cover the chunked-prefill finality the scheduler freezes
xysheng-AMD Sep 15, 2026
574483a
[scheduler] Size an offload resume by what it owes, not by its prompt
xysheng-AMD Sep 15, 2026
cc6a131
[rollout] Put the wait with the write, on every path that writes a we…
xysheng-AMD Sep 15, 2026
9dff525
[rollout] Convert a weight to e4m3fnuz once, not once per sync
xysheng-AMD Sep 15, 2026
4c37ef0
[rollout] Keep only the unshuffled entries when a relayout fails part…
xysheng-AMD Sep 15, 2026
f9a9eb6
[rollout] Release the TBO graph store, and name a recapture fault early
xysheng-AMD Sep 15, 2026
ec671ea
[model_ops] Compare QuantType by value, as every post-load path does
xysheng-AMD Sep 15, 2026
948e863
[model_ops] Fall back to the rebind where copy_ has no kernel for the…
xysheng-AMD Sep 15, 2026
839d058
[rollout] Release the graphs a PIECEWISE capture made, and the drafter's
xysheng-AMD Sep 16, 2026
65b4bae
[rollout] Drop a half-delivered expert instead of failing every later…
xysheng-AMD Sep 16, 2026
deb4f05
[rollout] Resolve the trainer's names against the model, not its wrap…
xysheng-AMD Sep 16, 2026
489c6da
[model_ops] Decide a weight's layout by value on both halves of the d…
xysheng-AMD Sep 16, 2026
ecefb5e
[scheduler] Record the whole placeholder run, not just the part appended
xysheng-AMD Sep 16, 2026
26cca88
[engine] State what the n > 1 fan-out does to generate()'s flat list
xysheng-AMD Sep 16, 2026
03a8e5d
[model_ops] Say which consumers alias a weight that is now written in…
xysheng-AMD Sep 16, 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
19 changes: 19 additions & 0 deletions atom/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1581,6 +1581,25 @@ class Config:
pipeline_parallel_size: int = 1
prefill_context_parallel_size: int = 1
enforce_eager: bool = False
# Number of vocabulary positions that carry a real token. A checkpoint
# whose embedding matrix is padded up to a friendlier width -- Qwen3 rounds
# 151665 up to 151936 -- leaves the tail rows holding whatever the padding
# was initialised to, which is neither zero nor -inf, so sampling can land
# on an id the tokenizer cannot decode. Take it from the tokenizer, not
# from the model card. 0 means "not padded", which is the right answer for
# every model whose embedding matrix matches its tokenizer.
true_vocab_size: int = 0
# Sleep (`release_memory`) normally frees the weights and the KV pool and
Comment thread
ZhangDanyang-AMD marked this conversation as resolved.
Resolved
# recaptures the decode CUDA graphs on wake. Set this to keep both
# allocated instead, so the addresses the graphs captured stay valid and
# nothing is recaptured -- recapture is what faults under
# PYTORCH_CUDA_ALLOC_CONF=expandable_segments.
#
# The cost is that sleep no longer returns that memory to the allocator, so
# a colocated trainer that sleeps the rollout engine to get its memory back
# will not get it back. That is why it is opt-in. No effect under
# `enforce_eager`, where there are no graphs to keep valid.
sleep_keeps_memory_resident: bool = False
hf_config: PretrainedConfig = field(init=False)
generation_config: GenerationConfig = field(init=False)
parallel_config: ParallelConfig = field(default_factory=ParallelConfig)
Expand Down
30 changes: 27 additions & 3 deletions atom/model_engine/llm_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,22 @@ def add_request(
multimodal_data_list: list[dict] | None = None,
request_ids: list[str] | None = None,
):
"""Submit one batch of prompts.

``SamplingParams.n > 1`` fans out: a prompt becomes ``n`` sibling
sequences, and the batch handed to the scheduler is prompt-major --
prompt 0's siblings in order, then prompt 1's. Sequence ids are assigned
in that same order, which is what makes :meth:`generate`'s flat list
line up with a prompt list expanded by ``n``. It is one request per
sibling from here on; nothing downstream reassembles them.

This used to reach ``io_processor.preprocess``, which returns a single
sequence and refuses ``n > 1`` outright, so offline ``n > 1`` raised
before a token was generated. That guard is still there and still right
for a caller that expects one sequence back -- the two entry points
differ in whether the caller is prepared for siblings, not in what they
support.
"""
# if sampling params is not list, use it for all prompts
if not isinstance(sampling_params_list, list):
sampling_params_iter = itertools.repeat(sampling_params_list)
Expand Down Expand Up @@ -251,14 +267,14 @@ def add_request(
mm_data_iter,
request_id_iter,
):
req = self.io_processor.preprocess(
fanout = self.io_processor.preprocess_fanout(
prompt,
sampling_param,
stream_callback=callback,
multimodal_data=mm_data,
request_id=request_id,
parent_request_id=request_id,
)
reqs.append(req)
reqs.extend(fanout)
self.core_mgr.add_request(reqs)

def step(self) -> list[Sequence]:
Expand All @@ -274,6 +290,14 @@ def generate(
sampling_params: SamplingParams | list[SamplingParams],
request_ids: list[str] | None = None,
) -> list[str]:
"""Complete every prompt and return the outputs as ONE flat list.

Not one entry per prompt: ``SamplingParams.n`` of them, prompt-major
(see :meth:`add_request`), because the sort below is over sequence ids
and those are assigned in fan-out order. A caller pairing prompts with
outputs has to expand its own list by ``n`` to do it -- with ``n = 1``,
the usual case, that is the identity and the list is 1:1 as before.
"""
# Reset DP routing state (round-robin cursor + in-flight load) so a
# fresh batch gets deterministic DP assignment and no leaked counts.
self.core_mgr.reset_dp_router()
Expand Down
166 changes: 140 additions & 26 deletions atom/model_engine/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -1303,7 +1303,37 @@ def _schedule(self) -> tuple[ScheduledBatch, dict[int, Sequence]] | None:
if offload_resume:
# Blocks already held from the pre-park allocate; only re-check
# the batch budget. No re-match / re-allocate / re-park.
num_new_tokens = seq.num_prompt_tokens - seq.num_cached_tokens
#
# `num_tokens`, like Phase 1 above and the non-offload branch
# below. A seq re-admitted after `preempt` owes KV for the
# tokens it had already generated, and the offload load can
# come back covering some of them -- `_mark_offload_load_ready`
# sets `num_cached_tokens` to whatever the tier returned, which
# is not bounded by the prompt. Sized against
# `num_prompt_tokens` that is a non-positive width:
# `_prefill_chunk_for_budget` answers None, the seq goes back
# to the head of `waiting`, and the loop breaks -- every tick,
# forever, starving everything queued behind it. With chunked
# prefill off it is `_assert_positive_prefill_chunk` that fires
# instead, in the engine loop.
tier_hit = seq.num_cached_tokens
num_new_tokens = seq.num_tokens - tier_hit
if num_new_tokens <= 0:
# The tier can cover the request whole: its lookup runs
# over the entire prompt and is not bounded below it, where
# `can_allocate`'s HBM match stops one hash block short so
# that a prefill always has something to forward. A wholly
# resident request reaches here with nothing to size, which
# is the same non-positive width by another route.
#
# Hand back the trailing block, the one the HBM match would
# never have offered. Its KV is present, so recomputing it
# costs one block of forward, and the forward has to happen
# regardless: the first decode samples from the logits this
# prefill produces, and there is nowhere else to get them.
hbs = self.block_manager.hash_block_size
seq.num_cached_tokens = max(0, (seq.num_tokens - 1) // hbs * hbs)
num_new_tokens = seq.num_tokens - seq.num_cached_tokens
budget_remaining = self.max_num_batched_tokens - num_batched_tokens
chunk = self._prefill_chunk_for_budget(
num_new_tokens, budget_remaining, num_batched_tokens
Expand All @@ -1321,7 +1351,12 @@ def _schedule(self) -> tuple[ScheduledBatch, dict[int, Sequence]] | None:
# is what reaches the API as `cached_tokens` -- the tier would
# look like it returned nothing. Unconditional, because a seq
# re-admitted after `preempt()` keeps the field.
seq.prefix_cache_hit_tokens = seq.num_cached_tokens
#
# The tier's own figure, not the possibly-rolled-back one: what
# it returned is what the API should report, and giving the
# trailing block back to the forward is this scheduler's
# decision rather than a smaller hit.
seq.prefix_cache_hit_tokens = tier_hit
num_seqs_prefill, num_batched_tokens = self._schedule_prefill_seq(
seq,
chunk,
Expand Down Expand Up @@ -1581,16 +1616,24 @@ def _schedule(self) -> tuple[ScheduledBatch, dict[int, Sequence]] | None:
self._publish_state_stores()
connector_meta_output = self.kv_connector.build_connector_meta()

# Freeze, per seq, whether this chunk finishes the prompt. Uses the
# pre-advance offsets so it is correct whether or not schedule-time
# advancement runs below.
# Freeze, per seq, whether this chunk is the last one this prefill
# owes. Uses the pre-advance offsets so it is correct whether or not
# schedule-time advancement runs below.
#
# Measured against `num_tokens`, for the same reason
# `next_token_ids` below is: a seq re-admitted after `preempt`
# re-forwards the tokens it had already generated, so its prefill
# runs past the prompt boundary. Ending it at `num_prompt_tokens`
# would call a middle chunk final and leave the rest of the context
# with no KV -- the sequence would then decode against blocks still
# holding the text of whichever request owned them last, and carry
# on writing that request's answer. Equal to `num_prompt_tokens` on
# a first admission, which is every seq that was never preempted.
is_final_chunk = [
(num_cached_tokens_list[i] + int(num_scheduled_tokens[i]))
>= seq.num_prompt_tokens
>= seq.num_tokens
for i, seq in enumerate(scheduled_seqs.values())
]
# Bound on num_tokens (not num_prompt_tokens): preempted seqs
# re-forward generated tokens past the prompt boundary.
next_token_ids = None
if self.drafter_needs_next_token:
next_token_ids = []
Expand Down Expand Up @@ -1979,6 +2022,14 @@ def _schedule_first_decode_after_remote_kv(self, seq: Sequence) -> None:
for d in drafts:
seq.append_token(int(d))
seq.spec_token_ids = np.asarray(drafts, dtype=np.int32)
# These trailing slots are the remote's drafts, awaiting
# verification by the first local decode -- the same contract
# as postprocess's placeholders, so record the same width.
# `preempt()` reads it to decide what to strip, and the remote
# may have sent fewer than `mtp_k` (or none at all), so the
# count has to come from what was actually appended. T0 is
# excluded: it is a real generated token.
seq.num_placeholder_tokens = len(drafts)
logger.debug(
"[PD-TRANSITION] seq %s: num_tokens=%d, "
"num_prompt=%d, blocks=%d, first_token=%s, "
Expand Down Expand Up @@ -2373,15 +2424,49 @@ def preempt(self, seq: Sequence) -> bool:
return False
self.total_preemptions += 1
seq.status = SequenceStatus.WAITING
# Strip placeholder + rejected draft tokens added by postprocess.
# Real token count = seq.num_tokens - mtp_k - num_rejected
# (same formula as postprocess line: num_tokens = seq.num_tokens - self.mtp_k - num_rejected)
if self.spec_decode_local and self.mtp_k > 0:
strip = self.mtp_k + seq.num_rejected
if strip > 0:
del seq.token_ids[-strip:]
del seq.output_tokens[-strip:]
seq.num_tokens -= strip
# Strip the placeholder tokens postprocess appended, so the recompute
# restarts from real context only.
#
# Deferred output appends its placeholder on every decode step, not
# only under speculation: `is_deferred_out` is `pipeline_parallel_size
# == 1`, so a plain TP-only engine takes that path for every running
# sequence. The placeholder is `eos_token_id`, and postprocess
# overwrites it in place one step later -- a step this sequence will
# never reach, because it is being preempted now. Left in place it
# stops being a placeholder: the recompute prefills a context ending in
# `<|endoftext|>` and the model duly starts a new document, and the
# same token is handed back to the caller as generated output. With
# `ignore_eos=False` the request just stops there, which reads as a
# coherent answer that ends before it answers anything.
#
# `num_placeholder_tokens` is the only width that describes what is
# actually there: postprocess appends `mtp_k + is_deferred_out -
# num_rejected` slots and records exactly that count. Re-deriving it
# here as `mtp_k + num_rejected` agrees only when deferred output is
# off AND nothing was rejected -- on a TP-only MTP engine it leaves one
# `eos_token_id` behind (the case above), and with `num_rejected = r`
# it deletes `2r - 1` real tokens and their logprobs instead.
strip = seq.num_placeholder_tokens
# A truncating stop can move `num_tokens` back over the placeholders
# (postprocess only rewrites the count, not the arrays), so bound the
# deletion by what is present and by the prompt, which is never a
# placeholder. Without this, `del token_ids[-strip:]` on an oversized
# `strip` clears the whole list and drives `num_completion_tokens`
# negative.
strip = min(
strip,
len(seq.output_tokens),
max(0, seq.num_tokens - seq.num_prompt_tokens),
)
if strip > 0:
del seq.token_ids[-strip:]
del seq.output_tokens[-strip:]
seq.num_tokens -= strip
# Each placeholder pushed a 0.0 alongside it (postprocess patches
# that in place too), so they go together or the two lists stop
# describing the same tokens.
if seq.return_logprobs and len(seq.logprobs) >= strip:
del seq.logprobs[-strip:]
Comment thread
ZhangDanyang-AMD marked this conversation as resolved.
Resolved
seq.num_rejected = 0
seq.num_bonus_tokens = 0
seq.num_placeholder_tokens = 0
Expand Down Expand Up @@ -2551,13 +2636,19 @@ def postprocess(
# multiple steps (hash_blocks clips to fully-filled blocks).
self.block_manager.hash_blocks(seq, chunk)
seq.num_cached_tokens += chunk
# Prefill is partial until the whole PROMPT's KV is computed.
# Compare against num_prompt_tokens, not num_tokens: once a
# completion token is appended (this step's sampled token, or an
# externally-appended EOS), num_tokens > num_prompt_tokens and
# comparing against it would wrongly keep a finished prefill
# flagged partial — which makes the EOS/finish loop below skip it.
now_partial = seq.num_cached_tokens < seq.num_prompt_tokens
# Ask the scheduler what it decided rather than re-deriving it
# from lengths here. It froze `is_final_chunk` at pre-advance
# offsets, against the whole admitted length -- which for a
# preempted seq runs past the prompt. By the time this runs
# `num_tokens` may already have grown by this step's sampled
# token, so no comparison against it is safe either. The length
# fallback is for callers that hand a batch without the field;
# the scheduler always sets it.
final = getattr(batch, "is_final_chunk", None)
if final is not None:
now_partial = not final[i]
else:
now_partial = seq.num_cached_tokens < seq.num_prompt_tokens
if now_partial != seq.is_partial_prefill:
self._partial_prefill_count += 1 if now_partial else -1
seq.is_partial_prefill = now_partial
Expand Down Expand Up @@ -2688,6 +2779,21 @@ def postprocess(
for i, el in enumerate(token_ids):
seq.token_ids[-required_placeholders + i] = el
seq.output_tokens[-required_placeholders + i] = el
# What the write did NOT consume of the window it was given.
# Those slots are still `eos_token_id`, so they are still
# placeholders, and on a verify step there are always some: the
# window is `mtp_k` wider than the run appended for it (the
# `offset` above) and the step hands back `mtp_k - num_rejected
# + 1` tokens for it. Recorded rather than left to `preempt` to
# re-derive -- `num_placeholder_tokens` is the width it reads,
# and until this line it named only the run appended below.
# Measured steady state, deferred output and drafts every step:
# the trailing `eos` run is `2 * mtp_k + 1` while the recorded
# width was `mtp_k + 1 - num_rejected`, so a preemption left
# `mtp_k + num_rejected` of them in the recomputed context.
seq.num_placeholder_tokens = max(
0, required_placeholders - len(token_ids)
)
if seq.return_logprobs and token_logprob is not None:
if seq.logprobs:
seq.logprobs[-1] = token_logprob
Expand Down Expand Up @@ -2893,12 +2999,20 @@ def postprocess(
# placeholder for the each decode step
for seq in seqs:
if seq.status == SequenceStatus.RUNNING and not seq.is_partial_prefill:
num = num_placeholder - seq.num_rejected
# Clamped because `preempt()` strips exactly this many:
# `range()` on a negative width appends nothing, so storing
# one unclamped would claim placeholders that are not there.
num = max(0, num_placeholder - seq.num_rejected)
for _ in range(num):
seq.append_token(self.eos_token_id)
if seq.return_logprobs:
seq.logprobs.append(0.0)
seq.num_placeholder_tokens = num
# Added to what the overwrite above left unconsumed, not
# replacing it: both runs are trailing `eos_token_id` and
# both are what `preempt` has to strip. `=` also undercounted
# a sequence that went two steps without a row -- nothing
# consumed the first run, and this one appends beyond it.
seq.num_placeholder_tokens += num
for seq in finished_seqs:
logger.debug("Freeing blocks for finished seq %s", seq.id)
if seq.is_partial_prefill:
Expand Down
18 changes: 16 additions & 2 deletions atom/model_ops/layernorm.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,13 @@ def rmsnorm2d_fwd_(
) -> torch.Tensor:
ori_shape = x.shape
x = x.reshape(-1, dim)
return rmsnorm2d_fwd(x, weight, eps).view(ori_shape)
Comment thread
ZhangDanyang-AMD marked this conversation as resolved.
Resolved
out = rmsnorm2d_fwd(
x,
weight,
eps,
use_model_sensitive_rmsnorm=int(envs.ATOM_USE_MODEL_SENSITIVE_RMSNORM),
)
return out.view(ori_shape)


@torch_compile_guard()
Expand All @@ -71,7 +77,15 @@ def rmsnorm2d_fwd_with_add_(
x = x.reshape(-1, dim)
out = torch.empty_like(x)
residual_out = torch.empty_like(x)
rmsnorm2d_fwd_with_add(out, x, residual, residual_out, weight, eps)
rmsnorm2d_fwd_with_add(
out,
x,
residual,
residual_out,
weight,
eps,
use_model_sensitive_rmsnorm=int(envs.ATOM_USE_MODEL_SENSITIVE_RMSNORM),
)
return out.view(ori_shape), residual_out.view(ori_shape)


Expand Down
Loading
Loading