Skip to content

perf: enqueue directly into the stream, and share channel tag maps - #317

Closed
drake-nominal wants to merge 3 commits into
mainfrom
perf/direct-enqueue
Closed

perf: enqueue directly into the stream, and share channel tag maps#317
drake-nominal wants to merge 3 commits into
mainfrom
perf/direct-enqueue

Conversation

@drake-nominal

@drake-nominal drake-nominal commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Two commits, both aimed at making bulk writes cost what they should. Measured against a local
file target (no uploader) at --release on both sides.

What was wrong

Every enqueued point crossed a hardcoded bounded(4) tokio mpsc channel drained by a single
forwarding task, costing a park/unpark round-trip per EnqueueItem rather than per call. That
made enqueue_from_dict — one boundary crossing, but one item per channel — no faster than looping
over enqueue, and measurably slower on a 6,480-channel record (10,923us vs 8,964us).

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.

With that gone, the next cost became visible: a wide record is thousands of channels sharing one
timestamp and one set of tags, but ChannelDescriptor owned its tags, so each channel copied the
map — once building the descriptor, once more when the buffer first saw the channel.

Results

Measured on a local file target, --release on both sides. Numbers were taken on a workstation
with 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:

shape before after
6,480 channels @ one timestamp 10,766us 714us 15.1x
10,000 points @ one channel 576us 182us 3.2x
single point 1.86us 0.22us 8.4x

Realistic workload -- 6,480 tagged channels at 10Hz, median of 200 samples per call:

max_request_delay main + direct enqueue + shared tags
0.1s 10,462us 4,260us 1,532us 6.8x
1.0s 12,224us 6,011us 2,756us 4.4x

Both commits carry their weight at both settings: direct enqueue is 2.0-2.5x, shared tags a
further 2.2-2.8x.

Commits

commit what
d38cb9d Delete the ingest channel; python calls stream.enqueue directly. Add enqueue_many so a wide record takes one capacity reservation and one buffer lock.
8c8fbf6 ChannelDescriptor::tags becomes Option<Arc<BTreeMap<..>>>, so a wide record shares one tag map instead of copying it per channel. Breaking for direct field access.
bce5cc2 Chunk enqueue_many to max_points_per_record, bounding both request size and lock hold time; own the stream rather than holding it in an Arc, so the drain cannot become a silent no-op.

Also in here

  • 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.
  • Python-side: skip timestamp normalization when the caller already passed integral nanoseconds,
    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 stream
    that rejects every write.
  • Concurrent ingest from several python threads now contends only on the buffer lock, rather than
    serializing through one forwarding task.

Breaking

ChannelDescriptor::tags is now Option<Arc<BTreeMap<String, String>>>. new() and with_tags()
are unaffected; only code reading or assigning the field directly needs a change.

Verification

  • Avro round-trip output is byte-identical to main across every write shape: scalars, series,
    wide dicts, tags, structs, arrays, and non-integral timestamps.
  • 21 cargo tests pass, including two new ones covering enqueue_many.
  • 8-thread concurrent ingest lands exactly 40,400 points, no loss or duplication.
  • ruff, mypy, and clippy clean (one pre-existing clippy warning at lib.rs:267, untouched).

Known follow-up

Per-call cost is about 1.8x higher at max_request_delay=1.0s than at 0.1s (2,756us vs
1,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_processor parks for the full
max_request_delay after each flush and is only woken early by a write that no longer fits, so a
writer 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

drake-nominal and others added 2 commits August 26, 2026 14:52
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
drake-nominal marked this pull request as draft August 26, 2026 22:32
@drake-nominal

Copy link
Copy Markdown
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.

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