Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
3286270
check_stop can end a partition at the prefill (END_PARTITION)
dghosef Oct 28, 2026
d6d347f
TokenStepNode, Qwen3.5, Command-A+: a first-token stop ends the request
dghosef Oct 28, 2026
9ddb8c8
test: a first-token stop ends the request at the prefill, on real wei…
dghosef Oct 28, 2026
524069a
Conductor: a done partition is not advanced again
dghosef Oct 28, 2026
813d2b7
test: gate the GPU prefill-stop cases on the greedy first token
dghosef Oct 28, 2026
7311ca6
Worker: an END_PARTITION from inside a loop fails the request
dghosef Oct 28, 2026
96831ef
Rename the stream-only partition-done names
dghosef Oct 28, 2026
dd6996d
Round-2 review cleanups for the prefill stop
dghosef Oct 28, 2026
cfa6702
Merge remote-tracking branch 'origin/main' into fix/prefill-stop-v2
dghosef Oct 28, 2026
021b44d
GLM-5.2: a first-token stop ends the request at the prefill
dghosef Oct 28, 2026
68a5a2b
GLM-5.2 prefill stop: tests end the request as the runtime does
dghosef Oct 28, 2026
ba4d6c4
Merge remote-tracking branch 'origin/main' into fix/prefill-stop-v2
dghosef Oct 28, 2026
3ef63fb
TokenStepNode: a prompt walk ends the partition on a stop id only
dghosef Oct 28, 2026
4473363
BAGEL: a first-token stop ends a text reply at the prefill
dghosef Oct 28, 2026
e648337
Merge remote-tracking branch 'origin/main' into maint/794
dghosef Oct 29, 2026
a43c6c0
Merge fixups: main's partition-done test uses the renamed report flag…
dghosef Oct 29, 2026
1deee91
Engine.check_stop_for_batch docstring: its stops may hold END_PARTITION
dghosef Oct 29, 2026
65f86da
test_partition_done: the conductor stub carries main's producer_trigg…
dghosef Oct 29, 2026
de93a12
Merge origin/main 8a2095f81 into fix/prefill-stop
dghosef Oct 29, 2026
4cbc7d2
TokenStepNode ends the partition only on its PREFILL_WALK (R1.2)
dghosef Oct 29, 2026
9e0ee98
TokenStepNode's token budget counts the prefill's token (R1.9, R1.5)
dghosef Oct 29, 2026
79cc58d
TokenStepNode.check_stop_batched: a decode step makes one call a row …
dghosef Oct 29, 2026
239fe4c
Delete the unused GraphRuntime.partition_done pymethod (R1.4)
dghosef Oct 29, 2026
5d53e44
BAGEL: set the prefill-token step metadata from one helper, read it w…
dghosef Oct 29, 2026
05b60fd
test_dropped_row_stream_stop: don't pin error and log text (R1.10)
dghosef Oct 29, 2026
93b5322
adding_models: drop the roster of models that return END_PARTITION (R…
dghosef Oct 29, 2026
15ff6fd
TokenStepNode models: the decode loop cap leaves room for the prefill…
dghosef Oct 29, 2026
db03157
Qwen3.5: the token budget counts the last prefill's token (R2.2 / D2)
dghosef Oct 29, 2026
7f152ef
test_model_kv_layouts: Canary and Canary-Qwen cap decode where their …
dghosef Oct 29, 2026
dff0b50
TokenStepNode._stop_names: say the batched decode path doesn't call i…
dghosef Oct 29, 2026
456f287
Merge origin/main fadeb2759 into fix/prefill-stop
dghosef Oct 30, 2026
0ef91a0
Merge origin/main 065292e6c into fix/prefill-stop
dghosef Nov 1, 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
11 changes: 10 additions & 1 deletion docs/adding_models.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1144,7 +1144,16 @@ methods are:
``check_stop(...) -> set[str] | StopCheck`` (optional)
Runs off the GPU thread, and may read tensor values. Return the names of the ``Loop``
objects to stop, for example after seeing the EOS token. This is how a decode loop
terminates. To also keep some of this step's outputs from being routed, return
terminates. A prefill samples the first token before its decode loop exists, so a stop
there is returned as ``END_PARTITION`` (from ``mstar.model.submodule_base``) instead:
the worker reports the partition done with that pass, and the conductor ends it rather
than start decode. Return it only from a row outside any loop; from inside one the
request fails. ``TokenStepNode`` does this for its ``PREFILL_WALK``. It suits a partition
with nothing left to run once generation stops: a model that runs another walk after its
loop (an ``align`` walk, or image generation after thinking) or publishes final-only
state at the loop's stop needs its own transition instead. Keep ``check_stop_batched``,
if you write one, in agreement.
To also keep some of this step's outputs from being routed, return
``StopCheck(stops, withheld=(names...))``, for example a frame streamed alongside the
EOS token: its consumer then never receives it, instead of finding and dropping it with
a device read. ``check_stop_batched`` returns a ``BatchedStopCheck`` for the same.
Expand Down
54 changes: 28 additions & 26 deletions mstar/conductor/conductor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1793,6 +1793,11 @@ def _process_worker_graphs_done(
)
return []

# Any report of the pass may carry it, not only the one that completes
# the pass: that can be another worker's (an encoder's, say).
if body.partition_done:
pstate.worker_reported_done = True

# Persist signals: every rank contributes its shard (different uuid +
# source_tp_rank); accumulate across ranks, do not dedup.
if body.persist_signals:
Expand Down Expand Up @@ -1851,10 +1856,7 @@ def _process_worker_graphs_done(

return done_partitions

def _process_done_forward(
self, request_id: str, partition_name: str,
partition_done_from_worker: bool = False,
) -> bool:
def _process_done_forward(self, request_id: str, partition_name: str) -> bool:
"""Process a completed forward pass for a specific partition.

Calls get_partition_forward_pass_args for all partitions uniformly.
Expand All @@ -1865,10 +1867,16 @@ def _process_done_forward(
"""
request_data = self.requests[request_id]
pstate = request_data.partition_states[partition_name]
# A self-triggering partition's next pass can report in the same cycle,
# and is queued again before this pass's state is reset. When the
# earlier call ended the partition, on the flag the later report
# latched, the later call has nothing left to advance.
if pstate.is_done:
return all(ps.is_done for ps in request_data.partition_states.values())

incoming_connections = request_data.get_incoming_connections(partition_name)
stream_fed = partition_name in self._stream_fed_partitions
if stream_fed and partition_done_from_worker:
if stream_fed and pstate.worker_reported_done:
pstate.is_done = True

prev_walk = pstate.metadata.graph_walk
Expand All @@ -1881,17 +1889,15 @@ def _process_done_forward(
pstate.metadata = fwd_args.full_metadata
pstate.metadata.kwargs.update(fwd_args.step_metadata)

# The worker's signal is authoritative for a stream-terminated
# partition: the pass that consumed the stream's final chunk has run.
# Treat it as the partition's own `request_done` so its downstream
# connections get `producer_done` and the chain closes. A model that
# inferred the end from the connection counters instead
# (`consumed_count >= token_count`) could race: `consumed_count` counts
# chunks popped for execution, and every worker-graphs-done report from
# a worker carries every buffer's count, so a colocated consumer's report
# could finish this partition while its last step was still running,
# and that step's output arrived for a request already gone.
if stream_fed and partition_done_from_worker:
# The worker's signal is authoritative, whatever walk the model chose:
# the pass that consumed a stream's final chunk, or whose stop check
# returned END_PARTITION, has run. Treat it as the partition's own
# `request_done` so its downstream connections get `producer_done`. A
# stream-fed model that inferred the end from the connection counters
# instead (`consumed_count >= token_count`) could race: a colocated
# consumer's report carries every buffer's count, so it could finish
# this partition while its last step was still running.
if pstate.worker_reported_done:
fwd_args.request_done = True

# Check max output tokens for partitions that produce tokens
Expand Down Expand Up @@ -1943,6 +1949,7 @@ def _process_done_forward(
pstate.completed_worker_graph_ids = set()
pstate.current_worker_graph_ids = set()
pstate.wg_rank_completions = {}
pstate.worker_reported_done = False
pstate.fwd_pass_number += 1

self._set_partition_worker_graph_ids(
Expand Down Expand Up @@ -2084,7 +2091,7 @@ def _wait_for_workers_ready(self) -> None:
logger.info("Conductor: all %d worker(s) ready", len(self.worker_ids))

def _route_message(
self, message, done_partition_forwards: list[tuple[str, str, bool]],
self, message, done_partition_forwards: list[tuple[str, str]],
) -> None:
"""Apply one message from the api server or a worker. A WORKER_GRAPHS_DONE
that finishes a partition's pass is queued on ``done_partition_forwards``."""
Expand Down Expand Up @@ -2119,9 +2126,7 @@ def _route_message(
range_push("conductor._process_worker_graphs_done")
done_parts = self._process_worker_graphs_done(message.body)
for pname in done_parts:
done_partition_forwards.append(
(rid, pname, message.body.partition_done)
)
done_partition_forwards.append((rid, pname))
if self.enable_nvtx:
range_pop()
else:
Expand Down Expand Up @@ -2216,7 +2221,7 @@ def run(self):
range_push("conductor.run_loop")

try:
done_partition_forwards: list[tuple[str, str, bool]] = []
done_partition_forwards: list[tuple[str, str]] = []

startup_backlog = self._startup_message_backlog
self._startup_message_backlog = []
Expand All @@ -2233,14 +2238,11 @@ def run(self):

completed_requests = []

for request_id, partition_name, p_done in done_partition_forwards:
for request_id, partition_name in done_partition_forwards:
if request_id not in self.requests or request_id in self.draining:
continue # already completed by another partition in this cycle
try:
all_done = self._process_done_forward(
request_id, partition_name,
partition_done_from_worker=p_done,
)
all_done = self._process_done_forward(request_id, partition_name)
except Exception as exc:
self._fail_after_error(
request_id, f"advancing partition {partition_name}", exc,
Expand Down
3 changes: 3 additions & 0 deletions mstar/conductor/request_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ class PartitionState:
current_worker_graph_ids: set[int] = field(default_factory=set)
# wg_id -> count of distinct TP ranks that have reported completion
wg_rank_completions: dict[int, int] = field(default_factory=dict)
# a report since the last advance set `partition_done` (it can be the
# next pass's; see `Conductor._process_done_forward`)
worker_reported_done: bool = False
num_output_tokens: int = 0
# resource label -> PublishedInfo, accumulated across all TP ranks and
# handed back out on the next forward
Expand Down
11 changes: 6 additions & 5 deletions mstar/engine/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -310,9 +310,9 @@ class ExecutingBatch:
# rid -> this step's InputMetadata; rids without any are absent
per_request_input_metadata: Mapping[str, InputMetadata] = field(default_factory=dict)

# rids for which this step ends every finite stream into the partition;
# this step reports the partition done
stream_partition_done_rids: set[int] = field(default_factory=set)
# rids for which this step ends every finite stream into the partition, or
# whose stop check returned END_PARTITION; this step reports the partition done
partition_done_rids: set[int] = field(default_factory=set)

# Populated on batch preparation
inputs: list[NodeInputs] | None = None
Expand Down Expand Up @@ -435,7 +435,7 @@ def drop_rids(self, rids: set[int]):
# A row that does not run this step must not end its streams on it:
# the worker would stop its loop and finalize a row it no longer has.
self.final_stream_rids.difference_update(rids)
self.stream_partition_done_rids.difference_update(rids)
self.partition_done_rids.difference_update(rids)
self.per_request_info = {
rid: info for rid, info in self.per_request_info.items()
if rid not in rids
Expand Down Expand Up @@ -1908,7 +1908,8 @@ def check_stop_for_batch(
self, batch: ExecutingBatch, outputs: dict[str, NameToTensorList],
host_rows: HostRows | None = None,
) -> dict[str, set[str]]:
"""Each rid's ``submodule.check_stop``, as rid -> loops that should stop.
"""Each rid's ``submodule.check_stop``, as rid -> its stops (loop names,
or ``END_PARTITION``).

Reads tensor values, so it belongs on the caller's slow-postprocess
path rather than in ``exec``, where the ``.item()`` / ``.cpu()`` would
Expand Down
5 changes: 3 additions & 2 deletions mstar/graph/runtime/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -730,8 +730,9 @@ def cleanup_consumed_inputs(
pass

@abstractmethod
def mark_stream_partition_done(self, rid: int, partition: str):
"""The consuming pass saw the final streaming chunk. Reported on the
def mark_partition_done(self, rid: int, partition: str):
"""The partition is done: the consuming pass saw the final streaming
chunk, or a stop check returned ``END_PARTITION``. Reported on the
WORKER_GRAPHS_DONE that follows, unless the node was speculative."""
pass

Expand Down
8 changes: 4 additions & 4 deletions mstar/graph/runtime/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
class GraphRuntimePartitionInfo:
graph_walk: str
graph_walk_worker_graph_ids: list[int] = field(default_factory=list) # for this worker
stream_partition_done: bool = False # set True when last chunk pops with is_final
partition_done: bool = False # see GraphRuntime.mark_partition_done

@dataclass
class _SpecRidPrep:
Expand Down Expand Up @@ -452,10 +452,10 @@ def cleanup_consumed_inputs(
# the caller.
return FreedTensors.none()

def mark_stream_partition_done(self, rid: int, partition: str):
def mark_partition_done(self, rid: int, partition: str):
info = self._request_info.get(rid)
if info is not None and partition in info.partition_info:
info.partition_info[partition].stream_partition_done = True
info.partition_info[partition].partition_done = True

def get_consumed_edges(
self, source_node: str, dest_node: str, graph_walk: str,
Expand Down Expand Up @@ -1598,7 +1598,7 @@ def send_outputs(
# A speculatively-scheduled node has not really finished
# the partition, so it must not report done.
partition_done=(
part is not None and part.stream_partition_done
part is not None and part.partition_done
and not speculative
),
profiling=profiling.get(rid),
Expand Down
4 changes: 2 additions & 2 deletions mstar/graph/runtime/rust.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,8 +358,8 @@ def is_in_flight(
) -> bool:
return self._rust.is_in_flight(node, wg_id, rid)

def mark_stream_partition_done(self, rid: int, partition: str):
self._rust.mark_stream_partition_done(rid, partition)
def mark_partition_done(self, rid: int, partition: str):
self._rust.mark_partition_done(rid, partition)

def get_worker_graph_id_for_node(self, node: str, graph_walk: str) -> int:
return self._rust.get_worker_graph_id_for_node(node, graph_walk)
Expand Down
16 changes: 14 additions & 2 deletions mstar/model/bagel/bagel_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -1329,6 +1329,16 @@ def _get_step_metadata(
"image_preprocess": full_metadata.kwargs["image_preprocess"],
}

@staticmethod
def _prefill_token_metadata(sample_prefill_token: bool, target_output: str) -> dict:
"""Whether this pass samples the reply's first token, and whether a stop
there ends the request: only for a text reply, which has no walk after
decode."""
return {
"sample_prefill_token": sample_prefill_token,
"prefill_stop_ends": sample_prefill_token and target_output == "text",
}

def _get_fwd_pass_inputs(
self,
metadata: CurrentForwardConductorMetadata,
Expand Down Expand Up @@ -1460,7 +1470,7 @@ def get_initial_forward_pass_args(
step_metadata = self._get_step_metadata(full_metadata)

sample_prefill_token = (think_mode or target_output != "image") and len(schedule) == 1
step_metadata["sample_prefill_token"] = sample_prefill_token
step_metadata.update(self._prefill_token_metadata(sample_prefill_token, target_output))

inputs = self._get_fwd_pass_inputs(
full_metadata, input_signals
Expand Down Expand Up @@ -1542,7 +1552,9 @@ def get_partition_forward_pass_args(
request_done = True

step_metadata = self._get_step_metadata(metadata)
step_metadata["sample_prefill_token"] = sample_prefill_token
step_metadata.update(
self._prefill_token_metadata(sample_prefill_token, metadata.kwargs["target_output"])
)
inputs = self._get_fwd_pass_inputs(
metadata, persist_signals
)
Expand Down
12 changes: 10 additions & 2 deletions mstar/model/bagel/submodules.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
from mstar.model.bagel.config import MIXED_TEXT, BagelModelConfig
from mstar.model.higgs_audio.config import SAMPLER
from mstar.model.submodule_base import (
END_PARTITION,
ARNodeInputs,
ARNodeSubmodule,
BatchedModelOutput,
Expand Down Expand Up @@ -1677,8 +1678,15 @@ def check_stop(
return set()
token = outputs["new_token"][0].item()
ignore_eos = request_info.resource_configs[SAMPLER].ignore_eos
if (not ignore_eos and self.eos_token_id == token) or \
(request_info.dynamic_loop_iter_counts.get("decode_loop", 0) + 2 >= request_info.max_tokens):
hit_eos = not ignore_eos and self.eos_token_id == token
if request_info.graph_walk != "decode":
# The last prefill's token comes before decode_loop exists. The
# conductor says whether a stop there ends the request: not for an
# earlier prefill's throwaway token, nor for a think-then-image
# request, whose image_gen only the conductor's transition can start.
ends = request_info.step_metadata.get("prefill_stop_ends", False)
return {END_PARTITION} if hit_eos and ends else set()
if hit_eos or request_info.dynamic_loop_iter_counts.get("decode_loop", 0) + 2 >= request_info.max_tokens:
return {"decode_loop"}
return set()

Expand Down
3 changes: 2 additions & 1 deletion mstar/model/canary/canary_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,8 @@ def get_max_output_tokens_limit(self) -> int:
return self.get_max_output_tokens()

def get_loop_caps(self, **model_kwargs) -> dict[str, int]:
return {"decode_loop": self.get_max_output_tokens(**model_kwargs)}
# the prefill emits the first of max_output_tokens, so the loop runs one fewer
return {"decode_loop": max(1, self.get_max_output_tokens(**model_kwargs) - 1)}

def get_graph_walk_graphs(self) -> dict[str, GraphSection]:
prefill = Sequential([
Expand Down
3 changes: 2 additions & 1 deletion mstar/model/canary_qwen/canary_qwen_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,8 @@ def get_max_output_tokens_limit(self) -> int:
return self.MAX_OUTPUT_TOKENS

def get_loop_caps(self, **model_kwargs) -> dict[str, int]:
return {DECODE_LOOP: self.get_max_output_tokens(**model_kwargs)}
# the prefill emits the first of max_output_tokens, so the loop runs one fewer
return {DECODE_LOOP: max(1, self.get_max_output_tokens(**model_kwargs) - 1)}

def get_graph_walk_graphs(self) -> dict[str, GraphSection]:
prefill = Sequential([
Expand Down
18 changes: 4 additions & 14 deletions mstar/model/command_a_plus/submodules.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
CommandAPlusTextConfig,
)
from mstar.model.submodule_base import (
END_PARTITION,
ARNodeInputs,
ARNodeSubmodule,
ModelInputsFromEngine,
Expand Down Expand Up @@ -88,7 +89,6 @@ def __init__(
self.language_model = language_model
self.config = config
self.image_token_id = image_token_id
self._ended_at_prefill: set[str] = set()

def get_accelerator_graph_configs(
self, device: torch.device, tp_world_size: int = 1,
Expand Down Expand Up @@ -207,20 +207,13 @@ def postprocess(self, request_id, request_info, outputs, **kwargs):
outputs["decode_input"] = outputs["new_token"]
return
outputs["text_inputs"] = outputs["new_token"]
if request_id in self._ended_at_prefill:
# The reply ended on its first token; this step only stops the loop.
del outputs["new_token"]

def check_stop(self, request_id, request_info, outputs) -> set[str]:
ignore_eos = request_info.resource_configs[SAMPLER].ignore_eos
if request_info.graph_walk in PREFILL_WALKS:
if (request_info.max_tokens > 1 and not ignore_eos
and outputs["new_token"][0].item() == self.config.eos_token_id):
self._ended_at_prefill.add(request_id)
return set()
if request_id in self._ended_at_prefill:
self._ended_at_prefill.discard(request_id)
return {"decode_loop"}
# the reply's first token, before decode_loop exists
eos = not ignore_eos and outputs["new_token"][0].item() == self.config.eos_token_id
return {END_PARTITION} if eos else set()
if "new_token" not in outputs:
return set()
token = outputs["new_token"][0].item()
Expand All @@ -231,6 +224,3 @@ def check_stop(self, request_id, request_info, outputs) -> set[str]:
return {"decode_loop"}
return set()

def cleanup_request(self, request_id: str):
self._ended_at_prefill.discard(request_id)
super().cleanup_request(request_id)
Loading
Loading