communication: vendored Rust transport (rust/) + RustZMQCommunicator (RFC #130 Step 1) - #164
Conversation
…(RFC mstar-project#130 Step 1) Vendors the mstar-rs ZMQ transport into rust/ (communicator.rs: opaque byte frames over PUSH/PULL, ipc + tcp endpoints, wakeup-fd polling, and a typed Codec layer as the pickle->msgpack migration seam) with a PyO3 module (mstar_rust; maturin develop in rust/) — no external dependency. RustZMQCommunicator is a drop-in for ZMQCommunicator over that module: same constructor/methods/endpoints (ipc prefix + the MSTAR_ZMQ_* TCP scheme), wire-compatible with unwrapped pyzmq entities in both directions (pickle-default codec, no added framing), eventfd wakeup forwarded to the Rust poller with unchanged drain semantics, and a buffering bridge so a readiness poll never drops or reorders a frame. test/modular/test_rust_communicator.py (skips unless mstar_rust is built): pickle interop both directions on one mesh; an EventWakeup fire cuts wait_for_work to ~50 ms against a 2 s timeout; FIFO-lossless readiness polls. All 3 pass against the vendored extension.
52c16b0 to
ddb8a86
Compare
make_communicator() selects the transport at the four construction sites (worker, conductor, api_server, data worker): default is the pyzmq ZMQCommunicator, byte-identical behavior; MSTAR_RUST_ZMQ=1 opts a process into RustZMQCommunicator. The two are wire-compatible (same endpoints, same pickle frames), so the flag is per-process — one entity can be switched and A/B'd while the rest of the mesh stays on pyzmq. The default flip stays a later step, after perf gates and packaging (building the extension in CI/wheels).
A path-filtered workflow (rust/, communication/, its test file): cargo tests for the transport semantics (raw frames, tcp endpoints, wakeup-fd polling), then maturin-build the mstar_rust extension and run the pyzmq <-> Rust interop pytest against it — so the importorskip guard never silently skips in CI when the transport is what changed.
test/modular/conftest.py imports torch, which the transport test neither needs nor should pull into its CI job — test/rust/ has no conftest, so the job stays pyzmq-only.
One workflow, two jobs (ruff + rust-transport). Path filtering is workflow-level only, so the transport job now runs on every PR — with Swatinem cargo caching it stays around a minute warm, and the transport is core enough to earn that.
The runner had no importable mstar package; --no-deps keeps the job lean — the transport test chain needs only pyzmq and the stdlib.
There was a problem hiding this comment.
Overall looks good. I also cloned the repo and did spot checks on the Orpheus, Qwen3-Omni, and Bagel benchmarks. Only comment from testing: I got some build warnings about dead code, should maybe add #[warn(unused)] where appropriate
Also inviting @merceod to comment as well if you have time, since this is the first PR adding Rust to our system.
The typed ZmqCommunicator<M, C> / Codec layer is the API the migration's later steps (Rust conductor, API server) consume, and the cargo tests exercise it — but with only a cdylib target and a private module, rustc saw it as unreachable and warned. Exposing it (crate-type rlib + pub mod communicator) makes the crate a real Rust library and the build warning-free.
Review follow-ups: - get_all_new_messages(blocking=True) now waits for at least one message before draining, on BOTH communicators (the pyzmq one had the same latent bug: the flag was accepted and ignored). A registered wakeup event also ends the wait, mirroring wait_for_work. - _endpoint/_tcp_port were copied between ZMQCommunicator and RustZMQCommunicator; they live on BaseCommunicator now. - The codec seam is a small Codec class (encode/decode), mirroring the Rust trait, with PickleCodec as the wire default. - poll_for_messages docstring states the actual contract: message, wake, or timeout, whichever comes first. - MSTAR_RUST_ZMQ is tri-state: 0 (default, pyzmq), 1 (Rust, raise if the extension is missing), AUTO (Rust when importable). Tests cover the blocking receive on both transports and the factory selection.
- docs/environment_variables.rst starts the documented-env-vars precedent, seeded with the communication variables (MSTAR_RUST_ZMQ, MSTAR_ZMQ_TRANSPORT, MSTAR_ZMQ_TCP_HOST, MSTAR_ZMQ_TCP_BASE_PORT). - installation.rst + README: how to build the optional rust/ extension. maturin defaults to a debug build, so the instructions say 'maturin develop --release' explicitly — debug costs real latency on the hot receive path.
|
Thanks for the careful review — all comments addressed in b0e29f2..3fc0753:
CI is green on the branch. Happy to iterate further, and looking forward to @merceod's take on the Rust layout. |
The wrapper predates the vendoring: it said DRAFT and pointed at the prototype's module path (mstar_rs._core). It is neither — the extension is mstar_rust, built from rust/ in this repo.
bincode is a governance risk (1.x frozen; upstream has left GitHub and disavows further updates there) — and MessagePack is what this migration standardizes on anyway: the codec seam's stated target wire, readable by Python peers with the msgpack package (to_vec_named = field-name maps, i.e. Python dicts). rmp-serde is actively maintained. The transport is unaffected (opaque frames); only the Rust-internal typed default changes.
|
One more change worth flagging (2f1870a): swapped the typed layer's default codec from bincode to MessagePack (
The transport is unaffected (it moves opaque frames); only the Rust-internal typed default changes. |
- send: a PUSH send blocks when the peer sits at its high-water mark; doing that with the GIL held freezes every Python thread in the process. Release it around the send, as pyzmq does. - get_all_new_messages picks up the whole queued batch with one drain() call instead of one try_recv FFI round-trip per message.
recv_bytes copied every frame twice: zmq's buffer -> Vec, then Vec -> PyBytes (or the codec's decode). Receives now hand the zmq::Message itself through RecvEvent/try_recv/drain (Deref<Target=[u8]>), so the single copy happens at the consumer boundary and nowhere else.
|
A/B of the two communicators (same pickle codec both sides, ipc endpoints, single box — isolates the transport layer):
The deltas line up with the implementation differences: small-message throughput is the per-message FFI path, the 1 MB rate is the single-copy receive ( To be clear about magnitude: pyzmq is already fast, and at today's control-message rates neither transport is a bottleneck. The claim this supports is "parity or better on every metric, measured" — the migration doesn't cost anything on the way to the later steps. |
Unset now means: the Rust transport when the vendored extension imports, pyzmq otherwise. Explicit 0/1 behave as before. Test covers the default.
|
Is there anything else that needs to be updated? |
Not that I can think of right now. I will take another look through the PR later today before approving just to be sure. |
NSagan271
left a comment
There was a problem hiding this comment.
Minor comments, then it should be good to go on my end.
Review follow-ups: get_all_new_messages(blocking=True) takes an optional timeout_s on BOTH communicators (None = wait indefinitely; on expiry, drain whatever arrived) so a future blocking caller cannot wait forever. MsgpackCodec joins PickleCodec — msgpack is language-neutral, so edges using it can terminate in (future) Rust processes. Module docstring condensed.
The 50 ms is a signal-responsiveness bound, not a latency knob (events end a slice immediately) — worth saying at a named constant instead of a literal in the loop.
merceod
left a comment
There was a problem hiding this comment.
@npuichigo Thanks for the PR. Most things look great to me!
I left a few comments. Please let me know if you disagree with any of them (I wrote the comments based on my understanding of the code so please correct me if I'm am wrong somewhere). Most of the comments are minor changes and overall the code looks great.
@NSagan271 please take a look too.
One thing:
I think it might be worth adding one comment in the README about what AUTO-by-default means: on any machine where the extension is built, the whole mesh switches to Rust with no configuration change (the current "selectable per process" framing undersells this and since all entities of one mstar-serve launch inherit the same environment, the flag is effectively per-launch-tree, not per-entity; true per-process mixing only happens across separately launched processes). Fine as designed — just deserves saying.
Five review items on the transport: - send() no longer holds the peers-map mutex across the socket send: a PUSH send blocks at the peer's high-water mark, so one stalled consumer would freeze sends to every other peer (and registration) process-wide once anything drives this layer multi-threaded. The map now stores Arc<Mutex<Socket>>: the map lock covers only lookup/insert, the per-socket lock serializes sends to one peer (per-peer FIFO kept), and an HWM-blocked send stalls only that peer. - The typed layer's Codec::decode returns Result: a frame that fails to decode is a codec/version mismatch with the peer, and silently conflating it with "no message" would turn skew into requests that hang with no log line in the later Rust consumers of this API. try_recv/recv/recv_timeout return Result<Option<M>>, drain Result<Vec<M>>; new test proves garbage surfaces as CommError::Decode, never as None. - poll_for_messages now exists on BOTH communicators (the pyzmq twin is the poller without consuming), restoring the factory's drop-in guarantee; parity test covers the contract on each. - Transport observability: the factory logs the chosen transport (and extension version) at INFO on construction; mstar_rust exports __version__, and a mismatch against the tree's expected version warns loudly — a stale wheel can no longer take over the mesh silently under AUTO. README notes what AUTO-by-default means operationally. - recv_or_wake distinguishes a closed/invalid wakeup fd (POLLERR/POLLNVAL) from a timeout and fails loudly instead of degrading every blocking wait into an instant-timeout CPU spin.
|
@merceod Thanks — all five are correct, and the mutex one especially (nice probe; the two-thread measurement is exactly the failure mode the later multi-threaded consumers would have hit). Everything is addressed in 838fb63:
Stack rebased on the new head. Happy to adjust any of these if you'd prefer a different shape. |
Hey @npuichigo Everything looks good. Confirmed the lock fix. Re-ran the same two-thread probe on 838fb63: healthy-peer send under an HWM-blocked flood is now 0.2 ms (was ~6 s), per-peer FIFO intact. On the SNDTIMEO half (my first comment): let's explicitly defer it out of this PR rather than leave it hanging. Two reasons: no call site today handles a send error, so a timeout would just turn a stalled send into an unhandled exception mid-loop (worse than the stall); and adding it only on the Rust side would break send-semantics parity between the two transports. The right home for it is the worker-liveness/failure-propagation work, where a timed-out send has somewhere to go (fail the affected requests, mask the peer). Can you open a small tracking issue referencing this thread so it doesn't get lost - then this is resolved from my side? One optional nit while you're in the file: in the recv_or_wake fd guard, PollEvents::from_bits_truncate(0x20) truncates to empty (0x20 isn't a defined zmq flag) — the guard works because libzmq folds raw-fd POLLNVAL/POLLERR into ZMQ_POLLERR, which the other half of the check catches. The dead half can be dropped for clarity. @NSagan271 thoughts? |
PollEvents::from_bits_truncate(0x20) truncates to empty (0x20 is not a defined flag in the zmq crate); the guard worked because libzmq folds a raw fd's POLLNVAL/POLLERR into ZMQ_POLLERR, which the other half already catches. Keep only the live half, with the explanation.
|
Both done:
|
|
Perfect! |
Step 1 of the migration plan in #130, as requested — self-contained: the Rust transport is vendored into
rust/(no dependency on the mstar-rs repo), withRustZMQCommunicatorlanding side-by-side so it can be selected per-entity rather than replacing the pyzmq class in one cut.rust/— the ZMQ transport (opaque byte frames over PUSH/PULL; ipc + tcp endpoints; wakeup-fd polling viazmq_pollraw-fd items; a typedCodeclayer as the pickle→msgpack migration seam) + a PyO3 modulemstar_rust. Build withmaturin develop(orpip install .) inrust/.RustZMQCommunicator— drop-in forZMQCommunicator: same constructor/methods/endpoints (ipc prefix scheme and theMSTAR_ZMQ_*TCP host/port map), wire-compatible with unwrapped pyzmq entities in both directions (pickle-defaultcodec=(dumps, loads)over a no-framing transport — migrate one process at a time);register_event_for_pollforwards theEventWakeupfd to the Rust poller with drain semantics unchanged; and a buffering bridge for the one semantic mismatch (your poller reports readiness without consuming; the Rust receive consumes — a frame consumed during a poll is delivered by the nextget_all_new_messages, FIFO intact).Tests —
test/modular/test_rust_communicator.py(pytest.importorskip("mstar_rust"), so CI skips until the extension is built): pickle interop pyzmq↔Rust both directions on one mesh; anEventWakeupfire cutswait_for_workto ~50 ms against a 2 s timeout; lossless FIFO readiness polls. All 3 pass locally against the vendored build.