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
37 changes: 17 additions & 20 deletions mstar/api_server/data_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,6 @@ def _kv_page_sizes(model: Model, model_config: dict) -> dict[str, int]:
}


NameToLoopIndices = dict[str, NestedLoopIndices]


class PreprocessWorker:
Expand Down Expand Up @@ -166,7 +165,8 @@ def __init__(
self.wakeup = EventWakeup()

self.per_request_reading_tensors = {}
self.output_loop_idxs: dict[str, NameToLoopIndices] = {}
# request id -> output name -> chunks received, for received_final_chunks
self.received_chunk_counts: dict[str, dict[str, int]] = {}

# Build the communicator + tensor manager here (main thread) and hand
# them to the worker thread, rather than constructing them inside it.
Expand Down Expand Up @@ -219,7 +219,7 @@ def _signal(self):
wakeup.signal()

def new_request(self, input: PreprocessInput):
self.output_loop_idxs[input.request_id] = {}
self.received_chunk_counts[input.request_id] = {}
self.per_request_reading_tensors[input.request_id] = 0
self.request_input_queue.put(input)
self._signal()
Expand All @@ -232,7 +232,7 @@ def abort_request(self, request_id: str):
# out from under it. Drop only the main-thread bookkeeping here.
self.abort_request_queue.put(request_id)
self._signal()
self.output_loop_idxs.pop(request_id, None)
self.received_chunk_counts.pop(request_id, None)
self.per_request_reading_tensors.pop(request_id, None)

def finished_reading(self, request_id: str, drained: bool = True):
Expand All @@ -244,21 +244,20 @@ def finished_reading(self, request_id: str, drained: bool = True):
for any in-flight read."""
self.reads_done_queue.put((request_id, drained))
self._signal()
self.output_loop_idxs.pop(request_id, None)
self.received_chunk_counts.pop(request_id, None)
self.per_request_reading_tensors.pop(request_id, None)

def new_result_tensors(self, input: ResultTensors):
name = input.graph_edge.name
if input.request_id not in self.output_loop_idxs:
if input.request_id not in self.received_chunk_counts:
# Request was removed while this output was still in flight; ack the
# tensors so the producing worker can reclaim them rather than leak.
logger.debug("Late result_tensors for cleaned-up request %s, acking and dropping", input.request_id)
self.discard_result_tensors(input)
return

self.output_loop_idxs[input.request_id][name] = input.loop_indices.max(
self.output_loop_idxs[input.request_id].get(name, None)
)
counts = self.received_chunk_counts[input.request_id]
counts[name] = counts.get(name, 0) + 1

self.per_request_reading_tensors[input.request_id] += len(input.graph_edge.tensor_info)
logger.debug(
Expand All @@ -277,13 +276,11 @@ def new_result_token(
transport read and nothing to ack. ``wake=False`` queues without
ending the thread's idle wait: a frame's caller hands over every
request's value first and calls ``wake`` once."""
if request_id not in self.output_loop_idxs:
if request_id not in self.received_chunk_counts:

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.

#419 (38113a4) shares output_loop_idxs with the data worker thread under _admit_lock, its thread teardown drops the entry, and its received_final_chunks treats a missing entry as done unless an error is held. Whoever rebases second needs to carry the rename into that teardown, keep the increment under the lock and keep the None branch, otherwise a request #419 tore down reads as 0 received here and sits to the 15 s TTL with a spurious not-delivered error. 8 textual hunks in this file plus 2 in entrypoint.py, the rest merges clean.

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.

Thanks for the runs. Merge order: #551 first, then #419 rebases onto it; both bodies now say so. No change to #551's code since the approval.

The resolution is ready and tested on main + #551 + #419: #419's side of data_worker.py and entrypoint.py, with the counts carried into every use, including the worker thread's constructor argument and _drop_producer_state, both increments kept under _admit_lock, and the None branch kept. Both PRs' test files pass on that tree (247 passed, 1 skipped). Breaking each of the three things on that tree fails a #419 test:

Broken on the union Failing test
teardown still pops output_loop_idxs test_cancel_queued_then_new_request_then_drain_cleans_maps
None branch dropped test_teardown_between_the_pending_check_and_the_read_completes_normally
token count taken outside _admit_lock test_new_result_token_waits_for_the_admit_lock_and_respects_teardown

The rebase also adds test_a_request_torn_down_short_of_its_chunk_count_completes_without_the_ttl, the case here: completion reports 3 chunks, 1 arrived, then the thread tears the request down. It completes at once with what arrived and no error, and fails if the None branch is lost. If #419 lands first instead, #551's rebase takes the same tree.

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.

Sounds good on the merge order. One small thing for the description while you are in it, the phrase about any AR model with separate prefill and decode worker graphs is wider than the code. Qwen3.5 does not reach this because its prefill and decode use different output names (qwen3_5_model.py:274-279 vs 291-296), Qwen3-Omni does because both emit new_token.

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.

the phrase about any AR model with separate prefill and decode worker graphs is wider than the code

Fixed in the body: it now names models whose prefill and decode emit under the same output name, such as Qwen3-Omni (new_token from both), and says Qwen3.5 does not reach it (new_token from prefill, text_inputs from decode). No code change.

logger.debug("Late token for cleaned-up request %s dropped", request_id)
return
if loop_indices is not None:
self.output_loop_idxs[request_id][signal] = loop_indices.max(
self.output_loop_idxs[request_id].get(signal, None)
)
counts = self.received_chunk_counts[request_id]
counts[signal] = counts.get(signal, 0) + 1
self.per_request_reading_tensors[request_id] += 1
self.result_tensor_input_queue.put(
InlineToken(request_id, value, loop_indices, modality)
Expand All @@ -309,18 +306,18 @@ def has_pending_tensors(self, request_id: str):

def received_final_chunks(
self, request_id: str,
final_outputs: dict[str, NestedLoopIndices],
output_chunk_counts: dict[str, int],
):
# Every serving walk reports at least one client-facing output, so an
# empty dict means the walk emitted nothing (or completion raced ahead
# of every result). Report not-done and let the TTL backstop close the
# request rather than completing it instantly with zero chunks.
if not final_outputs:
if not output_chunk_counts:
return False
received = self.received_chunk_counts.get(request_id, {})
return all(
not loop_iters.label_context_gt( # recv'd loop iters is not less than the final_fwd
self.output_loop_idxs[request_id].get(name, None)
) for name, loop_iters in final_outputs.items()
received.get(name, 0) >= count
for name, count in output_chunk_counts.items()
)

def get_result_chunks(self)-> list[ResultChunk]:
Expand Down Expand Up @@ -373,7 +370,7 @@ def get_rx_info(self, request_id: str) -> list[RxInfo]:
def cleanup_request(self, request_id: str):
self.cleanup_request_queue.put(request_id)
self._signal()
self.output_loop_idxs.pop(request_id, None)
self.received_chunk_counts.pop(request_id, None)
self.per_request_reading_tensors.pop(request_id, None)

def shutdown(self):
Expand Down
8 changes: 4 additions & 4 deletions mstar/api_server/entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ class PendingRequest:
profile: RequestProfile
event: threading.Event = field(default_factory=threading.Event)
chunks: list[ResultChunk] = field(default_factory=list)
final_outputs: dict = field(default_factory=dict)
output_chunk_counts: dict[str, int] = field(default_factory=dict)
consumed_chunks: int = 0
error: Any | None = None
error_status: int = 500
Expand Down Expand Up @@ -629,7 +629,7 @@ def _prune_recently_completed(self) -> None:
req is not None
and not self.preprocess_worker.has_pending_tensors(rid)
and self.preprocess_worker.received_final_chunks(
rid, req.final_outputs
rid, req.output_chunk_counts
)
)
if drained or req is None:
Expand Down Expand Up @@ -762,14 +762,14 @@ def _process_messages(self) -> None:
logger.info("API server received %s done", rid)
self.recently_completed[rid] = time.time()

if not message.body.final_outputs:
if not message.body.output_chunk_counts:
logger.warning(
"Request %s completed with no reported "
"outputs; holding for late results "
"until the cleanup TTL", rid,
)
req = self.pending_requests[rid]
req.final_outputs = message.body.final_outputs
req.output_chunk_counts = message.body.output_chunk_counts
req.profile.timing.conductor_ingest_time = \
message.body.conductor_ingest_time
req.profile.timing.conductor_finish_time = \
Expand Down
8 changes: 4 additions & 4 deletions mstar/api_server/request_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,10 @@ class ResultTokens:
class RequestComplete:
"""Signals that a request has finished processing."""
request_id: str
# Maps output signal name to its final forward pass number.
# The API server waits until all entries are received before
# completing the request.
final_outputs: dict[str, NestedLoopIndices]
# Output signal name -> chunks the workers emitted to the client. The API
# server waits until it has received that many of each before completing
# the request: completion can overtake the last chunks on the wire.
output_chunk_counts: dict[str, int]
conductor_ingest_time: float
conductor_finish_time: float
graph_timings: GraphTimings = field(default_factory=dict)
Expand Down
19 changes: 10 additions & 9 deletions mstar/conductor/conductor.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
from mstar.distributed.communication import GlobalParallelConfig, WorkerParallelGroups
from mstar.engine.resources import KVReqConfig, ResourceReqConfig
from mstar.graph.base import GraphEdge, NodeAndGraphWalk, TensorPointerInfo
from mstar.graph.loop_indices import NestedLoopIndices
from mstar.model.base import ForwardPassArgs, Model, WorkerGraph
from mstar.platforms import apply_process_env_defaults
from mstar.profile.format import RxInfo, TxInfo
Expand Down Expand Up @@ -263,8 +262,9 @@ class RequestData:
# Per-streaming-connection state, keyed by (from_partition, to_partition, edge_name)
streaming_connections: dict[tuple[str, str, str], StreamingConnectionState] = field(default_factory=dict)

# for api server recv bookeeping
final_outputs: dict[str, NestedLoopIndices] = field(default_factory=dict)
# output name -> chunks the workers emitted to the client; the API server
# holds the request open until it has received this many of each
output_chunk_counts: dict[str, int] = field(default_factory=dict)

# Conductor-side profiling timestamps, in ``time.perf_counter()`` seconds.
# Comparable with the api-server stamps because perf_counter is
Expand Down Expand Up @@ -1438,7 +1438,7 @@ def _process_request_done(
message_type="request_complete",
body=RequestComplete(
request_id=request_id,
final_outputs=request_data.final_outputs,
output_chunk_counts=request_data.output_chunk_counts,
conductor_ingest_time=request_data.conductor_ingest_time,
conductor_finish_time=request_data.conductor_finish_time,
graph_timings=request_data.graph_timings,
Expand Down Expand Up @@ -1499,8 +1499,13 @@ def _process_worker_graphs_done(
for tx in body.tx_info:
request_data.tx_info[(tx.source_entity, tx.edge_name)] = tx

# From every rank: a rank reports only the chunks it sent itself, and
# each of those reaches the API server as its own message.
counts = request_data.output_chunk_counts
for name in body.output_signal_names:
counts[name] = counts.get(name, 0) + 1

pstate = request_data.partition_states.get(partition_name)
request_data.final_outputs.update(body.output_loop_indices)
if pstate is None:
logger.warning(
"WorkerGraphsDone for unknown partition %s (request %s)",
Expand Down Expand Up @@ -1538,10 +1543,6 @@ def _process_worker_graphs_done(
consumed = body.stream_tokens_consumed.get(conn.edge_name, 0)
conn.consumed_count = max(conn.consumed_count, consumed)

request_data.final_outputs.update(body.output_loop_indices)

pstate.curr_forward_outputs += body.output_signal_names

# Each wg is only marked complete when all its TP ranks have reported.
for wg_id in body.worker_graph_ids:
count = pstate.wg_rank_completions.get(wg_id, 0) + 1
Expand Down
1 change: 0 additions & 1 deletion mstar/conductor/request_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,6 @@ class PartitionState:
# wg_id -> count of distinct TP ranks that have reported completion
wg_rank_completions: dict[int, int] = field(default_factory=dict)
num_output_tokens: int = 0
curr_forward_outputs: list[str] = field(default_factory=list)
# resource label -> PublishedInfo, accumulated across all TP ranks and
# handed back out on the next forward
resource_publish_info: dict[str, PublishedInfo] = field(default_factory=dict)
7 changes: 0 additions & 7 deletions mstar/graph/loop_indices.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,3 @@ def label_context_gt(self, other: "NestedLoopIndices | None", target_loop_name:
if our_idx < their_idx:
return False
return False

def max(self, other: "NestedLoopIndices | None") -> "NestedLoopIndices":
if other is None:
return self
if other.label_context_gt(self):
return other
return self
6 changes: 1 addition & 5 deletions mstar/graph/runtime/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,8 @@ class GraphRuntimeRequestInfo:
# so a persist signal cannot race the message that announces it.
pending_persist_signals: list[GraphEdge] = field(default_factory=list)
pending_new_token_counts: dict[str, dict[str, int]] = field(default_factory=dict) # by partition
# one name per chunk emitted to the client; the conductor counts them
current_output_chunks: list[str] = field(default_factory=list)
output_loop_indices: dict[str, NestedLoopIndices] = field(
default_factory=dict
)


class PythonGraphRuntime(GraphRuntime):
Expand Down Expand Up @@ -1509,7 +1507,6 @@ def send_outputs(
edge.name for edge in routing.emit_to_client
)
for edge in routing.emit_to_client:
info.output_loop_indices[edge.name] = nested_idxs.get(rid)
self._communicator.send("api_server", APIServerMessage(
message_type="result_tensors",
body=ResultTensors(
Expand Down Expand Up @@ -1618,7 +1615,6 @@ def _send_worker_graphs_done(
partition_name=partition,
partition_done=partition_done,
stream_tokens_consumed=stream_tokens_consumed,
output_loop_indices=info.output_loop_indices,
graph_timings=graph_timings,
rx_info=rx_info,
tx_info=tx_info,
Expand Down
2 changes: 1 addition & 1 deletion mstar/utils/ipc_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,12 +235,12 @@ class WorkerGraphsDone(MessageBody):
is_first_tp_rank: bool
persist_signals: dict[str, list[TensorPointerInfo]] = field(default_factory=dict)
new_token_counts: dict[str, int] = field(default_factory=dict) # name to token counts
# one name per chunk this worker emitted to the client since its last report
output_signal_names: list[str] = field(default_factory=list)
resource_publish_info: dict[str, PublishedInfo] = field(default_factory=dict)
partition_name: str = field(default="default")
partition_done: bool = field(default=False)
stream_tokens_consumed: dict[str, int] = field(default_factory=dict) # edge_name -> tokens consumed from stream
output_loop_indices: dict[str, NestedLoopIndices] = field(default_factory=dict)
graph_timings: GraphTimings = field(default_factory=dict)
rx_info: list[RxInfo] = field(default_factory=list)
tx_info: list[TxInfo] = field(default_factory=list)
Expand Down
2 changes: 1 addition & 1 deletion mstar/worker/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -3245,7 +3245,7 @@ def _pp_stage(name: str) -> None:
range_push("worker.send_outputs", synchronize=False)

# The consuming pass (not the earlier ingest) reports the partition
# done, so it rides this pass's WGD with the final output loop index.
# done, so it rides the WGD that also counts this pass's last chunks.
for rid in batch_N.node_batch.stream_partition_done_rids:
self._graph_runtime.mark_stream_partition_done(rid, batch_N.partition)

Expand Down
9 changes: 0 additions & 9 deletions rust/src/graph/frames.rs
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,6 @@ pub struct WorkerGraphsDone<'a> {
pub partition_name: &'a str,
pub partition_done: bool,
pub stream_tokens_consumed: &'a [(String, i64)],
pub output_loop_indices: Vec<(Sym, (Vec<Sym>, Vec<(Sym, u32)>, u32))>,
/// `dict[str, PublishedInfo]` -- abstract, so it stays Python's. Read
/// encoded by Python and forwarded without interpreting resource types.
pub resource_publish_info: Option<Value>,
Expand Down Expand Up @@ -275,14 +274,6 @@ impl WorkerGraphsDone<'_> {
str_counts(
m.key("stream_tokens_consumed"), self.stream_tokens_consumed,
);
w_map(
m.key("output_loop_indices"),
self.output_loop_indices.len() as u32,
);
for (sig, (order, idxs, fwd)) in &self.output_loop_indices {
w_str(m.buf(), it.name(*sig));
nested_loop_indices(m.buf(), it, order, idxs, *fwd);
}
// Omitted when absent, so the decoder's default stands.
if let Some(v) = &self.resource_publish_info {
w_value(m.key("resource_publish_info"), v);
Expand Down
21 changes: 5 additions & 16 deletions rust/src/graph/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,15 +106,14 @@ pub struct PendingOutputs {
pub persist: Vec<(Sym, Vec<u64>)>,
/// partition -> signal -> count; each report carries its own partition's.
pub new_tokens: Vec<(PartitionId, Vec<(String, i64)>)>,
/// Emitted signal names, in emission order.
/// One name per chunk emitted to the client, in emission order. The
/// conductor counts them to tell the API server how many chunks to wait
/// for.
pub output_signals: Vec<Sym>,
/// signal -> the loop context it was emitted at.
pub output_loop_indices: Vec<(Sym, (Vec<Sym>, Vec<(Sym, u32)>, u32))>,
}

impl PendingOutputs {
/// Everything but `output_loop_indices`, which the conductor keeps for the
/// life of the request -- Python re-sends it on every WGD.
/// Everything pending, with only `partition`'s token counts.
pub fn take_for_send(
&mut self, partition: Option<PartitionId>,
) -> (Vec<(Sym, Vec<u64>)>, Vec<(String, i64)>, Vec<Sym>) {
Expand Down Expand Up @@ -147,8 +146,7 @@ impl PendingOutputs {
}
}

/// Accumulate, matching Python: a repeated signal's count adds up, and a
/// repeated loop index overwrites.
/// Accumulate, matching Python: a repeated signal's count adds up.
pub fn add_new_tokens(&mut self, partition: PartitionId, counts: &[(String, i64)]) {
let i = match self.new_tokens.iter().position(|(p, _)| *p == partition) {
Some(i) => i,
Expand All @@ -165,15 +163,6 @@ impl PendingOutputs {
}
}
}

pub fn set_loop_indices(
&mut self, signal: Sym, idx: (Vec<Sym>, Vec<(Sym, u32)>, u32),
) {
match self.output_loop_indices.iter_mut().find(|(s, _)| *s == signal) {
Some(slot) => slot.1 = idx,
None => self.output_loop_indices.push((signal, idx)),
}
}
}

impl RequestInfo {
Expand Down
Loading
Loading