perf: 3x throughput and half the CPU by removing the ingest queue (py-only) - #320
Merged
Conversation
Every enqueued point crossed a hardcoded `bounded(4)` tokio mpsc channel drained by a single
forwarding task, costing a park/unpark round trip **per item**, not per call. So
`enqueue_from_dict` -- one boundary crossing but one item per channel -- was no faster than
looping over `enqueue`, and measured slower on a 6,480-channel record.
The channel bought nothing. `NominalDatasetStream::enqueue` is synchronous and `Sync`, so python
threads can call it directly under `py.detach()`; the tokio runtime stays, but only for the
uploader. Several python threads can now ingest concurrently, contending on the stream's buffer
lock rather than serializing through one task.
Entirely within `py-nominal-streaming` -- the core crate is untouched.
Measured on a local file target, release both sides, min of 7:
6,480 channels @ one timestamp 10,766us -> 762us 14.1x
10,000 points @ one channel 576us -> 186us 3.1x
single point 1.86us -> 0.23us 8.2x
Also here, none of them strictly speedups:
- own the stream rather than holding it in an `Arc`, so the drain cannot become a silent no-op if
something later clones it
- add a `Drop` for the pyclass that releases the GIL around teardown. Dropping the stream drains
it, and pyclass deallocation runs with the GIL held, so without this a stream left to the
garbage collector stalls every python thread for the length of the drain. Verified: a stream
dropped without `close()` writes all 400,000 of its points and takes 0.54s off the GIL.
- absent and empty tags both normalize to `None`, so a channel written with `tags=None` and one
written with `tags={}` share a series instead of splitting into two. Identical on the wire.
- skip timestamp normalization in python when the caller already passed integral nanoseconds, and
stop copying the caller's mapping in `enqueue_from_dict`, which can hold thousands of channels
- `open()` now raises when the stream fails to build, rather than logging and returning a stream
that rejects every write
Avro round-trip output is byte-identical to main across scalars, series, wide dicts, tags,
structs, arrays and datetime timestamps. 8-thread concurrent ingest lands exactly 40,400 points.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The SIGINT handler called `cancel`, whose contract was to abandon buffered points and exit fast.
The behaviour we want is the opposite: refuse further writes, let everything already enqueued
reach avro or the backend, and exit once it has.
Refusing first is what makes the drain converge. Without it the teardown races a producer loop
that has not noticed the interrupt yet, and on a multi-threaded writer it never notices at all --
KeyboardInterrupt is delivered only to the main thread.
`stop_accepting_writes` takes `&self` rather than `&mut self` deliberately: shutdown starts on
whichever thread caught the signal while other threads may be inside `enqueue`, and `close` needs
`&mut self`, which would fail to borrow in that situation. The handler sets the flag and re-raises
KeyboardInterrupt; the drain then happens in `close`, reached through `__exit__` as the exception
unwinds, or through the stream's destructor if the caller is not using a context manager.
Writes attempted after shutdown begins raise `RuntimeError("stream is shutting down")`.
Verified by sending a real SIGINT to a streaming process:
single writer: 10,442,916 points enqueued, 10,442,916 in the avro file, exited in 3.4s
four writers: 1,498,653 points enqueued, 1,498,653 in the avro file, all 4 threads refused
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 26, 2026
alxhill
approved these changes
Aug 26, 2026
This was referenced Aug 26, 2026
najork
approved these changes
Aug 26, 2026
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.
Touches only
py-nominal-streaming. The core crate is not modified.Bottom line
Writing through this package used to be bounded by a queue that served no purpose. Removing it
means the same hardware moves several times the data for a fifth of the CPU, and Ctrl+C now
flushes instead of hanging.
Measured against the real staging backend, not a local file -- so the uploader is a genuine
gRPC round trip and backpressure is real. Interleaved A/B, three rounds each,
batch_size=250_000,max_wait=0.1s.enqueue, same shape, local targetenqueue, single point, local targetEvery point was verified delivered, not merely accepted into a buffer: reading the channels back
out of staging reconciles exactly with what each run enqueued (e.g. 1,250 records expected and
1,250 stored). That check matters here because the request dispatcher currently counts a failed
upload as a success, so throughput alone could have measured discarding data faster.
It depends on the shape you write
enqueue_from_dict)enqueue_batch)enqueue_float_array)enqueue_string_array)Deep and grouped writes already produced one item per call, so the queue never bound them -- and
note they were already 5-8x faster than a wide write on
main. A caller that groups points perchannel before writing was therefore never hurt by this bug, and gains CPU here rather than
throughput. A caller using
enqueue_from_dictwas hit hardest, and gains the most.Array-valued channels write one point per call, so they sit on the same per-call path as scalar
enqueueand gain accordingly. The gain shrinks as the array widens -- 1.6x at width 8 down to1.45x at width 512 -- which is what a removed fixed per-call cost should do as the payload per
call grows. In element terms the wide case moves 136 M elements/s after, against 94 M before.
The practical guidance is unchanged by this PR and worth stating: batch per channel where you
can.
groupedremains the fastest scalar shape at 3.8 Mp/s, and a wide array is the mostefficient way to move bulk volume.
What is fundamentally different
Every point used to cross a hardcoded
bounded(4)tokio mpsc channel into a single forwardingtask. That cost a thread park/unpark round trip per item, not per call — so
enqueue_from_dict, the API that looks like a bulk write, was the worst case in the package: oneboundary crossing but one item per channel, and measurably slower than a python loop over
enqueue.The channel bought nothing.
NominalDatasetStream::enqueueis synchronous andSync(confirmedwith a compile probe), so python threads now call it directly under
py.detach(). The tokioruntime stays, but only for the uploader.
Two consequences beyond raw speed:
instead of serializing through one task.
4-deep queue is full.
An earlier analysis attributed the fixed per-call cost to the Python->Rust boundary and proposed
"fewer, larger calls". The numbers reproduce; the attribution does not.
enqueue_from_dictwith6,480 channels is one pyo3 crossing and 6,480 items: if the boundary were the cost it
would take ~119us, and it took 10,923us. The lever was item count.
Is it safe
Yes, and it fixes two ways the old code could lose data.
mainacross scalars, series, wide dicts, tags, structs,arrays and datetime timestamps, compared by digest.
writes and drains what is already enqueued. Verified against a live process with a real SIGINT:
10,442,916 points enqueued single-threaded and 10,442,916 in the file (exit in 3.4s); 1,498,653
across four writer threads and 1,498,653 in the file, with all four threads correctly refused.
Refusing first is what makes the drain converge —
KeyboardInterruptreaches only the mainthread, so workers would otherwise keep feeding the buffer indefinitely.
pyclass had no
Drop; dropping the stream drains it, and pyclass deallocation holds the GIL.The new
Dropreleases it. Verified: 400,000 points all written, 0.54s, off the GIL.Arc, so the drain cannot silently become ano-op if something later clones it.
cargo test, mypy, ruff and clippy are clean (one pre-existing clippy warning atlib.rs:267).The two commits are not independently safe and should land together: with only the perf
commit, Ctrl+C on a multi-threaded writer deadlocks, because
close()needs&mut selfandcannot take the pyclass borrow while workers sit inside
enqueueholding it. Confirmed by test —the process hung rather than draining. #321 was folded in here for that reason.
Smaller things included
None, so a channel written withtags=Noneand onewritten with
tags={}share a series rather than splitting. Identical on the wire.copying the caller's mapping in
enqueue_from_dict, which can hold thousands of keys.open()raises when the stream fails to build, rather than logging and returning a stream thatrejects every write.
Deliberately not here
ChannelDescriptorin the core crate. Separate PR.enqueue_many, worth a further ~7% on wide records, is a core-crate addition. Separate PR.timer. Core crate, and it currently costs 4-5% throughput at saturation. Separate PR.
Known, not introduced here
open()installs a SIGINT handler whose closure capturesself, so the signal module holds astrong reference and a main-thread stream is never garbage collected. Wants a weakref.
max_points_per_recordis a soft limit under concurrency:when_capacityreads the countwithout the lock. Unchanged code, but it had one caller before this PR and now has N. Overshoot
is bounded per racing writer and the buffer already tolerates exceeding its cap.
f64::extractaccepts anything with__float__), losing precision above 2^53.🤖 Generated with Claude Code