Skip to content

communication: vendored Rust transport (rust/) + RustZMQCommunicator (RFC #130 Step 1) - #164

Merged
NSagan271 merged 21 commits into
mstar-project:mainfrom
npuichigo:rust-communicator-step1
Jul 18, 2026
Merged

communication: vendored Rust transport (rust/) + RustZMQCommunicator (RFC #130 Step 1)#164
NSagan271 merged 21 commits into
mstar-project:mainfrom
npuichigo:rust-communicator-step1

Conversation

@npuichigo

@npuichigo npuichigo commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

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), with RustZMQCommunicator landing 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 via zmq_poll raw-fd items; a typed Codec layer as the pickle→msgpack migration seam) + a PyO3 module mstar_rust. Build with maturin develop (or pip install .) in rust/.

RustZMQCommunicator — drop-in for ZMQCommunicator: same constructor/methods/endpoints (ipc prefix scheme and the MSTAR_ZMQ_* TCP host/port map), wire-compatible with unwrapped pyzmq entities in both directions (pickle-default codec=(dumps, loads) over a no-framing transport — migrate one process at a time); register_event_for_poll forwards the EventWakeup fd 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 next get_all_new_messages, FIFO intact).

Teststest/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; an EventWakeup fire cuts wait_for_work to ~50 ms against a 2 s timeout; lossless FIFO readiness polls. All 3 pass locally against the vendored build.

…(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.
@npuichigo npuichigo changed the title communication: RustZMQCommunicator — pyzmq surface over the mstar-rs transport (RFC #130 Step 1) communication: vendored Rust transport (rust/) + RustZMQCommunicator (RFC #130 Step 1) Jul 15, 2026
@npuichigo
npuichigo force-pushed the rust-communicator-step1 branch from 52c16b0 to ddb8a86 Compare July 15, 2026 02:21
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.

@NSagan271 NSagan271 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread mstar/communication/rust_communicator.py Outdated
Comment thread mstar/communication/rust_communicator.py
Comment thread mstar/communication/communicator.py Outdated
Comment thread mstar/communication/rust_communicator.py Outdated
Comment thread mstar/communication/rust_communicator.py Outdated
Comment thread rust/pyproject.toml
Comment thread mstar/communication/rust_communicator.py Outdated
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.
@npuichigo

Copy link
Copy Markdown
Contributor Author

Thanks for the careful review — all comments addressed in b0e29f2..3fc0753:

  • Dead-code warnings: the warnings came from the typed ZmqCommunicator<M, C>/Codec layer being unreachable from a cdylib-only crate with a private module. Rather than #[allow]ing it, the crate now also builds as a Rust library (crate-type = ["cdylib", "rlib"] + pub mod communicator) — that layer is the API the later migration steps (Rust conductor/API server) consume, and cargo build is warning-free.
  • blocking honored in get_all_new_messages on both communicators (the pyzmq one had the same latent bug), with tests for both.
  • MSTAR_RUST_ZMQ is 0/1/AUTO; default left at 0 pending your "fully tested" call — flipping to AUTO is a one-word change whenever you want it.
  • Docs: docs/environment_variables.rst starts the env-var documentation precedent; Rust setup instructions are in installation.rst + README (with explicit maturin develop --release, since debug builds cost real latency on the receive path).
  • Codec is a small class mirroring the Rust trait; _endpoint/_tcp_port are hoisted to BaseCommunicator.

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.
@npuichigo

Copy link
Copy Markdown
Contributor Author

One more change worth flagging (2f1870a): swapped the typed layer's default codec from bincode to MessagePack (rmp-serde). Two reasons:

  1. bincode is a governance risk — the 1.x line we pinned is frozen, and upstream has moved off GitHub with no commitment to further releases there.
  2. msgpack is where the codec seam is headed anyway: it's the language-neutral wire this migration targets, and to_vec_named frames (field-name maps) are directly readable by Python peers with the msgpack package — so the same codec serves Rust-internal typed messaging and future cross-language edges.

The transport is unaffected (it moves opaque frames); only the Rust-internal typed default changes. cargo test and the interop pytest are green.

- 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.
@npuichigo

Copy link
Copy Markdown
Contributor Author

A/B of the two communicators (same pickle codec both sides, ipc endpoints, single box — isolates the transport layer):

Metric pyzmq Rust Ratio
RTT p50 (2 hops) 68.4 µs 54.1 µs 1.26×
RTT p99 82.8 µs 86.9 µs 0.95× (parity)
Throughput, 1 KB msgs 75.9k msg/s 127.0k msg/s 1.67×
Throughput, 64 KB 159 MB/s 191 MB/s 1.20×
Throughput, 1 MB 193 MB/s 277 MB/s 1.43×
Drain of a 512-msg burst 3.49 ms 0.97 ms 3.59×

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 (zmq::Message end to end), and the burst drain is get_all_new_messages picking up the whole queue in one crossing instead of one per message. p99 is honest parity — at the tail, scheduler noise dominates both.

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.
@npuichigo

Copy link
Copy Markdown
Contributor Author

Is there anything else that needs to be updated?

@NSagan271

Copy link
Copy Markdown
Collaborator

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 NSagan271 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor comments, then it should be good to go on my end.

Comment thread mstar/communication/rust_communicator.py Outdated
Comment thread mstar/communication/rust_communicator.py
Comment thread mstar/communication/rust_communicator.py Outdated
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.

@NSagan271 NSagan271 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good on my end, and I jut did one last spot check (orpheus benchmark). I'd wait for a review/go-ahead from @merceod before merging.

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 merceod left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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.

Comment thread rust/src/communicator.rs
Comment thread mstar/communication/rust_communicator.py
Comment thread rust/src/communicator.rs Outdated
Comment thread mstar/communication/communicator.py
Comment thread rust/src/communicator.rs
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.
@npuichigo

Copy link
Copy Markdown
Contributor Author

@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:

  • Lock held across send — the peers 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 preserved, since a zmq socket is single-user anyway), and an HWM-blocked send now stalls only that peer, not the process's whole control plane (or register_peer).

  • poll_for_messages parity — added the pyzmq twin (the poller without consuming, wakeup drained exactly as in wait_for_work), so the factory's drop-in guarantee holds again; a parity test drives the same contract through both classes.

  • Silent decode drop — took your primary suggestion: Codec::decode -> Result<M, CommError>, with try_recv/recv/recv_timeout returning Result<Option<M>> and drain Result<Vec<M>>, so codec/version skew is an error distinct from "no message" in the API Steps 4/5 inherit. CommError::Decode carries the frame length; a new test proves garbage surfaces as Err, never None.

  • Transport observability — the factory logs control mesh transport: rust <version> (MSTAR_RUST_ZMQ=...) (or pyzmq) at INFO on construction; mstar_rust now exports __version__ (the crate version), and the factory warns when it differs from the tree's expected constant — the stale-wheel-under-AUTO case you described now announces itself. README got the AUTO-by-default operational note from your review body.

  • POLLNVAL on wakeup fds — you're right, and it's cheap: recv_or_wake now checks the wakeup items' revents for POLLERR/POLLNVAL and returns a distinct WakeFdError, which the Python binding raises as a loud RuntimeError instead of letting every blocking wait degrade into an instant-timeout spin.

Stack rebased on the new head. Happy to adjust any of these if you'd prefer a different shape.

@merceod

merceod commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

@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:

  • Lock held across send — the peers 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 preserved, since a zmq socket is single-user anyway), and an HWM-blocked send now stalls only that peer, not the process's whole control plane (or register_peer).
  • poll_for_messages parity — added the pyzmq twin (the poller without consuming, wakeup drained exactly as in wait_for_work), so the factory's drop-in guarantee holds again; a parity test drives the same contract through both classes.
  • Silent decode drop — took your primary suggestion: Codec::decode -> Result<M, CommError>, with try_recv/recv/recv_timeout returning Result<Option<M>> and drain Result<Vec<M>>, so codec/version skew is an error distinct from "no message" in the API Steps 4/5 inherit. CommError::Decode carries the frame length; a new test proves garbage surfaces as Err, never None.
  • Transport observability — the factory logs control mesh transport: rust <version> (MSTAR_RUST_ZMQ=...) (or pyzmq) at INFO on construction; mstar_rust now exports __version__ (the crate version), and the factory warns when it differs from the tree's expected constant — the stale-wheel-under-AUTO case you described now announces itself. README got the AUTO-by-default operational note from your review body.
  • POLLNVAL on wakeup fds — you're right, and it's cheap: recv_or_wake now checks the wakeup items' revents for POLLERR/POLLNVAL and returns a distinct WakeFdError, which the Python binding raises as a loud RuntimeError instead of letting every blocking wait degrade into an instant-timeout spin.

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.
@npuichigo

Copy link
Copy Markdown
Contributor Author

Both done:

  • Dead half of the fd guard dropped in 5b18c1d — you're right that from_bits_truncate(0x20) truncates to empty; kept only the POLLERR check with a comment noting libzmq folds a raw fd's POLLNVAL/POLLERR into ZMQ_POLLERR (verified the loud-failure behavior is unchanged, crate tests green).
  • Tracking issue for the SNDTIMEO/failure-propagation deferral: Control-mesh send timeout (SNDTIMEO) + peer-failure propagation #173 — framed exactly as you laid out (no call site can handle a send error today; single-transport SNDTIMEO would break parity; belongs with worker-liveness where a timed-out send can fail requests / mask the peer).

@merceod

merceod commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

Perfect!

@merceod merceod left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@NSagan271
NSagan271 merged commit 8a107f3 into mstar-project:main Jul 18, 2026
2 checks passed
@npuichigo
npuichigo deleted the rust-communicator-step1 branch July 18, 2026 08:03
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.

3 participants