perf: enqueue directly into the stream, and share channel tag maps - #317
Closed
drake-nominal wants to merge 3 commits into
Closed
perf: enqueue directly into the stream, and share channel tag maps#317drake-nominal wants to merge 3 commits into
drake-nominal wants to merge 3 commits into
Conversation
Every enqueued point crossed a `bounded(4)` tokio mpsc channel drained by a single
forwarding task, costing a park/unpark round-trip per `EnqueueItem`. That cost was
per-item rather than 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.
`NominalDatasetStream::enqueue` is synchronous and `Sync`, so the channel bought
nothing: python threads can call it directly under `py.detach()`. The tokio runtime
stays, but only for the uploader. This also lets several python threads ingest
concurrently, contending on the stream's buffer lock rather than serializing through
one task.
Adds `NominalDatasetStream::enqueue_many` so a wide record takes one capacity
reservation and one buffer lock for the whole batch rather than one per channel.
Measured against a local file target (release build), before -> after:
6,480 channels @ one timestamp 10,923 us -> 903 us 12.1x
1,000 points @ one channel 61.6 us -> 22.5 us 2.7x
single point 1.62 us -> 0.25 us 6.5x
Also:
- normalize absent and empty tags to `None`, so a channel written with `tags=None`
and with `tags={}` shares one series instead of splitting into two
- skip timestamp normalization in python when the caller already passed integral
nanoseconds, and stop copying the caller's mapping in `enqueue_from_dict`
- surface stream build failures from `open()` rather than logging them and leaving
a stream that rejects every write
Avro round-trip output is byte-identical to before across all write shapes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… channel A wide record is thousands of channels written at one timestamp, all carrying the same tags. Because `ChannelDescriptor` owned its tags, each of those channels copied the map: once building the descriptor, once more when the buffer first saw that channel. On a 6,480-channel tagged record that was the dominant remaining cost after removing the ingest channel -- 3,939us against 701us for the same record untagged. Making `tags` an `Arc<BTreeMap<..>>` turns both copies into a refcount bump. The protobuf `Series` still owns its tags, so the map is copied out at flush time, but that is once per channel per flush rather than once per channel per write. Measured on a 6,480-channel tagged record (release, local file target): amortized over ~38 writes per flush 3,939us -> 1,039us 3.8x one flush per write 6,766us -> 2,060us 3.3x BREAKING CHANGE: `ChannelDescriptor::tags` is now `Option<Arc<BTreeMap<String, String>>>` rather than `Option<BTreeMap<String, String>>`. `ChannelDescriptor::new` and `::with_tags` are unaffected; only code reading or assigning the field directly needs to change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…utright Two hazards introduced by enqueueing directly into the stream. `enqueue_many` admitted a whole batch under one capacity reservation, so a single call could build a request of any size and hold the buffer lock for as long as that took. `has_capacity` deliberately lets an oversized batch into an empty buffer, which is right for one channel's series but wrong for a batch assembled from thousands of channels -- nothing bounded how large that batch could get. It now admits in pieces that fit `max_points_per_record`, which bounds both the request size and the lock hold time. A batch that already fits is still admitted whole, so the wide record this exists for keeps its guarantee that all its channels share a request, and a single channel carrying more than a record is still not split. The python bindings held the stream in an `Arc`, so `close` draining depended on nobody else having cloned it. Nothing did, but a future clone would have turned the drain into a silent no-op and torn the runtime down with points still buffered. Owning the stream makes the drain a property of the type rather than a convention. Avro round-trip output remains byte-identical to before the perf work, and the benchmarks are unchanged or slightly better (6,480-channel record: 932us -> 715us). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
drake-nominal
marked this pull request as draft
August 26, 2026 22:32
Contributor
Author
|
Superseded by a split into independently reviewable PRs, so the core-crate changes can be judged on their own terms:
All numbers here were also re-measured against the real staging backend rather than a local file target, with delivery verified by reading the data back out. |
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.
Two commits, both aimed at making bulk writes cost what they should. Measured against a local
file target (no uploader) at
--releaseon both sides.What was wrong
Every enqueued point crossed a hardcoded
bounded(4)tokio mpsc channel drained by a singleforwarding task, costing a park/unpark round-trip per
EnqueueItemrather than per call. Thatmade
enqueue_from_dict— one boundary crossing, but one item per channel — no faster than loopingover
enqueue, and measurably slower on a 6,480-channel record (10,923us vs 8,964us).The channel bought nothing:
NominalDatasetStream::enqueueis synchronous andSync, so pythonthreads can call it directly under
py.detach(). The tokio runtime stays, but only for the uploader.With that gone, the next cost became visible: a wide record is thousands of channels sharing one
timestamp and one set of tags, but
ChannelDescriptorowned its tags, so each channel copied themap — once building the descriptor, once more when the buffer first saw the channel.
Results
Measured on a local file target,
--releaseon both sides. Numbers were taken on a workstationwith other load present; repeating a measurement on the same build moves the median by 11-17%, so
treat one significant figure as meaningful and ignore differences smaller than that.
Three write shapes, min of 7:
Realistic workload -- 6,480 tagged channels at 10Hz, median of 200 samples per call:
Both commits carry their weight at both settings: direct enqueue is 2.0-2.5x, shared tags a
further 2.2-2.8x.
Commits
d38cb9dstream.enqueuedirectly. Addenqueue_manyso a wide record takes one capacity reservation and one buffer lock.8c8fbf6ChannelDescriptor::tagsbecomesOption<Arc<BTreeMap<..>>>, so a wide record shares one tag map instead of copying it per channel. Breaking for direct field access.bce5cc2enqueue_manytomax_points_per_record, bounding both request size and lock hold time; own the stream rather than holding it in anArc, so the drain cannot become a silent no-op.Also in here
None, so a channel written withtags=Noneand onewritten with
tags={}share a series instead of splitting into two. Identical on the wire.and stop copying the caller's mapping in
enqueue_from_dict(it can hold thousands of channels).open()now raises when the stream fails to build, rather than logging and returning a streamthat rejects every write.
serializing through one forwarding task.
Breaking
ChannelDescriptor::tagsis nowOption<Arc<BTreeMap<String, String>>>.new()andwith_tags()are unaffected; only code reading or assigning the field directly needs a change.
Verification
mainacross every write shape: scalars, series,wide dicts, tags, structs, arrays, and non-integral timestamps.
enqueue_many.lib.rs:267, untouched).Known follow-up
Per-call cost is about 1.8x higher at
max_request_delay=1.0sthan at 0.1s (2,756us vs1,532us), which is well outside the 11-17% repeat spread. At 0.1s there is essentially nothing
left on the table: a tagged wide write costs 1,532us against a 1,489us floor measured with
flushing switched off entirely.
The mechanism is identified but not yet fixed:
batch_processorparks for the fullmax_request_delayafter each flush and is only woken early by a write that no longer fits, so awriter that fills the buffer can block for the remainder of the delay. Two candidate fixes
(building the protobuf series off the buffer lock, and waking the processor at a fill threshold)
were tried and neither showed a reliable win; both are worth retrying, since the measurements that
rejected them were taken in a noisy window. That work is not part of this PR.
🤖 Generated with Claude Code