Pure-Rust walk layer: MSTAR_RUST_WALK=pure (RFC #130, Step-5 endgame) - #171
Draft
npuichigo wants to merge 35 commits into
Draft
Pure-Rust walk layer: MSTAR_RUST_WALK=pure (RFC #130, Step-5 endgame)#171npuichigo wants to merge 35 commits into
npuichigo wants to merge 35 commits into
Conversation
npuichigo
force-pushed
the
rust-walk-pure
branch
10 times, most recently
from
July 18, 2026 08:15
42657d6 to
081720c
Compare
npuichigo
force-pushed
the
rust-walk-pure
branch
2 times, most recently
from
July 18, 2026 08:48
edf0bed to
1ad232a
Compare
npuichigo
force-pushed
the
rust-walk-pure
branch
12 times, most recently
from
July 24, 2026 01:57
a8ae23f to
030d702
Compare
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.
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.
npuichigo
force-pushed
the
rust-walk-pure
branch
from
July 26, 2026 00:43
030d702 to
d8ef689
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Pure-Rust walk layer:
MSTAR_RUST_WALK=pure(RFC #130, Step-5 endgame)PureRustWorkerGraphIOmakes the RustWalkStatethe only walk state machine. The adapter keeps just two things (the value-map pattern):GraphEdgestore: every ingested/produced edge gets a uuid via the newseed_with/complete_withbindings; route events return the captured uuids, so loop outputs are reconstructed with realtensor_infoat termination (the worker fills descriptors post-execution; mutation is visible through the store).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 byloop_statesdeltas: 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'sspeculate/claimimplementation is the ready-made reference when it does.The ladder is now complete:
0→shadow(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)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
GraphEdgeimpersonation. 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.