perf(data): absolute-space pyramid so acquire() is O(produced), not O(window)
Frame cost was O(capacity x levels) once the ring saturated: acquire() saw the window base move (it always moves once the ring wraps), cleared the pyramid and re-appended all 1,048,576 retained samples, and append() walked every live level with no early stop -- 13,647,936 bucket merges and 73.8 ms per frame at a 1M-sample window, scaling with the window, which is what capped the 1 MS/s demo at 32-58 ms/frame while it was still filling. Index the pyramid in absolute bucket space instead. Level l bucket k covers the samples [k * (kBucket << l), (k + 1) * (kBucket << l)), so a bucket's span never depends on where the window starts and the window can slide over the aggregate instead of forcing it to be rebuilt. Each level keeps its complete buckets in a deque plus the absolute base of its first entry; append() folds a sample into the single in-progress level-0 bucket and, only when that bucket completes, cascades upward, stopping at the first level that gains no new entry; a new drop_before() slides the window by discarding leading buckets and remembers where the window begins. acquire() is now: drop to the new base, then append exactly what was produced -- 256 merges and 0.034 ms per frame for 8192 new samples, independent of the window, and memory bounded by the window (32,767 entries after 50M samples through a 1M ring, the same as after 10M). A producer that laps the consumer by more than capacity() between two acquires restarts the window rather than splicing a gap. Interface: `level(l) -> std::span<const MinMax>` cannot survive a ring and is replaced by level_base/level_size/bucket in absolute bucket coordinates, plus window_base/window_end/drop_before. The *_buckets queries stay in window coordinates (index 0 = oldest retained sample), so Series keeps its window semantics for callers; its exactness split now aligns in absolute space (window_base() + the ceil/floor) because the bucket grid is absolute. The sums move from per-level prefix sums to per-bucket ones -- the only representation a sliding front can keep exact -- and StreamSeries::y_bounds() becomes reduce(0, size()), exact in O(log n + kBucket) per frame instead of a scan of the window. The pyramid owns the published window now, so there is no second copy of base/end to keep in step, and gather() emits only buckets that are both complete and wholly retained. Tests: test-only merge counters (mirroring PLOT_PYRAMID_COUNT_COVERS, absent from every non-test build) pin an acquire proportional to production and independent of capacity, an append cost amortised over 16 live levels, and bounded memory; brute-force checks pin absolute bucket aggregates across slides, sliding-window reduce/sum/sumsq over unaligned ranges, and the level-1 gather mapping. The demo now draws 946 samples/px at 1.03 ms/frame (967 fps) where the saturated window cost 54.96 ms/frame (18 fps).
This commit is contained in:
1 parent
4b649a2783
commit
0ac5d47fa6
13 files changed
+769
-244
No files matched your search
@@ -17,13 +17,16 @@ namespace plot::data {
|
||||
///
|
||||
/// Indexing is the *retained window published by the last acquire()*: size(),
|
||||
/// reduce(), sum(), sumsq(), gather() and pyramid() all use it, and index 0 is
|
||||
/// the oldest sample still held. Once the producer has written more than
|
||||
/// capacity() samples the oldest ones are gone, so acquire() notices that the
|
||||
/// window's base moved and rebuilds the pyramid from the retained samples -- an
|
||||
/// incremental append would splice a dropped prefix onto a retained suffix and
|
||||
/// mis-align every bucket (bucket boundaries are absolute, not relative to the
|
||||
/// window). Until the ring saturates, acquire() is O(new samples); once it wraps,
|
||||
/// an acquire whose base moved costs O(capacity()).
|
||||
/// the oldest sample still held.
|
||||
///
|
||||
/// The pyramid behind those queries is indexed in absolute sample space, so a
|
||||
/// window that slides only makes it drop the leading buckets the ring has
|
||||
/// overwritten and append the samples produced since the last acquire(): acquire()
|
||||
/// costs O(samples produced), not O(capacity()), and the aggregates that stay in
|
||||
/// the window are never recomputed. The one exception is a producer that laps the
|
||||
/// consumer -- more than capacity() samples between two acquires -- where the
|
||||
/// samples in between were never appended and the pyramid restarts at the new
|
||||
/// base, so that acquire re-reads the whole window.
|
||||
///
|
||||
/// x0() is therefore the x of the oldest retained sample, x_bounds().min == x0(),
|
||||
/// and y_bounds() covers exactly the published samples.
|
||||
@@ -42,8 +45,9 @@ namespace plot::data {
|
||||
/// one of those k newer samples (or a still-newer sample, once the producer has
|
||||
/// lapped again) instead of the sample the snapshot names. Nothing else moves: the
|
||||
/// count, the absolute index -> x mapping and the newest end of the window are
|
||||
/// derived from the one published count, and the next acquire() rebuilds from the
|
||||
/// base it observes, so the pyramid never mixes two windows. Ingest with no reuse
|
||||
/// derived from one published count, and the next acquire() slides the pyramid's
|
||||
/// window to the base it observes (dropping the leading buckets, appending what is
|
||||
/// new), so the pyramid never mixes two windows. Ingest with no reuse
|
||||
/// underneath a read at all needs the producer to stop before unread data
|
||||
/// (drop-on-full) or a claim protocol, i.e. a different contract from the one this
|
||||
/// class specifies.
|
||||
@@ -76,8 +80,9 @@ class StreamSeries final : public Series {
|
||||
RingBuffer<f32> ring_;
|
||||
f64 x0_ = 0.0;
|
||||
f64 dx_ = 1.0;
|
||||
u64 base_ = 0; // absolute index of retained index 0 in the published snapshot
|
||||
u64 end_ = 0; // absolute index one past the published snapshot
|
||||
// The pyramid owns the published window: its window_base() is the absolute
|
||||
// index of retained index 0 and its size()/window_end() are the snapshot's
|
||||
// extent, so there is no second copy of that state to keep in step.
|
||||
MinMaxPyramid pyramid_;
|
||||
Range y_bounds_{};
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user