Skip to content

[WIP] Rust graph port - #283

Draft
NSagan271 wants to merge 65 commits into
mainfrom
nsagan-rust-graph-2
Draft

NSagan271 wants to merge 65 commits into
mainfrom
nsagan-rust-graph-2

Conversation

@NSagan271

Copy link
Copy Markdown
Collaborator

What does this PR do?

How was it tested?

Checklist

  • ruff check . passes
  • Added or updated tests / docs where relevant

NSagan271 and others added 30 commits September 19, 2026 16:45
Ports the batched surface from the draft branch, adapted to the int rid
handles and uuid-only per-tensor calls that landed since.

The base-class forms are loops, so every transport gets the surface for
free; what they already buy is hoisting the per-call CUDA sync out of the
loop. ArenaShmCommunicationManager overrides register_for_send_batch
because the default would leave B host-blocking _d2h_stream.synchronize()
stalls per forward pass -- the override stages the whole batch in one
stream context and syncs once. register_for_send's body moves to
_stage_one so both paths share it.

_register_outputs now uses the batched form. The store path is left on
the per-rid call: it already runs under skip_cuda_sync=True (the worker
syncs on the batch completion event first), so batching it would reorder
work for no gain.

Also drops the unused rid parameter from set_output_ref_counts, which the
rid refactor missed -- its body is entirely uuid-keyed.

Co-Authored-By: Claude <noreply@anthropic.com>
…id sweep

Moves set_walk, get_worker_graph_id_for_node and get_dynamic_loop_iters
onto PythonGraphRuntime and deletes the WorkerGraphsManager versions.

To let the port proceed method by method, the runtime is now constructed
with real state and OWNS the per-request queue lifecycle; the manager is
handed the same queues dict rather than building its own, so there is one
copy of the state and no chance of divergence. WorkerGraphsManager
add_request/remove_request no longer touch the queues.

set_walk also re-derives graph_walk_worker_graph_ids, which the ported
version was missing -- the walk selects which worker graphs are live, so a
stale list routes a pass's inputs into the previous walk's graphs.

The two get_dynamic_loop_iters call sites were per-rid loops and are now
one batched call each.

--- keyword drift ---

The earlier rid refactor renamed identifiers with a regex that also hit
keyword arguments, silently breaking nine constructors:

  InputSignals, ResultTensors, WorkerGraphsDone, StopLoops,
  RemoveRequest (x2), GraphRuntime.add_request, StreamBuffer

All are on integration paths the unit suite never builds, which is why
1030 green tests said nothing. StreamBuffer is worker-internal so its
field becomes rid: int; the rest are wire types and get request_id back.

test_wire_keyword_arity walks the AST and checks every in-tree dataclass
construction against the real fields, so the next sweep cannot do this
quietly. It found the StreamBuffer case on its first run.

It also found mstar/model/cosmos3/tests/* passing sampling_config= to
CurrentForwardPassInfo, dropped from that type in #228. Those files are
outside the suite's paths and are excluded with a pointer rather than
guessed at.

Co-Authored-By: Claude <noreply@anthropic.com>
Every other worker test stubs _add_new_request out, so the whole admit
path was untested -- which is why eight constructor-keyword breakages
from the rid sweep sat behind a green suite.

Covers what the port just changed: the runtime mints the handle and owns
the per-request queue lifecycle, the manager shares that same queues dict
and owns per_request_info, the handle is stamped onto fwd_info, and both
sides tear down on REMOVE. The teardown assertion matters because handles
are recycled -- the test admits a second request and asserts it gets the
same integer back, so anything left behind would attach to it.

Verified against the real regression: reintroducing add_request(rid=...)
fails two of the three tests.

Also scopes test_wire_keyword_arity to mstar-owned dataclasses. HF configs
(PretrainedConfig subclasses) take **kwargs, so dataclasses.fields() does
not describe what their __init__ accepts and every extra kwarg read as an
error.

Co-Authored-By: Claude <noreply@anthropic.com>
check_dyn_loop, get_dyn_loop_workers and the sharding-config accessor move
to PythonGraphRuntime and the WorkerGraphsManager versions are deleted.
These are pure reads over dyn_loop_to_workers / sharding_config, which
add_request already builds on the runtime -- the manager's PerRequestInfo
was carrying a second copy of the same thing.

Also stores the communicator param (added to the signature but not kept),
which stop_loops_batched and send_outputs will need.

Fixes a shadowing bug in the loop-stop block while converting it: the
inner `for worker, loop_names in stop_loop_workers.items()` rebound the
set the enclosing iteration was still working from. Harmless today because
it is the last use, but it is the same shape as the bug ruff caught in
the add_request port.

Co-Authored-By: Claude <noreply@anthropic.com>
Completes the bookkeeper's tensor-info storage: adds the read side
(get_info / get_info_batch) and threads the descriptor through every
put_tensor call site.

This is what unblocks the flat ingest/route contract. Those carry uuids,
but a disaggregated loop re-emits its ingested external inputs as outputs,
which can be routed to a peer -- and there the descriptor IS the payload
the peer reads from. A uuid-only stub would produce a structurally valid
but unreadable message, failing as a garbage remote read rather than an
exception.

The stored descriptor is the same object the edge carries, so the in-place
shm_segment/shm_offset that register_for_send stamps on is visible through
both. Two sites needed reordering to bind the descriptor before the put:
store_and_return_tensor_info built it after, and the slice path re-points
info.uuid at a freshly minted uuid -- moving the put after the re-point
makes the stored descriptor name the slice rather than the producer's
tensor by construction, instead of relying on it being a shared reference.

The last test pins the whole premise end to end: an edge holding nothing
but store.get_info(uuid) is read correctly by a peer over SHM.

Co-Authored-By: Claude <noreply@anthropic.com>
Uses the last_node_run parameter you added. loop_stop_times moves off
CurrentForwardPassInfo onto GraphRuntimeRequestInfo per the note: it was
worker-only state riding on an object that gets forwarded to peers.
clear_loop_stop_info went with it -- it had no callers.

_pending_loop_stops moves to the runtime too, since step (2) of the
contract puts it there. Its three worker readers become accessors.

The WorkerGraphsManager.stop_loops and the worker's inline stop block are
deleted; the runtime now does the check_dyn_loop filtering, the graph
stop, the stop-time snapshot and the peer fan-out in one call.

--- added ABC surface, please review the shape ---

Moving that state required five methods the contract did not name:
apply_peer_loop_stops, get_loop_stop_times, has_pending_loop_stop,
pending_loop_stop_rids, clear_pending_loop_stops. PendingLoopStop moved
from worker.py to runtime/base.py for the same reason.

apply_peer_loop_stops is the one worth a look: the receive side of
STOP_LOOPS needs to be on the contract for a Rust backend to handle the
message at all, and it is deliberately NOT stop_loops_batched -- it takes
no last_node_run (the originating rank took the snapshot) and does not
fan out (that rank already told everyone), so routing it through the
local path would loop.

Tests move with the behaviour. The peer-merge tests initially passed
while constructing a malformed NestedLoopIndices (loop_indices is a dict,
not a list) because wg_fwd_pass_idx short-circuits label_context_gt before
that field is read; fixed, and added a case that holds the forward pass
constant so the enclosing-loop-index comparison is actually exercised.

Co-Authored-By: Claude <noreply@anthropic.com>
get_ready_nodes and has_ready_excluding move to PythonGraphRuntime; both
get_next_batch's scan and the speculation peek now go through them.

The split the contract asks for: the runtime answers "whose graph inputs
are satisfied", the scheduler applies engine readiness. Engine readiness
has to stay outside because _check_ready can FAIL a request (an
AdmitRuntimeError parks it in admit_errors), which is a scheduling
decision, not a graph one.

Two asymmetries between the old scans are preserved deliberately rather
than unified:

  - get_next_batch filters to parallel_leader_nodes (only rank 0 initiates);
    the peek does not. The filter stays in the scheduler so the runtime's
    scan means the same thing for both callers.
  - the peek counts a rid in the head TP-follow batch as ready even when
    failed, because get_next_batch will schedule that one regardless. That
    stays in the exclude set the scheduler builds.

has_ready_excluding is now used as an early-out: graph readiness is
necessary but not sufficient, so a False is final and skips the engine
pass entirely, while a True falls through to the engine-filtered
confirmation. That keeps the cheap first-match scan meaningful without
changing what the method reports.

Also drops get_loop_stop_times from the ABC -- its only caller is the
runtime's own fan-out, so it becomes _loop_stop_times. The other four
added methods are all called from worker.py and stay.

Test fakes gain the graph-level scan over the same queues the manager
holds, mirroring the real sharing.

Co-Authored-By: Claude <noreply@anthropic.com>
Finishes the scheduling group. Both scheduler pop sites (pop_ready_rids
and _assemble_batch) and all four worker push-back sites go through the
runtime; WorkerGraphsManager.push_back_node is deleted.

pop_rids returns PopRidsOutput, so it also hands back the batch's ready
inputs as EdgeSpecs. Nothing consumes those yet -- _build_executing_batch
still walks node.ready_signals -- so there is a TODO at the call site
rather than a second path. That switch is what makes the descriptor store
earn its keep, and it belongs with complete_and_route_batch.

Engine readiness stays ahead of the pop in the scheduler: the contract
says pop_rids assumes it, and it has to run over the whole set first
because both checks are all-or-nothing.

get_nodes is explicitly transitional and says so. The worker still carries
GraphNodes through its batch for postprocess; those uses move into
complete_and_route_batch and this goes with them, since a Rust backend
cannot hand out live Python graph objects.

--- ParallelList footgun ---

`batch_rids, wg_ids = popped.wg_ids` looks like NamedTuple field unpacking
but goes through the overridden __iter__, so it binds (key, value) PAIRS.
At exactly two entries it succeeds silently and binds the wrong thing;
here it happened to raise. Documented on __iter__ and pinned in
test_containers.py, including the two-entry case.

Co-Authored-By: Claude <noreply@anthropic.com>
process_node_outputs, mark_node_complete and get_nested_loop_idxs_for_node
move to PythonGraphRuntime and the WorkerGraphsManager copies are deleted.
Everything process_node_outputs needed was already on the runtime
(sharding_config, node_to_workers, worker_graph_ids, the walk->wg index,
the queues), so it is a faithful move with field accesses retargeted.

The worker's postprocess prologue now stores tensors and hands over uuids:
store_and_populate_graph_edges splits back into store_and_return_tensor_info
plus a safety-hold increment, and the runtime populates its own edges from
tensor_store.get_info. That is what the descriptor store was for.

RouteOutput drives the staging: the runtime says which tensors remote
consumers will read (deduped by uuid, skipping already-registered), and
_register_outputs is now a translation from indices back to descriptors
instead of re-deriving the set from the routing.

take_completion is transitional and labelled: send_outputs will consume the
parked routing internally, and it goes away then.

--- test that proved nothing ---

test_route_batch_decodes_the_flat_rid_major_layout initially used ONE
output signal, so num_tensors[i * n_signals + s] and a transposed
num_tensors[s * n_rids + i] index identically -- it passed against a
deliberately transposed implementation. Rewritten with two signals and
asymmetric per-rid counts, and re-verified: the transposition now fails it.
Same failure mode as the NestedLoopIndices case two commits ago, so it is
worth being systematic about checking that these tests can fail.

Co-Authored-By: Claude <noreply@anthropic.com>
The runtime now owns the sends and the per-request buffers: persist
signals, new-token counts, output chunk names and output loop indices move
off PerRequestInfo, and the six buffer_/flush_ methods plus the loop-index
pair are deleted from WorkerGraphsManager.

What stays worker-side is what touches real tensors or cannot be derived at
send time, handed over via SendInput:

  - new-token counts need numel(), so the worker counts and passes them
  - local streaming feeds a StreamBuffer, which holds tensors; RouteOutput's
    local_streaming_tensor_idxs is how the caller finds those
  - stream_tokens_consumed, partition_done_rids, profiling (msgpacked, so
    the runtime need not own RxInfo/TxInfo)

Adds nested_loop_indices to SendInput. The snapshot has to predate the
completion -- mark_node_complete and stop_loops both advance loop state --
so the runtime cannot re-derive it at send time. Same category as the other
"cannot derive" fields already there.

take_completion becomes peek_completion: send_outputs is what pops the
entry, and the worker still needs to see the routing first. As written the
two both popped, so the second raised KeyError -- on the live path only,
which no test reached. test_send_outputs_consumes_the_completion now covers
the handoff.

--- second ParallelList footgun ---

`dict(pl)` raises "'list' object is not callable": dict probes for a
.keys() METHOD to decide it was handed a mapping, and ParallelList.keys is
a list attribute, so the probe calls it. Needs dict(iter(pl)). Documented
on the class alongside the unpacking trap and pinned in test_containers.

Co-Authored-By: Claude <noreply@anthropic.com>
All four ingest sites (new request, peer INPUT_SIGNALS, stream-buffer poll,
ready-tensor poll) go through the runtime. WorkerGraphsManager's
process_new_inputs / process_new_streaming_inputs are deleted, and so is
the WorkerGraphQueues streaming variant -- the runtime applies the
ready_for_streaming gate inline, per signal, so ingesting one signal can be
what makes the next eligible.

Only uuids cross the boundary; the runtime rebuilds descriptors from the
tensor store. Checked that all four provenances have them: signal-only
edges carry none, peer edges get theirs from start_read_tensors, and the
streaming synthetic edge mints fresh ones through
store_and_return_tensor_info.

The return type goes from leftover GraphEdges to indices, which is what the
streaming path actually needs -- it hands the original edge back to its
StreamBuffer, so an index is enough and the edge never has to survive the
round trip.

Both test stubs gain a real TensorStore, since resolving descriptors is now
part of what a tensor manager has to provide.

Co-Authored-By: Claude <noreply@anthropic.com>
set_node_metadata had no caller anywhere in the tree, so the runtime's
parallel_nodes / parallel_leader_nodes / tp_async_nodes were all empty.
Nothing depended on them yet, so it was latent rather than broken -- but
speculate_node is the first consumer and would have silently mis-filtered.

The worker resolves two cases the contract's signature cannot express
before passing them down: tp_async_nodes=None means "every node", and the
feature being off means "none". The runtime just takes the effective set,
where empty means none.

speculate_node is implemented and tested but NOT yet wired into
_try_speculate_next, because the two speculation methods are coupled: the
worker's per-rid filtering reads is_new_loop_iter and loop_name off
SpeculativeNodeInfo, and SpeculationOutput deliberately drops both since
the contract moves that filtering into prep_spec_rids. Converting the call
site without prep_spec_rids would lose it. Calling ingest_for_speculation
twice to recover the fields is not an option either -- it mutates
speculative slot state.

Co-Authored-By: Claude <noreply@anthropic.com>
prep_spec_rids does the per-rid loop-completion filtering as designed
(pending stop, final iteration), the streaming ingest with slot tracking,
the readiness check and the rollback. SpeculationPrepOutput gains wg_ids.

The worker keeps what holds tensors: polling the StreamBuffers, returning
unconsumed chunks to them, and turning the returned EdgeSpecs into tensors.

--- two contract notes ---

SpeculationOutput gains is_new_loop_iter and loop_name. NOT for filtering
-- prep_spec_rids still owns that -- but the speculative batch carries them
onto PendingBatch, and the postprocess needs them to match its pending loop
stops. The selection step already computes both, so reporting them avoids a
second ingest_for_speculation, which would mutate speculative slot state.

The TP-follow path still preps through the worker-side helper. A follower
is all-or-nothing: rank 0 committed to that exact composition and sits on
the collective until every follower joins, so one rid failing has to roll
the whole batch back. prep_spec_rids is per-rid best-effort and the input
has no way to ask for the other mode. Flagged rather than guessed at.

--- a test asserting something structurally impossible ---

The two loop-filter tests first drove prefill -> ar_decode, which ENTERS
the loop rather than looping back, so is_new_loop_iter is False and the
filter cannot fire. They failed, which is how the fixture error surfaced;
rewritten to speculate ar_decode -> ar_decode, with a sanity assertion that
the loop-back is speculatable before each stop is applied, plus a case
pinning that the filter correctly does NOT apply on the way in.

Co-Authored-By: Claude <noreply@anthropic.com>
Completes your WIP. ScheduledBatch now carries only rids, worker graph ids
and the ready inputs the pop already reported; no GraphNode survives in
flight, so get_nodes and _get_wgio_for_rid are gone.

Per your four decisions:

(4) pop_rids' input_edges thread through ScheduledBatch, so the batch build
    and both fresh-rid merges read them from there. _get_input_tensors is
    deleted; _tensors_for is the one place uuids become tensors.
(2) get_consumed_edges is a standalone runtime query -- structural, so it
    takes no rid.
(3) prep_follow_spec_rids is separate, sharing _prep_one_spec_rid and
    _streaming_edges_by_rid. The undo is factored out as _undo_spec_ingest
    so it serves both the per-rid not-ready case and the follower's
    all-or-nothing unwind.
(1) _partition_done is internal: the worker calls mark_stream_partition_done
    and send_outputs derives the rest, so SendInput loses
    partition_done_rids.

Also added get_spec_target: a follower cannot use speculate_node, because
that filter requires the node be in parallel_leader_nodes and a follower by
definition is not. The leader already chose; this reports the target's loop
context.

The follower now pops fresh rids BEFORE prepping continuing ones. The old
order needed _rollback_all when the pop failed; reversed, the only undo a
failure needs is push_back_node, which already exists.

Four bugs in the WIP:
  - pop_ready_rids returned a 1-tuple (trailing comma), so the TP-follow
    path would have put a tuple in request_to_worker_graph
  - `node` was undefined in the leader's fresh-rid merge
  - _try_follow_speculate still called _assemble_speculation with the old
    arity; ruff missed it because sample_node was bound in that scope
  - request_to_worker_graph kept `= None` after node_objects (the required
    field) was removed, making an empty ScheduledBatch constructible and
    __len__ raise; now default_factory=dict

Co-Authored-By: Claude <noreply@anthropic.com>
It stopped managing worker graphs several commits ago; this makes the name
and the contents match. What is left is the request state the runtime
deliberately does not take: CurrentForwardPassInfo, which it treats as an
opaque wire object, and the StreamBuffers, which hold tensors.

Deleted as dead or duplicated by PythonGraphRuntime:

  PerRequestInfo   node_to_workers, dyn_loop_to_workers, worker_graph_ids,
                   sharding_config
  PerPartitionInfo graph_walk_worker_graph_ids, stream_partition_done
  manager          queues, base_sharding_config, the three
                   all_worker_graph_ids_to_* maps, walk_node_to_worker_graph_id
                   and the __post_init__ that built it, get_graph_walk,
                   get_publish_info, get_fwd_number, has_partition

add_request drops from five parameters to two: the queue lifecycle and the
routing tables both belong to the runtime, so registering a partition is
all that is left.

The four sharding_config readers now go through the runtime, which is the
last thing the manager's copy was for. get_sharding_config returns None for
an unknown rid -- the drain and TP-fanout callers can legitimately race a
removal, and they already guarded for it.

The shared-queues wiring is gone with it: the manager was handed the
runtime's dict so the port could proceed method by method, and nothing on
that side reads queues any more. The admit test asserts the finished split
instead.

test_worker_graphs_manager.py is now test_graph_runtime.py, which is what
it has been testing for a while.

Co-Authored-By: Claude <noreply@anthropic.com>
Adds graph/request.rs following the struct-per-concern shape:
RequestPartitionInfo (walk, the worker graphs it selects, stream-done),
RequestInfo (partitions, node/loop -> workers, loop stop times), and
WorkerGraphMeta for the static worker-graph facts.

Partition info is keyed by partition on the request, NOT stored in the
worker graph queues -- see the module doc. A partition spans several worker
graphs, so putting it there duplicates the walk and makes set_walk a
scatter-write. The worker graph -> partition direction IS static, so that
lives on WorkerGraphMeta and costs nothing per request.

set_node_metadata and add_request are filled in, plus remove_request,
get_rid_handle/string, get_walk, set_walk and mark_stream_partition_done.

InternedRids no longer carries its own copy of the walk state -- the
runtime owns states[wg][handle]. intern() now reports whether the handle is
new so the caller knows to grow its per-handle vectors, and release() is
idempotent: a double free would push one handle onto the free list twice
and hand it to two requests at once.

add_request needs the workers behind REMOTE worker graphs to build
node_to_workers, which the constructor never saw -- it only takes this
worker's graphs. set_remote_worker_graphs takes the conductor's global
maps; the wg_ids already present are skipped.

Tests cover the recycling hazard (a leaked handle-keyed entry reads as one
request inheriting another's state, not as staleness), set_walk's
transition detection and cache refresh, and label_context_gt including the
same-forward-pass case where it must fall through to the enclosing loop
indices rather than short-circuit.

Co-Authored-By: Claude <noreply@anthropic.com>
ShardingConfig splits into ShardingTemplate (the deployment-wide config the
conductor hands over) and ShardMap (one request's resolved binding).
instantiate() is clone_empty() followed by setup(node_to_workers) in one
step, and the result hangs off RequestInfo.

Per request because a data-parallel replica puts the same node on different
workers, so node -> workers is not deployment-wide. The prototype's single
global ShardMap would have routed every replica to the first one's workers.

Keys become (node, Option<walk>) rather than node alone. The None entry is
Python's streaming lookup: a streaming consumer's walk is not known when
the edge is routed, so setup adds it for any group that spans all walks,
and for a singleton whose node has exactly one worker-set combination.

Both halves of setup are ported: configured groups claim their nodes first,
then whatever is left becomes a singleton per (node, worker set) at
tp_size 1 -- so several walks sharing a worker set share one group rather
than getting one each.

The two asserts become a ShardError that add_request surfaces as a
ValueError: a worker count disagreeing with tp_size, and two groups both
claiming a node with graph_walks=None. Both are deployment config errors,
worth failing loudly rather than routing around.

Tests cover the streaming-None entry appearing and NOT appearing (two walk
combinations make it ambiguous, which is why Python omits it), the
singleton fallback sharing a group across walks, and two requests binding
the same node to different workers -- the case the whole change exists for.

Co-Authored-By: Claude <noreply@anthropic.com>
…uction

The constructor was synthesising a single sharding group from `workers`,
which was a prototype stand-in: it had no shard_dim, no per-group nodes or
graph walks, and no tp/sp_enabled_nodes. ShardingArg now carries all of it
and `workers` is gone -- group membership comes from the config, and the
conductor already sets each group's tp_rank per worker.

shard_dim's Python type is `dict[str, int | None]`, but a None value reads
exactly like an absent key (both mean replicated), so the Nones are dropped
on the way in rather than carrying an Option nothing distinguishes.

tp_enabled_nodes / sp_enabled_nodes gate config validation only -- they add
no shard_dim entries and do not affect routing -- but the type now carries
them, so it is the whole ShardingConfig rather than the part routing reads.

Remote worker graphs move into the constructor and
set_remote_worker_graphs is deleted; add_request cannot build
node_to_workers correctly without them, so a separate call was a
sequencing trap.

The new test covers shard_dim reaching fanout, because losing it does not
fail: it silently takes the replicated branch and sends whole tensors where
slices were meant.

Co-Authored-By: Claude <noreply@anthropic.com>
Fills in tensors.rs: descriptors plus the full reference surface
(put/update/get info, ref counts, persist, mem_registered, can_gc,
collectable) and the batch forms.

TensorPointerInfo's strings are interned. dtype, source_entity,
source_session_id and shm_segment come from a handful of values but repeat
on every tensor, so they are ids inside and strings only at the boundary.
dtype is the wire codec's short name ("float16"), so Python converts with
the _dtype_name / _dtype_from_name it already has rather than a second
mapping.

Semantics kept from Python: mutating an untracked uuid is a no-op (a late
TENSOR_RECEIVED for an already-collected tensor is benign), put_tensor
RESETS reference state (a live uuid means a new tensor; update_info is the
edit), increment_ref rejects a negative n as Python asserts, and
dereference accepts one because set_output_ref_counts corrects downward
that way.

get_info returns a #[pyclass] rather than a tuple: pyo3 only converts
tuples up to 12 elements and this has 15, and named fields make the
reconstruction on the Python side obvious.

--- build config ---

extension-module becomes a default feature instead of being unconditional.
A Python extension must NOT link libpython (symbols resolve at import),
but a test binary is an executable and has to, so `cargo test` could not
link once #[pyclass] pulled in more of the C API. Tests now run with
`cargo test --no-default-features`; the cdylib is unchanged.

--- python ---

PythonGraphRuntime.queues existed to share the dict with the manager, which
no longer holds one. No production caller remained; the tests reach
_queues directly, as they already do for the other internals.

Co-Authored-By: Claude <noreply@anthropic.com>
The batch signatures take two parallel lists instead of a ParallelList,
which is what actually crosses the boundary -- pyo3 converts a list of ints
directly, where a NamedTuple needs unpacking per call.

RustTensorBookkeeping is a drop-in for PythonTensorBookkeeping, so
TensorStore does not know which it holds. Descriptors convert at the seam:
dtype uses the wire codec's short name, so there is one torch.dtype mapping
rather than two.

One behavioural difference is documented on the class: Rust COPIES a
descriptor in, where the Python backend keeps the caller's object. Anything
relying on seeing a later in-place edit (register_for_send stamping
shm_segment) has to re-update_info. Nothing does today -- the Python path
reads the store's copy -- but it would fail as a wrong address rather than
an exception, so it is worth stating.

test_rust_tensor_bookkeeping runs every case against BOTH backends, since a
divergence is silent: a refcount that drops early frees a tensor a peer is
still reading, one that never drops leaks until the request ends. Verified
the parity actually bites -- dropping put_tensor's reset fails
test_put_tensor_resets_reference_state on the rust backend only.

--- two bugs in the new set_speculatively_scheduled ---

It looked up the node with interner.get(), which is the STRING id, and
passed it where a node index within the worker graph was wanted. Both are
u32, so only the Option in the signature made the mix-up visible; had it
been unwrapped it would have flagged whichever node happened to sit at that
index. Now uses nid(wg, name).

It also indexed states[] with the wire worker-graph id, which is the
conductor's dense index over the deployment, not this worker's local
position. Added wg_index() to map between them.

Co-Authored-By: Claude <noreply@anthropic.com>
GraphRuntime takes a share of the TensorBookkeeping at construction, as
asked. TensorBookkeeping splits into the state (Bookkeeping) and the
#[pyclass] handle, both holding Arc<Mutex<_>>, so GraphRuntime gets the
SAME bookkeeper Python handed TensorStore. A copy would diverge the moment
either side moved a refcount, and nothing would raise.

Rust gains the structural lookups (get_worker_graph_id_for_node,
is_async_schedulable, get_output_signals, get_consumed_edges), the pending
loop stops and push_back_node. node_owner was being built in the
constructor and discarded; it is kept now. GraphRuntime is also registered
in lib.rs -- it was not, so it was unreachable from Python.

graph/runtime/rust.py is the Python side: the compile seam
(worker_graph_args / sharding_args) plus the ABC over mstar_rust. The 15
unported methods are bound in the CLASS BODY rather than setattr'd, because
ABCMeta freezes __abstractmethods__ at class creation and a later
assignment leaves the class abstract; each raises naming itself.

MSTAR_RUST_GRAPH=0|1 selects the runtime. No AUTO: the Rust one cannot run
a forward pass yet, so falling back silently would hide that and picking it
up silently would break a worker that merely has the extension installed.
It requires MSTAR_RUST_ZMQ != 0 and a RustZMQCommunicator, since the
runtime sends from Rust over a shared transport, and a Rust bookkeeper,
since it holds a share of it -- each checked with its own message.

Tests cover the compile seam specifically: a node compiling into the wrong
worker graph routes its outputs to the wrong peer, silently.

Co-Authored-By: Claude <noreply@anthropic.com>
…rship

Your three corrections, plus one the second turned up.

_managing_registry is only populated by a WorkerGraphIO, which the worker
builds per request -- so compiling straight off worker_graph.section saw
None every time and every loop crossed as top-level, accounting a nested
loop's completion to the wrong registry. The seam now builds one for that
side effect, over a deepcopy: WorkerGraphIO writes the registry into the
sections it walks, and the pristine section is shared by every request.

accumulated_outputs is a real Loop field, so it is read directly rather
than through a getattr default that would have silently sent nothing.

leader_nodes is gone from WorkerGraphArg and compile_one. Leadership is per
worker, not per worker graph, and already arrives via set_node_metadata;
NodeSpec.is_leader was written and never read, so it went too.

--- found while fixing the second ---

streaming_inputs was read off GraphNode, but the field there is
_streaming_inputs; the public name belongs to ReadySignals. The getattr
default meant every streaming node crossed with an empty set, losing its
ready-for-streaming seed. It is populated by _register_streaming during
worker-graph construction, so it is set by the time we compile.

The getattr defaults were the common cause in all three: each turned a
wrong attribute name into a plausible empty value instead of an error.
Direct access now, so the next one raises.

Tests pin the loop nesting, that compiling leaves the shared section
untouched, and that streaming inputs cross. Verified the nesting test fails
without the WorkerGraphIO.

Co-Authored-By: Claude <noreply@anthropic.com>
Offers each signal to the live worker graphs until one claims it -- a claim
loop, not a lookup, because a node can refuse a signal it owns when the name
is not one of its inputs or both ready slots are full.

Descriptors resolve once per signal through the shared bookkeeper, not per
worker graph. A uuid with no descriptor keeps its identity and zeroes the
shape: the uuid is what routing keys on, and Python tolerates the same case
(get_info returning None inside a tensor_info list).

The streaming gate is re-checked per signal, since ingesting one can be what
makes the next node eligible -- the contract calls this out and a hoisted
check would drop the second chunk of a pair.

Adds RequestState::is_ready_for_streaming and Bookkeeping::tensor_ref.

Tests cover the refusals specifically, since each returns an index rather
than raising: unknown node, an input the node does not take, both slots
full, can_buffer=False, an unknown rid, and the streaming gate.

Co-Authored-By: Claude <noreply@anthropic.com>
get_dynamic_loop_iters, cleanup_consumed_inputs and reset_outputs.

cleanup_consumed_inputs releases the just-executed node's inputs and
dereferences them in the bookkeeper the store shares. A loop's external
inputs are EXCLUDED: they are re-injected each iteration (Python's
_persist_for_loop), and clearing them would strand the loop waiting for a
signal nobody resends. That exclusion is structural -- from the spec's
external_inputs -- rather than a per-request flag, which is why Rust can
derive it without tracking the flag Python sets during ingest.

reset_outputs is a no-op by construction and says so: Python clears
tensor_info off per-request edge objects that carry it, where here outputs
are passed into complete_and_route_batch, so there is nothing stale.

The Bookkeeping operations become pub -- GraphRuntime calls them directly
now, not only through the pyclass.

Co-Authored-By: Claude <noreply@anthropic.com>
get_ready_nodes, has_ready_excluding and pop_rids, over one scan_ready
walker whose callback returns false to stop -- which is what lets the peek
bail on the first match instead of building the whole list, the reason the
contract has both.

Graph level only. Engine readiness stays with the caller, because
_check_ready can FAIL a request, and that is a scheduling decision rather
than a graph one.

pop_rids with check_ready verifies every rid before popping any, so one
not-ready rid leaves the set intact for a later retry rather than half
consuming it. Its input_edges come straight from the slots it just took, so
_build_executing_batch never has to walk the ready signals again.

Co-Authored-By: Claude <noreply@anthropic.com>
NSagan271 and others added 30 commits September 21, 2026 09:28
…ignature

test_runtime_parity drives BOTH runtimes through the same sequences and
compares every answer, rather than asserting hand-written expectations
twice. A divergence here is silent in production -- a request scheduled on
one and not the other, a refcount off by one -- so running one script
against both is the check worth having.

Covers admit/ingest/schedule, the structural lookups, every ingest refusal,
handle recycling, speculation (including is_new_loop_iter both ways),
routing with the safety hold, the prep room cap, and input cleanup.

Also fixes GraphRuntime.add_request's signature: it declared
`worker_graph_to_worker: ParallelList[int, str]` where both implementations
take `worker_graph_to_workers: ParallelList[int, list[str]]`. The name
differs, so a keyword call written against the ABC would have failed.

Co-Authored-By: Claude <noreply@anthropic.com>
A handle can outlive its request -- a message for a rid this rank already
removed is a benign race the Python side has always tolerated with .get().
Five Rust entry points indexed states[wg][rid] directly and panicked ACROSS
the FFI boundary instead: cleanup_consumed_inputs, push_back_node,
set_speculatively_scheduled, prep_spec_rids and prep_follow_spec_rids.

A PanicException is worse than an exception here: the worker's error
handling does not expect it, and unwinding through the boundary is not
something to rely on.

All state access now goes through bounds-checked state() / state_mut(). The
three remaining direct indexes are in add_request and remove_request, where
the vectors were just grown for that handle, and say so.

Found by fuzzing every method with an out-of-range handle; that fuzz is now
a parametrised test over all 19 entry points, so a new one cannot quietly
reintroduce the pattern.

Co-Authored-By: Claude <noreply@anthropic.com>
PythonGraphRuntime.add_request minted a fresh handle on every call. The
conductor sends one NewRequest PER PARTITION, so a three-partition model
(qwen3_omni: Thinker / Talker / Code2Wav) got three handles for one request.

Only the last was reachable through _rid_to_handle, so remove_request freed
only that one. The other two kept their per-request queues alive -- each a
deepcopy of the whole graph section -- with no way left to reach them. Two
leaked graphs per request, growing without bound, plus their tensor
references never dereferenced.

Found by the differential test: Rust returned one handle where Python
returned three. The Rust side already did this correctly.

Co-Authored-By: Claude <noreply@anthropic.com>
RequestState::complete clears a top-level node's ready slot -- matching
Python, which only clears for top-level entities -- but did so without
reporting the uuids, so nothing dereferenced them.

The worker cleans up BEFORE routing, so on that path the slot is already
empty and the counts came out right. The leak only appears if completion
runs first, which made it invisible to every test written in worker order.
Python has no such dependence: mark_entity_complete clears THROUGH the
tensor manager, so the dereference happens either way.

complete now returns what it freed and complete_and_route_batch
dereferences it. The test runs both orders.

Co-Authored-By: Claude <noreply@anthropic.com>
complete_and_route_batch parks the routing for send_outputs to consume. An
exception between the two -- _register_outputs raising, a missing tensor in
the new-token count -- abandons it, in BOTH runtimes.

That is not merely a leak. Handles are recycled, so the parked entry still
names a rid; the next request to get that integer would have another
request's outputs sent under its name. remove_request now drops the rid from
any parked completion and discards one left empty, which is the same rule as
every other handle-keyed map.

Rust gains num_parked_completions so the invariant is observable: a number
that only grows means sends are being abandoned.

Co-Authored-By: Claude <noreply@anthropic.com>
The postprocess prologue took the ref=1 hold one tensor at a time. With a
Rust bookkeeper each of those is a boundary crossing, and a 128-request
batch with two output signals makes 256 of them: 17.6us against 4.7us for
the same work batched, 3.8x. Python is unchanged, having no boundary.

increment_ref_batch existed on TensorBookkeeping but not on TensorStore or
the communication manager, so the call site could not reach it.

Co-Authored-By: Claude <noreply@anthropic.com>
Three problems with the wiring, on top of the one you found.

The factory gated on MSTAR_SHM_ARENA, which selects the tensor TRANSPORT
and has nothing to do with the graph runtime. It is MSTAR_RUST_GRAPH that
needs the Rust bookkeeper: the runtime holds a SHARE of that object, so the
two have to be the same implementation. Nothing else should select it --
a descriptor is copied in and rebuilt out, which the Python runtime pays
for and gains nothing from.

`from mstar_rust import TensorBookkeeping` sat at tensor_store's module
scope, and tensor_store is imported by effectively everything -- so the
whole package became unimportable on any machine that had not run maturin.
Now imported inside RustTensorBookkeeping.__init__, the same pattern
arena.py uses.

The three test files still imported the deleted rust_tensor_store module,
and their importorskip probed the WRAPPER rather than the extension. Since
the wrapper now imports fine either way, that would have failed rather than
skipped where the extension is absent; they probe mstar_rust itself.

test_tensor_store_backend pins all of it, including importing the package
with the extension blocked at the meta-path -- the simulation has to raise
ModuleNotFoundError, since pytest's importorskip deliberately does not skip
on a bare ImportError.

Co-Authored-By: Claude <noreply@anthropic.com>
Staging stamps shm_segment/shm_offset onto the TensorPointerInfo in place.
That reached the store only because the Python bookkeeper hands back the
caller's own object -- the Rust one copies the descriptor in, so the stamp
died on a throwaway rebuild.

The wire descriptor is looked up by uuid, not carried: RouteOutput gives
indices, and rust.py::_infos rebuilds from the bookkeeper. So under
MSTAR_RUST_GRAPH=1 every arena-staged tensor shipped with shm_segment=None,
which the consumer reads as "spilled to a per-uuid file" -- and goes looking
for a file that was never written, because the bytes went to the arena.

Made the stamper's job rather than the caller's; register_for_send has two
callers and neither should have to know. Collected across the batch so it is
one crossing per pass, not one per tensor.

The existing roundtrip test only checked the stamp on the caller's object,
which passes either way. The new one reads it back out of the store, over
both bookkeepers and both register forms; without the writeback the two Rust
cases fail and the two Python ones pass.

Co-Authored-By: Claude <noreply@anthropic.com>
The rid sweep renamed the tensor manager's parameter to `rid`; the preprocess
worker's call sites kept `request_id=`. You caught three. `ack_unread_tensors`
at data_worker.py:462 is the fourth -- the late-tensor ack for a request that
is already gone, so it fires exactly when a request aborts mid-read and the
producer is waiting to free its buffers.

All four are a TypeError the moment a request carries a tensor, and none is
reachable from the unit suite: the preprocess worker runs on its own thread
against a live mesh.

The guard resolves the class behind a named receiver and checks keywords
against the real signature. Scoped to the four rid seams on purpose -- the
first attempt unioned every def by method name, which flags every
`.gather(dim=...)` on a torch tensor because some unrelated mstar method is
also called `gather`. Reverting any of the four fixes fails it.

TensorStore's rid is now typed `int | str`. The preprocess worker never took
the int refactor and does not need to: rid is an opaque dict key here, never
arithmetic, and it does not reach the bookkeeper at all.

Co-Authored-By: Claude <noreply@anthropic.com>
Every worker test injects a runtime directly, so nothing checked that
MSTAR_RUST_GRAPH=1 reaches the Rust one -- which is how the Rust bookkeeper
shipped never being instantiated. A flag that silently does nothing reads
exactly like a flag that works.

Covers both selections, the refusal of anything that is not 0 or 1, all three
guards, and that the runtime holds the SAME bookkeeper object rather than a
rebuilt one.

Co-Authored-By: Claude <noreply@anthropic.com>
…ffers

Two divergences from the Python runtime.

speculate_node filtered on the SOURCE node's enable_async_scheduling. The
caller already filtered the source (worker.py::_can_speculate), so the real
check -- the destination's -- was simply missing, and a node that opted out
got speculated into and then dropped per rid. The parity fixture could not
see it: every node in that graph is async-enabled, so the source-side and
destination-side checks agree on every input it produces. The new fixture
opts the target out; without the fix the Rust case fails and Python passes.

remove_request forwarded to Rust and left four Python-side dicts keyed by the
handle. Handles are recycled, so a request aborted between the route and its
worker-graph completion would hand its persist signals, token counts and
buffered output names to whichever request next drew that integer -- exactly
the hazard base.py documents. _output_loop_indices was never popped at all.

Co-Authored-By: Claude <noreply@anthropic.com>
get_sharding_config was `pass # TODO`, so it returned None and the very first
NEW_REQUEST handed None to register_request -- MSTAR_RUST_GRAPH=1 could not
admit a request.

Derived in Python rather than exposed from Rust. Rust does build its own copy
for routing, but it cannot come back out usefully: register_request wants the
Python ShardingConfig and the four TP fan-out sites read group._workers, so
returning it would mean reconstructing the object across the boundary -- a
SECOND derivation, in a new place, free to drift. Deriving from the same
input by the same rule cannot.

Nothing is replicated: clone_empty()/setup() IS the logic, called once. The
one piece of assembly both runtimes were doing -- building node_to_workers
from the per-worker-graph map -- moved to graph/runtime/sharding.py and
python.py now calls it too.

Parity tests pin that the two agree, that the base config is never handed
back, that removal drops it (handles are recycled), and that an unknown rid
answers None rather than raising -- teardown and TP fan-out race removals.

Still unserved on the Rust path, both needing a design call:
get_nested_loop_idxs_for_node, and peek_completion (the worker reads
GraphEdge objects off it where Rust has only indices).

Co-Authored-By: Claude <noreply@anthropic.com>
Python sweeps all worker graphs the request runs in this walk and resets each
done one; Rust checked only the graph owning the completed node and never
reset anything -- RequestState::reset had zero call sites.

Two consequences. A worker graph that became done without ingesting an edge
in this call was silently dropped: the case Python calls out for Orpheus
prefill, BAGEL vae_decoder and Code2Wav, where the completed node's outputs
all go to EMPTY_DESTINATION / EMIT_TO_CLIENT / a streaming partition. And
`is_done` latched, so root_entity_done early-returned on it, node flags and
loop counters never cleared, and a request reported done exactly once.

A worker graph completes many times over one request, so the second effect is
every request past its first pass.

The parity fixture runs each request through exactly one pass, which is why
it could not see this. The new one is a single node that finishes its graph
in one completion, driven twice; without the reset the second ingest goes
nowhere and the Rust case fails.

Co-Authored-By: Claude <noreply@anthropic.com>
Python settles the refcount over the POST-fanout edges -- to_workers is keyed
by worker, so a tensor read by N workers is counted N times. Rust counted the
pre-fanout list, one entry per graph edge; the per-worker expansion happens
later, in take_send_plan, after the counts are already settled.

So with a TP consumer the hold dropped to 1 while N reads were outstanding,
and the first release freed a tensor the other N-1 were still reading.
ShardMap::fanout had no caller outside its own unit tests.

EMPTY_DESTINATION was counted as 1 in the same loop. Python drops it before
the count -- it routes nowhere -- so that was a reference no one would ever
release.

Every other fixture puts one worker on each node, which makes an edge and a
destination indistinguishable; the new one gives the consumer a real TP group
of two. Without the fix the Rust case frees after the first release.

Co-Authored-By: Claude <noreply@anthropic.com>
…_node

is_first_tp_rank was hardcoded True in the Python shim, so under TP>1 every
rank claimed rank 0 and the conductor counted tp_size reports per request
instead of one. Rust has what it needs -- the per-request ShardMap -- so it
is computed at completion and rides the plan. Computed at COMPLETION, not at
send: the group lookup is per completed node.

get_nested_loop_idxs_for_node was Python-only, an AttributeError in
_postprocess_batch on the first completed batch. Straight port: NodeSpec
carries the innermost enclosing loop, CompiledGraph::loop_order is already
Python's _get_loop_order, and num_times_run is on RequestState. A node
outside every loop gets the pass index and nothing else, as in Python.

Verified against the extension directly (rank 0 -> True, rank 1 -> False;
loop context and all three error paths), because base.py is mid-edit on the
peek_completion change and the Python suite cannot collect. cargo test: 49
passed. Will re-run the Python parity suite once base.py parses.

Co-Authored-By: Claude <noreply@anthropic.com>
…ransport

_count_new_tokens took a NodeOutputRouting per rid. Now it takes the flat
indices, like the stream-buffer path: numel() still needs the tensors, so the
counting stays on this side, but nothing has to carry GraphEdge objects back.

Rust had to learn Python's "don't double-count new tokens" for that to be
equivalent. One output routed to two destinations is two edges carrying the
SAME tensors, so first-edge-of-a-signal-name now wins there; without it the
flat indices name each tensor twice and every token count doubles.

WorkerGraphsDone.output_signal_names was declared `int` and always carried a
list[str]. It survived only because the encoder and decoder for int are both
identity, so the list passed through untouched -- and the conductor grew an
`isinstance(..., list) else []` guard to swallow the bogus 0 default. Both
gone. A Rust encoder written from the annotation would have emitted an int.

The Rust GraphRuntime now takes the communicator at construction, like the
bookkeeper: PyZmqCommunicator holds its RawZmqCommunicator behind an Arc and
hands out a share. No extra lock -- send() takes &self and guards its own
sockets. Until now the struct had no transport at all, so worker.py's gate
refused MSTAR_RUST_ZMQ=0 on the grounds that the runtime "holds an Arc to the
same transport", which was not yet true.

test_api_result_delivery stubbed the tensor manager with the pre-refactor
`request_id` parameter names. It was the unnamed third failure in every run
since the rename; it now passes, leaving only the two known-unrelated ones
(qwen3-tts subprocess dep, renamed RaggedPrefillWrapper attribute).

Co-Authored-By: Claude <noreply@anthropic.com>
A Rust sender can build the frame it owns and splice Python-owned fields in
as opaque values, rather than learning their types. That matters most for
resource_publish_info: PublishedInfo is abstract, so every new resource would
otherwise become a Rust change.

Passing the DECLARED type is what makes the result safe to splice -- it picks
the same encoder _plan would have chosen for that field, so the frame decodes
identically to having built the whole message in Python. Both tests assert
exactly that, against the field as encoded in place.

CurrentForwardPassInfo is registered (`fwd_pass_info`), so this never reaches
the pickle fallback, which a Rust peer could not decode.

Co-Authored-By: Claude <noreply@anthropic.com>
graph/frames.rs encodes the message directly as msgpack, so the highest-volume
frame no longer has to be assembled as Python objects and re-encoded per pass.

What has to match is what the frame DECODES to, not its bytes -- the receiver
runs unpackb and looks fields up by name, so map order and integer width are
free. Every test asserts wire.decode(rust_frame) == the message Python would
have sent, decoded.

Comparing against Python's DECODED message, not the object handed in: the
codec is lossy in small real ways. stride is declared list[int] and every
caller passes a tuple, so a round trip returns a list -- comparing to the
pre-encode object fails Python against itself.

Fields whose types belong to Python arrive already encoded and are spliced in
untouched: resource_publish_info (PublishedInfo is abstract, so owning it here
would mean a Rust change per resource) and the profiling trio, which today's
shim decodes only for the outgoing frame to re-encode. rmpv is the vehicle --
re-serialising a generic value is what makes the splice exact.

Rust resolves persist descriptors through the bookkeeper it already shares.
That needs care: a descriptor's symbols are interned in the BOOKKEEPER's
table, not the runtime's, and mixing them up would silently swap one string
for another. There is a test for it, and for the omission rule -- shm_segment
and the two _source_* fields have defaults, so an explicit nil would decode as
a segment literally named nil rather than "spilled to a file".

Sends through the shared transport when one was given; returns the bytes
either way, which is what makes it testable without a socket.

Not yet called from send_outputs -- the shim still builds this message. That
swap is next, along with InputSignals and StopLoops.

Co-Authored-By: Claude <noreply@anthropic.com>
INPUT_SIGNALS to peer workers and RESULT_TENSORS to the api server join
WORKER_GRAPHS_DONE in graph/frames.rs, so send_outputs is now a wrapper that
prepares only the Python-owned payloads and hands off.

The four per-request buffers moved to RequestInfo.pending. On the request
rather than in a side map keyed by handle, deliberately: handles are recycled,
and a side map that outlives its request hands one request's persist signals
and token counts to whichever request draws that integer next. I patched that
leak with explicit pops two commits ago; now remove_request frees them with
everything else and the shape cannot come back.

CurrentForwardPassInfo is encoded once per request, not per pass -- the worker
mutates it exactly once, at admit (rid_handle), and re-sends the same object
every pass. resource_publish_info is read back out of that same blob by key
rather than encoded again: it is a FIELD of the object Python already sends.
Getting that wrong spliced the whole fwd_info into resource_publish_info,
which surfaced as `unknown wire tag 'r'` -- a two-character string unpacked as
a [tag, payload] pair.

Profiling was PICKLED. wire.encode([rx, tx, timings]) takes a bare list, which
has no wire tag, so it hit the OPAQUE fallback -- undecodable by a Rust peer
and unspliceable into a frame Rust builds, while base.py described it as
msgpack. encode_fields encodes each by its declared type; Rust splits the blob
into the three fields, and a malformed one yields nothing rather than taking
the send down, since profiling is diagnostic.

The tests now drive the real path end to end: a completion is routed,
send_outputs pushes every frame down the SHARED transport, and the test reads
them off a peer's inbox. Nothing is inspected before it has travelled. A bare
drain() right after the send races zmq delivery and returns nothing, hence the
blocking first read.

Co-Authored-By: Claude <noreply@anthropic.com>
The last message type. stop_loops_batched no longer returns a fanout for
Python to turn into frames -- it stops the loops, decides who runs them, and
sends.

loop_stop_times is snapshotted once before the sends rather than read per
peer. stop_loops_for_rid has already recorded this stop by then, and every
peer has to see the same observation or they cannot agree on which stop is
newer.

Still never to ourselves: this rank originated the stop and has applied it,
and a self-send would land in apply_peer_loop_stops and stop the loops a
second time. There is a test for that, and for a loop no peer runs sending
nothing at all.

_loop_stop_times stays on the Python side -- no longer on a send path, but
three test files read it to inspect what a rank observed.

Co-Authored-By: Claude <noreply@anthropic.com>
A stalled pipeline looks the same from outside whether the graph never
became ready, the scheduler filtered the node out, or the engine keeps
saying not-ready. The probe walks those gates in order and names the first
one that drops everything.

Off unless MSTAR_STALL_PROBE is set to a seconds interval.

Co-Authored-By: Claude <noreply@anthropic.com>
Three paths hand back empty outputs for a whole batch without a word: admit
refusing the step, _drive_step returning no raw outputs, and _merge_per_rid
finding no key for any rid.

Downstream cannot tell any of them from a node that genuinely emitted
nothing. Routing still runs, every edge carries no tensors, nothing becomes
ready, and the pipeline stalls with no error anywhere -- which is exactly the
shape of the Orpheus/Bagel stall.

The merge case is worth naming precisely: out_ids are the worker's integer
rid handles, so a submodule that keyed its output dict by the request id
STRING misses every single one. That is the rid refactor's failure mode, and
silence made it indistinguishable from an empty node.

Only fires when ALL rids miss, so a genuinely partial batch stays quiet.

Co-Authored-By: Claude <noreply@anthropic.com>
The stall shows up as RouteInput(tensors=[], num_tensors=[0,...]): the node
runs, every edge carries nothing, nothing downstream becomes ready, and no
error is raised anywhere.

Instrumented at the point the emptiness is observable rather than upstream,
since the three engine paths that return empty outputs all stayed quiet. Two
distinct causes, told apart:

  - the rid is absent from the engine's dict, which is a KEY-TYPE mismatch
    (these are integer handles, and a submodule keyed by the request id
    string misses every one)
  - the rid is present and the node emitted names no edge carries, so the
    tensors are filed under keys the lookup never asks for

Co-Authored-By: Claude <noreply@anthropic.com>
The all-missed check compared against len(out_ids), but out_ids is the PADDED
id list -- so with any padding the count can never reach it and the warning
never fires. That is exactly what happened: the batch came through with
"present but empty" outputs and this said nothing.

Compares against the number of pairs actually zipped now, and adds the case
the stall turned out to be: every rid matched a key, and every one of them
mapped to an empty dict. That distinguishes "the forward emitted nothing"
from "we looked under the wrong keys", which is the whole point of the
warning.

Co-Authored-By: Claude <noreply@anthropic.com>
A partition only moves to the next walk once every worker graph of the
current one has reported from every rank. Stuck there, the request simply
stops: the walk never changes, no new inputs go out, and nothing raises --
which is the shape of the Bagel/Orpheus stall.

Names the worker graphs still outstanding, how many ranks reported for each,
and how many were expected. Quiet for a single-graph partition unless
something is actually wrong.

Co-Authored-By: Claude <noreply@anthropic.com>
…match (buggy?) python behavior in streaming_ready (marked FIXME)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants