Receive channel notifications with asyncio.Protocol instead of streams - #268
Draft
cboulay wants to merge 2 commits into
Draft
Receive channel notifications with asyncio.Protocol instead of streams#268cboulay wants to merge 2 commits into
cboulay wants to merge 2 commits into
Conversation
The channel's connection to its publisher used the high-level streams API,
which delivers bytes by resolving a future and scheduling the reading task.
That costs a full event-loop iteration before any work happens, on every
message.
An asyncio.Protocol has data_received() called synchronously from the
transport's read callback, so a notification is cached and its subscribers
notified in that callback instead. Measured on a two-process SHM hop
(8 KB AxisArray, medians over 2400 messages, interleaved A/B):
stage streams Protocol
pub exit -> rx wakeup 50.6us 15.6us 3.2x
end-to-end 97.0us 69.0us -29%
throughput 17,337/s 18,697/s +8%
frameproto.FramedProtocol runs a connection in two phases: a handshake
driven by `await read_exactly()`, then start_dispatch() flips to
frames_available() being called inline. Only public asyncio API is used, so
uvloop remains a drop-in on POSIX -- verified end-to-end under uvloop, where
the same code reaches ~50us.
SHM resize is the one path that needs an await, which a read callback cannot
do. On a generation change the protocol pauses reading and parks the frame,
then a task re-dispatches it against the new segment, or drops it and
releases its backpressure if the attach fails.
Two behaviour notes:
- A frame-dispatch exception is now logged with its traceback and the
transport closed, rather than propagating out of the channel task and
potentially surfacing through wait_closed() during teardown.
- Because dispatch never awaits, delivery is now atomic with respect to
other tasks on the loop, which the task-based version was not.
Deliberately not converted: the publisher's ack receive. It was built and
passed the suite, but measured consistently ~12% worse end-to-end across 5
interleaved rounds -- the cost localized to bytes leaving the publisher
later, with broadcast() itself and the subscriber side unchanged. The graph
server connections stay on streams as well; they are control plane, not
per-message.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Channel.wait_closed() now waits on the protocol's connection_lost, which the task-based version did not -- it cancelled a task and returned regardless of transport state. That introduced a way to hang shutdown. A graceful transport.close() does not schedule connection_lost while the transport still has queued writes; it waits for them to flush. If the peer has stopped reading -- which the publisher does during teardown, when it cancels the task that consumes acks -- those bytes never drain and wait_closed() blocks forever. Demonstrated directly: with the write buffer stuffed, close() leaves wait_closed() pending indefinitely while abort() completes immediately. Teardown paths now abort, which discards queued output and fires connection_lost without waiting for the peer. Dropping pending acks is safe: the publisher frees the whole channel's backpressure when it sees the disconnect. Adds a regression test that fills the transport's write buffer against a peer that never reads, then asserts teardown completes. It fails with TimeoutError if abort() is swapped back to close(). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Draft — not for merge yet. Opening this so @griffinmilsap can look it over before it goes anywhere.
What and why
Profiling a two-process SHM hop showed the time is not where I expected. Serialization is ~14us of a ~97us hop; the dominant stage is 50us between the publisher finishing
broadcast()and the subscriber's channel waking up. That turned out to be the streams API, not the transport and not ezmsg.asyncio.open_connectiondelivers bytes by resolving a future and scheduling the reading task, costing a full event-loop iteration per message before any work happens. Anasyncio.Protocolhasdata_received()called synchronously from the transport's read callback, so the notification is cached and subscribers notified right there.Measured
Two-process SHM hop, 8 KB
AxisArray, medians over 2400 messages, interleaved A/B on this base:Protocol won every round. Corroborating detail: latency is flat from 16 B to 512 KB payloads, which is what pointed at fixed scheduling cost rather than serialization in the first place.
uvloop
Written to public asyncio API only — no
reader._buffer, no transport internals — so uvloop stays a drop-in on POSIX. Verified end-to-end under uvloop with no code changes: all messages delivered, clean, ~50 us end-to-end. So this gets stock asyncio most of the way, and uvloop later would roughly halve the original baseline.Design
frameproto.FramedProtocolruns a connection in two phases:await read_exactly(). One task wakeup per read, which is fine since it happens once.start_dispatch()flips toframes_available()being called inline fromdata_received.SHM resize is the one path needing an
await, which a read callback can't do. On a generation change the protocol pauses reading and parks the frame; a task then re-dispatches it against the new segment, or drops it and releases backpressure if the attach fails. Covered bytest_shm_growand a rewritten reattach unit test.Things worth a reviewer's attention
wait_closed()during teardown. Happy to make it propagate if that's preferred.test_channel_preserves_cached_message_during_shm_reattachdrove_publisher_connectionwith aStreamReader, which no longer exists. Rewritten against a realChannelProtocolwith a fake transport, and strengthened — it now also asserts the segment swapped, the parked frame was re-dispatched, the client was notified, and the buffer fully consumed.Deliberately not included
The publisher's ack receive was built and passed the full suite, but measured consistently ~12% worse end-to-end across 5 interleaved rounds. Localized by stage:
broadcast()itself and the subscriber side were unchanged; the entire cost was bytes leaving the publisher later. Working hypothesis is that inline ack handling delays the transport's write scheduling, where the ack task previously competed on equal footing in the ready queue — unverified, flagging it as a hypothesis. Reverted rather than shipped.The graph server connections stay on streams: control plane, not per-message.
Testing
Full suite green on this base: 424 passed, 1 skipped. Lint clean.
Measurements are single-machine (Darwin, arm64, Python 3.13). Worth replicating on Linux before anyone leans on the numbers.
🤖 Generated with Claude Code