Skip to content

Pure-Rust walk layer: MSTAR_RUST_WALK=pure (RFC #130, Step-5 endgame) - #171

Draft
npuichigo wants to merge 35 commits into
mstar-project:mainfrom
npuichigo:rust-walk-pure
Draft

Pure-Rust walk layer: MSTAR_RUST_WALK=pure (RFC #130, Step-5 endgame)#171
npuichigo wants to merge 35 commits into
mstar-project:mainfrom
npuichigo:rust-walk-pure

Conversation

@npuichigo

@npuichigo npuichigo commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Pure-Rust walk layer: MSTAR_RUST_WALK=pure (RFC #130, Step-5 endgame)

Stacked on #164#168#169#170 — merge those first; the pure-mode changes are the final commits. Draft, and explicitly a proposal: this is the mode where the Python WorkerGraphIO no longer runs at all.

PureRustWorkerGraphIO makes the Rust WalkState the only walk state machine. The adapter keeps just two things (the value-map pattern):

  • uuid → GraphEdge store: every ingested/produced edge gets a uuid via the new seed_with/complete_with bindings; route events return the captured uuids, so loop outputs are reconstructed with real tensor_info at termination (the worker fills descriptors post-execution; mutation is visible through the store).
  • Node views satisfying the worker's execution reads (ready_signals.ready_inputs, enable_async_scheduling): per-(node, input) FIFOs are filled at schedule time, and FIFO order matches iteration order because completion re-emits loop externals exactly as the Python registries do (driven by loop_states deltas: terminated → loop outputs + filtered loop-backs; advanced → re-emitted externals).

Deliberately not modeled yet (auto-handled, not broken): graphs with stream consumers fall back to authority mode (=1) with a logged note; speculation returns no candidates in pure mode — that surface should come out of the async-scheduling design conversation, and mstar-rs's speculate/claim implementation is the ready-made reference when it does.

The ladder is now complete: 0shadow (mirror + compare) → 1 (Rust decides, Python values, per-request fallback) → pure (no Python registries). Each rung is verified by the previous one's clean logs.

🤖 Generated with Claude Code

Performance (in-repo A/B, test/rust/bench_walk_ab.py, interleaved; CI runs it informationally)

per-request setup per completion step
Python registries ~265 µs (deepcopy of the section tree) 13.3 µs
Pure-Rust adapter ~13 µs (20×) 14.9 µs (parity)
minimal uuid protocol (the post-worker-contract boundary) ~7 µs 3.8 µs (3.5×)

So pure mode is net-faster at every walk length today (setup win + per-step parity), and the third row shows where it lands once the worker contract stops requiring GraphEdge impersonation. Getting here surfaced four state-accumulation bugs (exponential re-ingest, two quadratic re-seeds, an aliased-list grow) — all caught by the bench and covered by the parity suite, which is a good argument for keeping the A/B in CI.

Replaces the per-tensor file open/write/read/unlink in
SharedMemoryCommunicationManager with a Rust segmented /dev/shm arena
(rust/src/shm.rs: persistent mmaps + first-fit allocator, buffer-protocol
views for zero-copy staging).

- Producer reserves (segment, offset) per tensor and D2H-copies straight
  into the mapped segment on the dedicated copy stream; one stream sync
  covers the batch before the control message ships.
- The location rides the existing descriptors (TensorPointerInfo grows
  optional shm_segment/shm_offset) — no wire-shape change.
- Consumer opens the named segment once, views the bytes zero-copy, and
  H2D-copies on its dedicated stream; the stream is synchronized before
  ACKing, since the producer reclaims the slot on ACK (the file
  transport's f.read() made that implicit).
- Pinning: each mapped segment is cudaHostRegister-ed once per process,
  both sides (MSTAR_SHM_ARENA_PIN, default on) — copies through the side
  streams then run at page-locked bandwidth and stay asynchronous, which
  is what preserves the copy/compute overlap the side streams exist for.
  Measured on the standalone bench: 256 MiB x20 D2H = 466 ms pageable
  mmap vs 187 ms registered (== torch pinned); registration is a one-time
  ~21 ms per 256 MiB segment.
- Capacity: the arena grows by segments up to
  MSTAR_SHM_ARENA_MAX_SEGMENTS (mappings never move — registrations and
  open consumer views stay valid; oversized tensors get a dedicated
  segment). At the cap, sends backpressure until consumers ACK, failing
  loudly after MSTAR_SHM_ARENA_FULL_TIMEOUT_S.
- Reclaim is uuid-keyed on the sender (cleanup maps uuid -> free), same
  lifecycle as the file transport's unlink.
- Selection: MSTAR_SHM_ARENA = 0 (default, files) / 1 / AUTO on the SHM
  protocol, mirroring MSTAR_RUST_ZMQ; documented in
  docs/environment_variables.rst.

Tests: cargo tests for the arena/allocator; pytest covers the
producer->consumer roundtrip through two managers, descriptor stamping,
uuid reclaim, growth + backpressure-then-fail, and factory selection.
The edge's H2D copies must complete before the ACK lets the producer
reclaim the slot. A blocking h2d-stream synchronize enforced that on the
host thread — serializing the transport tick on the slowest copy, which
compounds under concurrency. get_ready_tensors already polls a future
per pending edge, so the constraint moves onto the device timeline: one
CUDA event covers the batch (all copies queue on the h2d stream in
program order) and the edge reports ready when the event fires. No host
block; the wait_stream ordering for downstream kernels is unchanged.

The event path needs CUDA, so it rides the same c16-32 gate run as the
pinning claim; on CPU (and in CI) edges stay future=None as before.
An arena consumer already rejected file-producer descriptors explicitly;
the reverse (file consumer, arena producer) surfaced as a bare
FileNotFoundError on a path that never existed. Both directions now
raise the same explicit message: MSTAR_SHM_ARENA must match across the
deployment. Test covers both.
npuichigo added 29 commits July 26, 2026 00:42
Two saturation corners the spill tests didn't pin down: (1) a single
edge mixing arena-staged and spilled tensors — the consumer dispatches
per descriptor, one start_read_tensors call reads both; (2) the
fragmentation warning itself, constructed deterministically: fill to
the cap, free alternate slots (total free ample, largest block small),
then a reserve that fits the total but no block — asserts the warning
fires and the tensor spills while a small tensor still lands in a hole.
The lib-target comment explained the rlib in terms of specific future
consumers, and the crate docs described the crate as exactly its first
two modules. Both now say what is structurally true: one crate of
independent, individually opt-in components with a Python surface and a
plain-library form — new capabilities land as new modules.
…tats

- SegmentedShmArena gets interior mutability (segments behind a Mutex),
  so reserve takes &self: the binding releases the GIL across the
  growth path, and a &mut PyO3 borrow held there made any concurrent
  &self call (stats, free, free_uuid) raise "Already mutably borrowed".
  Growth now serializes on the segments lock instead of the PyCell
  borrow.
- cudaHostRegister goes through ctypes, which releases the GIL for the
  duration — registering a 256 MiB segment on the send path no longer
  stalls the process's other Python threads once per growth (the torch
  cudart binding holds the GIL). Torch-binding fallback kept for
  environments without a loadable libcudart.
- _CudaEventFuture uses Event(blocking=True): the default busy-waits,
  so the watcher thread turning events into wake futures burned a full
  core per wait.
- _wake_when_done guards its queue/thread creation with a lock (two
  concurrent first-callers could create two watchers and lose wakes).
- Oversized-segment pinning REVERSED per review: dedicated segments are
  reused for later large tensors, so an unpinned one degrades every
  subsequent transfer through it — they now pin like any segment,
  within the budget.
- Manager stats() renamed stats_summary() (the raw arena tuple keeps
  stats()), and under --log-stats the producer path logs the snapshot
  at most once per MSTAR_SHM_ARENA_STATS_INTERVAL_S (default 60 s), so
  a long soak leaves an occupancy/fragmentation time series in the
  logs.
Ceilings are per-entity and multiply across a node (every entity —
workers + the api-server data worker — creates its own arena, and a
consumer pins peer segments beyond its own): construction now fails
fast with the sizing formula when ONE entity's
MAX_SEGMENTS x SEGMENT_MB already exceeds /dev/shm (statvfs), warns
when it exceeds current free space, and warns when the per-process pin
budget is an outsized share of physical RAM. Both x(num entities)
formulas documented in the module docstring and env-vars table.

Rebased on main: register_for_send now receives TensorPointerInfos
directly; the arena override stamps the passed info as well as the
store-tracked ones, and the tests call with infos.
… out

Deep-review fixes (verified by reviewer across 7 models x 9 configs with
bitwise gates):

- Segment base names are now instance-unique
  (mstar_arena_{entity}_{pid}_{token}): a fixed per-entity name in the
  global /dev/shm namespace let a second server's create() truncate the
  first's LIVE segments (silent cross-server corruption, observed on a
  shared cluster) and made cross-user startups die on Permission
  denied. Wire-compatible: consumers open whatever name the descriptor
  carries. Test: two same-entity-id managers coexist and the first's
  staged data survives the second's creation.
- Startup sweep of orphaned segments: SIGKILL never runs Drop, leaving
  up to a full arena per kill until reboot. The pid embedded in the new
  names gives the sweep its liveness check (/proc/<pid>); files it
  cannot judge or cannot remove are left with a debug note. Test: a
  dead-owner file is reclaimed, a live-owner file is untouched.
- Spill grace defaults to 0: on a worker, the TENSOR_RECEIVED ACKs that
  free slots are processed by the same thread that would sit in the
  grace wait, so waiting was pure dead time per at-cap tensor (and
  strict mode a guaranteed stall-then-fail). Docs now state plainly
  that strict backpressure is only meaningful where another thread
  drains ACKs (the threaded api-server).
- The uuid-ledger arena API (reserve_for/free_uuid) had no Python
  users — removed along with its bindings; the module docs no longer
  describe a reclaim flow this PR does not implement. The
  _infos_by_uuid side-table is gone too (register_for_send stamps the
  infos it is passed).
… a leak)

Self-review follow-up: the consumer's peer-segment cache never evicted,
and the instance-unique naming fix turned that from stale-reuse into an
unbounded leak — every producer restart mints NEW segment names, so a
long-lived consumer mapped and pinned each generation while the old
unlinked-but-mapped segments' memory (and registered pinned bytes)
stayed resident forever.

Cached entries now record their pinned size and are evicted once the
backing /dev/shm file is gone (producer finished or restarted):
cudaHostUnregister via ctypes (GIL released), mapping dropped, pinned
accounting decremented. Eviction is gated on `pending` being empty —
an mmap must outlive any in-flight h2d copy reading from it, and the
pending futures are exactly those copies — and time-gated off the hot
path. Test: a consumer's cache entry disappears after its producer
cleans up and exits.
… gate

From a two-lens self-audit (concurrency; lifecycle/protocol) ahead of
the soak:

- Pin accounting was a torn read-modify-write: _pin releases the GIL
  (ctypes), so concurrent pins corrupted _pinned_bytes, and _peer_view's
  before/after delta could attribute another thread's bytes to the wrong
  segment (mis-eviction later). One _pin_lock now makes budget check +
  register + accounting atomic, and closes the check-then-insert race
  that let two threads map+pin the same peer segment (loser leaked).
- Eviction is now safe under threaded reads: gated on being the sole
  active reader (a second thread may have queued an async H2D whose
  future has not reached `pending` yet), and unregisters BEFORE
  unmapping — keeping the entry on unregister failure so accounting
  stays truthful.
- register_for_send can no longer orphan slots: an exception between
  reserve and the _arena_locs record frees the slot on unwind, and a
  concurrent-duplicate registration returns its slot instead of leaking
  it. The D2H copy falls back to blocking when no copy stream exists,
  so a descriptor can never ship ahead of its bytes.
- The wake watcher survives a cancelled/already-resolved future (its
  death silently downgraded every later wake to the poll tick), closes
  over only its queue (a bound method pinned the whole manager — and
  the arena's segments — for the daemon's lifetime), and close() stops
  it via sentinel.
- Double-ACK gate: a re-surfaced (retried) edge no longer re-ACKs —
  the double-decrement could free a slot while another consumer in the
  fanout still held the descriptor (silent wrong-bytes on the arena;
  loud failure on the file transport).
- Sibling arena bindings (free/stats/num_segments) release the GIL: a
  growth holds the segments mutex across an mmap, and blocking on it
  with the GIL held froze every Python thread for the duration.
- Growth warns when this entity's real segment bytes exceed 80% of
  /dev/shm (oversized dedicated segments grow past the static ceiling),
  and the periodic stats line carries live_slots/spill_files — the
  leak canary for deferred reclaim (aborts without ACKs) during soaks.
A request aborted after staging but before every consumer ACKs defers
reclaim forever (cleanup_request waits for ACKs that will never come).
MSTAR_SHM_ARENA_SLOT_TTL_S (default 0 = off) adds a bounded backstop:
slots older than the TTL are force-freed, with a loud warning carrying
the running total. Safety argument: a slot older than the REQUEST
timeout cannot have a legitimate reader — the request is dead by the
system's own contract — so a bound safely above it (recommend >= 2x the
request timeout) cannot race a real consumer. Reclaims run under
capacity pressure (retrying the reserve before spilling) and with the
periodic stats sweep.

Left off by default pending review discussion; precedent across the
ecosystem: vllm-omni's RDMA sender uses a 300 s TTL sweep as its abort
backstop (with an acknowledged TTL-vs-in-flight caveat), sglang's omni
pipeline a 600 s central watermark. The stronger long-term fix is
event-driven reclaim wired to the abort control message (the shape
sglang's LLM disaggregation uses), which touches shared
cleanup_request semantics and deserves its own change.
The removal of the stale conductor-driven-reclaim description cut
mid-sentence; the header now states the actual contract (embedder frees
on consumer ACKs), matching the SegmentedShmArena docstring.
Soak finding 1 (one leaked slot per completed request on the LLM
worker): my double-ACK gate keyed suppression by uuid alone, so a
tensor legitimately consumed by TWO nodes with staggered readiness had
its second reference's ACK suppressed — the producer's fanout refcount
(which correctly counted both) never reached zero. The gate now keys by
(uuid, edge name, destination node): a re-DELIVERED edge repeats its
triple and stays suppressed (the corruption the gate exists for), while
a distinct consumer of the same uuid ACKs normally; partial edges ACK
only their fresh infos, and the per-request triple set is dropped with
the request. Test drives both cases through the real ACK plumbing.

Soak finding 2 (worker segments survive graceful shutdown): workers
exit with the manager still referenced — no cleanup path runs, the
interpreter never collects it, and the Rust Drop that unlinks never
fires (the api entity has an explicit cleanup, which is why only its
segments disappeared). A weakref.finalize callback — capturing only
the mutable segment-path list, never the manager — now unlinks at
interpreter shutdown regardless. Test proves it from a subprocess that
exits holding a global reference to the manager.
Root cause of the soak's one-slot-per-request leak, and it is neither
the abort deferral nor the ACK gate — it is pre-existing shared
bookkeeping that the arena's live_slots canary made visible for the
first time:

`set_output_ref_counts` counts `routing.persist` in the fanout, so a
persisted tensor carries one reference PER PERSIST EDGE on top of its
persist flag. Nothing ever released those references. Clearing the flag
(unpersist, or request cleanup) left the count above zero, so `can_gc`
stayed false and the tensor was never collectable — `cleanup_request`
logged "Deferring cleanup ... awaiting TENSOR_RECEIVED ACK" and never
revisited. BAGEL's end-of-prefill token is both emitted and persisted,
which is exactly one leaked slot per request. The file transport leaks
the same way (a per-uuid /dev/shm file per persisted tensor), which is
likely the manual cleanup maintainers have been doing for a while.

Fix: the manager tracks the persist references it holds (one per
`set_persist(True)` — the same edges the fanout counted) and releases
them when the flag clears, in step with the flag that was set alongside
them. `cleanup_request` now clears via `set_persist` for the same
reason. Regression tests cover both the request-end path and the
unpersist-then-consume re-route path.

Second finding (worker segments surviving shutdown): SIGTERM — what
`Conductor.shutdown`'s `p.terminate()` sends — defaults to immediate
death, so no unwinding, no atexit, no finalizers, and the segments were
never unlinked. The main process gets this for free from SIGINT ->
KeyboardInterrupt, which is why only the workers leaked. The worker
target now turns SIGTERM into SystemExit, and shutdown escalates to
SIGKILL after the join window so a worker stuck in a C call cannot hang
teardown (its segments are then reclaimed by the next start's sweep).

Also: document the ACK gate's known limitation (a graph routing the
same tensor to the same node under the same edge name twice in one
request is indistinguishable from a re-delivery), and `ruff --fix`.

test/modular failure set is byte-identical to upstream/main before and
after (45 pre-existing GPU-dependent failures on this box); arena suite
15/15.
…guard

@NSagan271's mstar-project#183 is the canonical fix for the persisted-tensor leak,
in the conductor/worker domain: it stops counting routing.persist in
set_output_ref_counts at the source, leaving the persist flag + the
conductor's unpersist-with-K accounting to balance the references.

My previous persist-hold mechanism here (tracking references per
set_persist and releasing them on flag clear) was a second, redundant
fix for the same bug — and once mstar-project#183 removes the count, it would
over-dereference and free persisted tensors early. Reverted, so mstar-project#168
makes no changes to the shared persist / ref-count semantics and
rebases cleanly onto mstar-project#183.

Also dropping the double-ACK guard from the self-audit: as NSagan271
noted, a persisted tensor legitimately re-sent to the same node across
graph walks looks identical to a re-delivery, so the guard could
suppress a real ACK and undercount against the conductor's K — exactly
what mstar-project#183's accounting must not lose. Removing it restores the
pre-audit ACK behavior the conductor logic is designed around.

Kept: the worker SIGTERM -> SystemExit graceful-exit handler and the
SIGKILL-after-join escalation in Conductor.shutdown (matches the code
NSagan271 verified), which is what lets the arena's exit finalizer run
and unlink worker segments. The three tests tied to the reverted
shared code are removed with it; arena suite green.
rust/src/core/ is the compiled-walk graph and the per-request walk state
machine — the Rust port of the runtime behavior of GraphNode/Loop
registries and WorkerGraphIO: input readiness, completion routing
(emit/persist/internal), loop iteration and termination (max_iters and
external stop signals), nested loops (an inner loop is an entity of its
parent's iteration; termination clears the body subtree while the loop's
own counter persists until a parent advance), and external-input
re-injection per iteration.

- mstar/graph/rust_core.py translates GraphSection trees into the core's
  JSON spec (including the sentinel-destination mapping: mstar's
  'emit_to_client'/'' vs the core's names).
- PyO3 surface: WalkSet.from_json / WalkState (seed, ready_nodes,
  schedule, complete, signal_loop_finish, loop_iters, is_done). Tensor
  payloads stay on the Python data plane; values are opaque uuids here.
- test/rust/test_walk_parity.py drives BOTH implementations with
  identical event sequences over sequential, parallel fan-out/join,
  loop-to-max-iters, loop-early-stop, and nested-loop graphs, asserting
  identical ready sets, doneness, and loop counters at EVERY step.

Scope: the state machine and its translation seam only — streaming
buffer semantics and the conductor/worker wiring stay in Python, to be
adopted per the staged plan once this core is reviewed.
_select_node_priority returned the scan's LAST-iterated node_name instead
of best_node_name, paired with best_node_name's walk — with more than one
distinct node ready, priority mode produced a (node, walk) combination
with no matching entries and scheduled nothing. Unnoticed because the
default scheduling type is round-robin. Found by the Rust scheduler's
parity suite (which asserts both implementations' decisions match).
rust/src/core/sched.rs ports mstar/worker/micro_scheduler.py's decision
logic. The seam is decide-vs-mutate: the caller hands a snapshot of
ready work — (node, walk, rid, worker_graph, engine_ready, priority,
leader) tuples, engine-level checks pre-evaluated since engines stay
Python — plus an explicit monotonic time; the scheduler returns the
batch to run, and queue pops/execution stay with the worker. Time is
always passed in, never read: decisions are deterministic and testable.

Ported semantics: round-robin by least-recent (node, walk); priority
mode (lowest engine priority, then the walk with the most requests);
OOM hold-with-backoff; deferred removes; leader-node filtering;
target/exclude filters; max-batch truncation; TP-follower replay first
with the consecutive-batch fairness cap; has_ready_excluding.

test/rust/test_sched_parity.py drives BOTH schedulers with identical
ready-work and event sequences (rotation, priority + biggest-walk,
engine-not-ready, hold expiry, truncation/exclude, TP-follow order +
fairness), asserting identical decisions at every call — which is how
the priority-mode bug in the previous commit was found. On exact ties
Python follows set/dict iteration order; the Rust core picks first in
snapshot order (documented; nothing depends on tie order).
The first runtime consumer: with MSTAR_RUST_WALK=shadow, every
per-request WorkerGraphIO gets a Rust WalkState mirroring its events on
real traffic — Python stays authoritative, and ready sets, doneness, and
loop counters are compared at every settle point; divergence logs an
error (MSTAR_RUST_WALK_STRICT=1 raises). Events the core does not model
yet (streaming-buffer edges, multi-pass resets after unmodeled state)
suspend comparison for that request with one logged reason instead of
false-positives. Compiled WalkSets are cached per worker-graph section;
per-request state is a cheap allocation (no deepcopy on the Rust side).

The comparison points encode a real lifecycle difference the shadow has
to bridge: the Rust core routes internal edges (and re-injects external
inputs on a loop advance) inside complete(), while mstar's worker
re-ingests those same edges after mark_node_complete returns — so the
shadow tracks the completion's locally-destined edges as a pending
multiset and compares only when Python has caught up. The multiset (not
a pair set) matters: the same (name, dest) pair arrives externally on
iteration 0 and as a loop-back echo afterwards.

Tests: a healthy shadowed loop run with zero divergence, and fault
injection proving divergence IS detected (strict mode raises). This is
the pre-authority adoption step: it exercises the Rust core against
every real model's walks in situ, and its clean logs are the evidence
for flipping authority later (speculation surface first, per the plan).
The authority step staged behind shadow: with =1, the ready set,
doneness, and loop indices are served from the Rust state (a mutable
ready view maps the worker's pop-discard onto rust schedule); Python
keeps executing values (ready_signals stay the tensor store) and the
lockstep comparison stays on — a divergence or an unmodeled event
(streaming edges, OOM push-back) falls THAT request back to Python with
an error logged, so =1 is safe to enable the moment shadow logs are
clean. Full single-state authority (dropping the Python registries)
still waits on the speculation-surface port, per the staged plan.

Tests: a full loop walk driven by Rust answers end to end, and forced
divergence falling back mid-request with the walk still completing.
test/modular/test_graph.py constructs WorkerGraphIO directly, bypassing
the adoption seam — so its five scenarios (pipeline, fixed-iteration
loop, dynamic finish, nested loops, EOS ready-signal clearing) never
exercised the Rust core under any MSTAR_RUST_WALK value. This matrix
re-runs each scenario with the wrap applied: shadow (and it FAILS on any
logged divergence, so a silent mismatch cannot pass) and authority
(Rust decisions drive the scenarios; the suite's own assertions
validate the behavior).
…lk=) signature

mstar-project#183's TP-follow fix in MicroScheduler now passes graph_walk to
get_worker_graph_id_for_node; the sched-parity harness's mock lambda
needs to accept it.
seed_with/complete_with let the Python side supply the uuids and get
them back in route events — so a pure-Rust walk adapter can key its own
uuid -> TensorPointerInfo store (the value-map pattern) and reconstruct
loop outputs with real tensor descriptors at termination.
PureRustWorkerGraphIO: the Rust WalkState is the only walk machine. The
adapter keeps the value store (uuid -> GraphEdge; edges' tensor_info is
filled by the worker post-execution, visible at loop termination via the
captured uuids), serves per-request node views for the worker's
execution reads (ready_signals.ready_inputs via per-input FIFOs filled
at schedule time), and synthesizes NodeCompletionOutput from Rust
loop_states deltas: terminated -> loop outputs + filtered loop-backs;
advanced -> re-emitted external inputs. Streaming graphs fall back to
authority mode; speculation is disabled (returns no candidates) pending
its staged port. clear() re-arms for multi-pass requests.
Found by the A/B bench: (1) re-emitted loop externals came back through
ingest_input and were APPENDED to _loop_externals again — doubling the
re-emission list every iteration (2^n edges by iter n; a 17-step walk
did 32,786 ingests). Externals are now keyed by (node, input) and
replaced, matching mstar's one-saved-value-per-input contract.
(2) the wg_state_registry property defined a class per access; now a
module-level __slots__ shim built once per request.

Bench (64-iter loop walk, CPU): per-request setup 152us -> 6.6us
(23x, the deepcopy elimination); per completion step 4.3us python vs
20.4us pure — the remaining gap is complete_with FFI marshaling, to be
batched (events+done+ready+loop deltas in one crossing) as a follow-up.
Walk bookkeeping is noise against model steps either way; setup wins
dominate for short walks, the registries for long ones, pending that
batching.
- complete_full returns events + done + ready set + loop states in one
  FFI crossing (was four calls per step); the adapter caches them.
- Re-emitted loop externals no longer re-seed Rust (it re-injects them
  internally): each re-seed appended to the loop's external_inputs, so
  completion cost grew linearly with iteration count. They now refill
  only the Python FIFO views.
- complete_node borrows the NodeSpec via an Arc bump instead of cloning
  it (and its edge strings) per completion.

Reference point: the mstar-rs-style uuid boundary runs 3.6us/step with
this same core — remaining adapter gap is the GraphEdge impersonation
(clones, dict churn), tracked for further trimming.
- ALL locally-destined completion outputs (loop-backs included, not just
  re-emitted externals) now skip re-seeding Rust on their re-ingest —
  the Rust complete already routed them, and a seed of a loop-member
  input appends to the loop's external_inputs, growing every iteration.
  Isolation bench: the same core through the minimal uuid protocol runs
  3.9us/step (complete_full binding included), so the remaining adapter
  cost is pure GraphEdge impersonation.
- Completion returns the declared edge objects instead of clones,
  matching the Python registries' own reuse contract (the worker
  overwrites tensor_info each pass); fresh uuids keep per-iteration
  value identity on the Rust side.
Three columns per run: the Python registries, the pure-Rust adapter,
and the minimal uuid protocol (the target boundary once the worker
contract retires GraphEdge). Interleaved per request so background load
lands on every column equally — ratios stay meaningful on busy hosts.
Sharing view.outputs as the completion's output_edges let the
loop-advance extend() grow the node's declared outputs every iteration
— reintroducing quadratic completion cost right after the clone-drop.
Copy the list; keep sharing the edge objects.
…n pure mode

Adding pure to the graph-suite mode matrix caught a contract gap the
parity tests missed: after a loop finishes via its stop signal, mstar
clears the member nodes' ready signals (the EOS contract —
test_eos_clears_ready_signals), but the pure adapter's node views and
pending FIFOs kept the final iteration's self-feed edges. The
termination branch now blanks each member's ready views and drops its
buffered inputs, matching the Python registries.
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.

1 participant