Skip to content

Elide coordinate axes a receiving channel already holds - #276

Draft
cboulay wants to merge 4 commits into
cboulay/shm-hardeningfrom
cboulay/wire-elision
Draft

cboulay wants to merge 4 commits into
cboulay/shm-hardeningfrom
cboulay/wire-elision

Conversation

@cboulay

@cboulay cboulay commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

Restacked: ported onto #268's asyncio.Protocol receive path: ELIDE_OK/AXIS_RESEND go through proto.write, resolution happens in _deliver_from_shm/_deliver_from_tcp, and _reattach_shm resolves preserved messages. With #268 underneath, the SHM round trip is now 74–75 µs with elision off and 66 µs on (−12%), at both 256 and 1024 channels.

Stacked on #275. Draft for design review.

Why

An AxisArray stream sends the same non-stream coordinate axes (channel labels, positions, ...) in every message, often more bytes than the data. Within a process that's free (messages share the axis objects), but across one every message serializes, copies and unpickles them again. The receiver also gets a new axis object each message, so the baseproc hash witness can only compare them by value (~540 vs ~280 ns per stateful consumer per message with a structured channel axis).

Design

No AxisArray API change; it all lives in the transport (ezmsg/core/axiselision.py).

  • Publisher (AxisElision): before pickling a top-level AxisArray, each non-stream CoordinateAxis goes out in full the first time (_AxisDef: token + axis) and as a 16-byte _AxisRef thereafter. The token is a blake2b digest of CoordinateAxis.fingerprint (64-bit as of CoordinateAxis.fingerprint: compute when pickling, widen to 64 bits #274), computed once per axis object. The caller's message is untouched; a bare copy with a substituted axes dict is what gets pickled.
  • Channel (AxisTable): resolves references after unpickling, records definitions as copies that own their memory (never views into SHM; see Cross-process SHM grow kills the subscriber channel (BufferError) when a received message is still referenced #272), bounded to 256 entries.
  • Never elided: the stream axis (stream_dim, else "time", matching baseproc), axes without a fingerprint, and anything that isn't a top-level AxisArray.
  • Negotiation: a channel sends Command.ELIDE_OK after connecting. An older publisher's read loop ignores the byte. A publisher elides only while every non-local channel has said so, so an older channel keeps everything in full. The new commands are appended to the enum, so no existing value moves.
  • Resets: a channel joining, or Command.AXIS_RESEND from a channel that got a reference it can't resolve (it missed or evicted the definition), makes the publisher define every axis again. The channel drops that one message, as the existing stale-SHM path does.
  • Off switch: EZMSG_DISABLE_AXIS_ELISION.
  • fast_replace also drops the cached _wire_token. Unrolling the drop makes it slightly faster than before (609 vs 623 ns).

Performance

(10, n) float32 + structured (label U8, x, y, z) channel axis, real implementation, elision on vs EZMSG_DISABLE_AXIS_ELISION:

256 ch 1024 ch
SHM round trip, off → on 94–96 → 86–88 µs 91–96 → 87–88 µs
message size −58% (25.2 → 10.6 KB) −58% (98.9 → 41.3 KB)
baseproc hash check past the hop ~540 → ~300 ns ~535 → ~300 ns

Over TCP (one serialization feeds both paths, and TCP channels negotiate and resolve the same way), loopback round trip, off → on: 256 ch 91.6–93.0 → 82.5–83.4 µs (−10%), 1024 ch 100.5–101.0 → 87.5 µs (−13%). Across a real network the saving scales with the bytes removed: ~0.46 ms per message at 1 Gb/s for 1024 channels, ~0.12 ms for 256 (estimated from sizes, not measured).

Byte payloads (ezmsg perf ab vs #275, 12 rounds): local/SHM/TCP all within ±2.4%, inside the ±3% A/A noise.

Open questions

  1. Dropping a message on an unresolvable reference is the simplest recovery. The alternative is to hold it until definitions arrive.
  2. Only top-level AxisArrays. Axes nested in other containers pickle in full.
  3. Table/announced bounds (256) are guesses.

Tests

tests/test_axiselision.py (16): substitution rules, sharing one owned axis on the far side, relabels, unknown references, surviving the publisher-side copy_obj on a grow, and end to end over SHM and TCP (sharing, relabel, recovery after a channel loses its table, staying off for a channel that never sends ELIDE_OK). Plus a guard that fast_replace's unrolled drops match _DERIVED_CACHE_ATTRS. Full suite: only the 10 JSON-schema/inspect failures that also fail on the base branch.

Writing these tests exposed a pre-existing grow race (a segment unlinked before a lagging channel attached it), now fixed in #275.

Since v3.10.0b5: the default stream dimension for messages that do not declare stream_dim is now defined once, as ezmsg.util.messages.axisarray.DEFAULT_STREAM_DIM ("time"), and elision reads it from there instead of its own FALLBACK_STREAM_DIM (which shipped in b5 but was module-internal). ezmsg-baseproc can import the same constant for STREAMING_DIMS once a beta includes it.

An AxisArray stream sends the same non-stream coordinate axes (channel
labels, positions, ...) every message -- often more bytes than the data --
and every message unpickles into new axis objects, so consumers past a
process hop can only compare them by value.

The publisher now sends each such axis in full once (_AxisDef: token +
axis) and thereafter as a 16-byte _AxisRef. The token is a blake2b digest
of CoordinateAxis.fingerprint, computed once per axis object. The channel
keeps an AxisTable of the axes it was given, copied out of the transport's
memory, and substitutes them for references, so every message of a stream
shares one axis object on the far side too.

- Scope: the axes of a top-level AxisArray, never its stream axis (stream_dim,
  else "time"). Everything else pickles exactly as before.
- Negotiation: a channel sends Command.ELIDE_OK after connecting (an older
  publisher ignores the byte); a publisher elides only while every
  non-local channel has said so.
- Resets: a channel joining, or a channel that receives a reference it
  cannot resolve (it drops that message, as the stale-SHM path does, and
  sends Command.AXIS_RESEND), makes the publisher define every axis again.
- EZMSG_DISABLE_AXIS_ELISION turns it off.
- fast_replace drops the cached _wire_token with _fingerprint; unrolling the
  drop makes it slightly faster than the one-name loop it replaces.

SHM round trip, (10, n) float32 + structured (label, x, y, z) channel axis:
256 ch 94-96 -> 86-88 us, 1024 ch 91-96 -> 87-88 us. Messages are 58%
smaller. Byte payloads: unchanged within noise (perf ab, 12 rounds).
Elision kept its own FALLBACK_STREAM_DIM ("time") for messages that do not
declare stream_dim, mirroring ezmsg-baseproc's STREAMING_DIMS by hand. Both
must agree on which axis changes every message, so move the definition to
ezmsg.util.messages.axisarray.DEFAULT_STREAM_DIM, next to stream_dim itself,
and read it from there (lazily, like elision's other axisarray imports).
Consumers such as ezmsg-baseproc can now import the same constant.

This branch was successfully deployed

1 active (outdated) deployment
github-pages — d7174548 Deployed Oct 1, 2026 by cboulay via deploy #127
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