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:
Martino Ferrari committed 2026-09-28 00:05:15 +02:00
1 parent 4b649a2783
commit 0ac5d47fa6
13 files changed
+769 -244

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
#pragma once
#ifdef PLOT_PYRAMID_COUNT_MERGES
#include <atomic>
#include <plot/core/types.hpp>
namespace plot::data::detail {
/// Test-only instrumentation. Counts the bucket merges the append path has
/// performed since the counter was last reset: one for the bucket pushed at
/// level 0 when it completes, plus one for every parent bucket written above it.
///
/// The append path's *cost* is invisible in every answer -- a pyramid that walks
/// every live level per sample returns exactly the same min/max/sum as one that
/// stops at the first level that gained no entry -- so a test that wants to pin
/// "amortised O(1) per sample, independent of the number of live levels" has to
/// count merges. The counter exists only when PLOT_PYRAMID_COUNT_MERGES is
/// defined (the test build defines it), so every normal build carries no counter
/// and no increment on the append path.
///
/// Not part of the query surface and not meant for production use.
inline std::atomic<u64>& merge_count() {
static std::atomic<u64> count{0};
return count;
}
} // namespace plot::data::detail
#endif // PLOT_PYRAMID_COUNT_MERGES
+86 -21
View File
@@ -1,5 +1,5 @@
#pragma once
#include <span>
#include <deque>
#include <vector>
#include <plot/core/types.hpp>
@@ -16,13 +16,33 @@ struct MinMax {
/// series and the level maths is a shift.
inline constexpr u32 kBucket = 64;
/// Incremental min/max LOD pyramid.
/// Incremental min/max LOD pyramid, indexed in absolute bucket space.
///
/// Level 0 buckets kBucket consecutive samples and keeps one (min, max) per
/// bucket; level l buckets 2^l level-0 buckets. Memory is n/4 bytes -- about 6%
/// of 4-byte raw samples. append() merges into the last bucket at each level and
/// walks up the O(log n) chain above it, so building is amortised O(1) per sample
/// and nothing is ever recomputed from raw samples.
/// Level `l` bucket `k` covers the absolute samples
/// `[k * (kBucket << l), (k + 1) * (kBucket << l))`. Bucket boundaries are
/// therefore fixed for the pyramid's whole life: appending never re-buckets an
/// existing aggregate, and the window a caller reads can slide over the same
/// buckets instead of being rebuilt from the raw samples.
///
/// Each level holds its complete buckets in a deque (O(1) push_back *and*
/// pop_front) plus a `base`, the absolute level-`l` bucket index of its first
/// entry. `append()` folds a sample into the in-progress level-0 bucket and, when
/// that bucket completes, pushes it and walks up -- stopping at the first level
/// that gains no new entry. Level `l` gains one entry per `kBucket << l` samples,
/// so an append costs amortised O(1) and never walks every live level.
///
/// `drop_before(u)` slides the window's leading edge to absolute sample `u`: each
/// level discards the buckets whose span starts before `u`, so the memory is
/// bounded by the retained window (about n/4 bytes, ~6% of 4-byte raw samples)
/// and not by the number of samples ever appended. Every stored entry is the
/// complete aggregate of its own span and every stored span lies inside the
/// retained window, so no aggregate is ever recomputed or invalidated.
///
/// Queries are in *window* coordinates: index 0 is the oldest retained sample,
/// `size()` is the retained sample count, and `window_base()` is the absolute
/// index window index 0 refers to. That is the same window `Series` publishes,
/// which is why a `StreamSeries` can hand the pyramid a moving base and keep
/// answering in its own coordinates.
///
/// A pyramid stores *aggregates*, so it can only answer exactly for whole
/// buckets; a range with partial edge buckets would silently pull in samples
@@ -30,23 +50,49 @@ inline constexpr u32 kBucket = 64;
/// *_buckets, and exact range queries live on Series, which owns the raw samples.
class MinMaxPyramid {
public:
MinMaxPyramid() { add_level(); }
MinMaxPyramid() = default;
/// Sizes every level (buckets and prefix sums) for `n` samples so a following
/// run of appends performs no reallocation. The pyramid itself stays empty.
/// Sizes the level table for a window of `n` samples so a following run of
/// appends never grows the level vector. The bucket deques themselves grow in
/// chunks: a sliding window cannot know their final length up front.
void reserve(u64 n);
void append(f32 v);
/// Slides the retained window's leading edge to absolute sample index
/// `absolute_sample`, dropping every aggregate that covered a sample before it.
/// Monotonic for arguments ahead of the base; a no-op for arguments at or
/// behind it, and for an empty pyramid.
///
/// An argument past `window_end()` (a producer that outran the consumer by more
/// than the ring held) drops everything and restarts the window there: the
/// samples in between were never appended, so they are not representable.
void drop_before(u64 absolute_sample);
void clear();
u64 size() const noexcept { return size_; }
/// Samples in the retained window.
u64 size() const noexcept { return count_ - base_; }
/// Absolute index of the sample window index 0 refers to.
u64 window_base() const noexcept { return base_; }
/// Absolute index one past the last appended sample: the first sample index a
/// following append() will occupy.
u64 window_end() const noexcept { return count_; }
/// At least 1, even when empty, so level_count() - 1 is always valid.
u32 level_count() const noexcept { return live_levels_; }
/// Level `l`'s buckets; empty span when `l` is beyond level_count().
std::span<const MinMax> level(u32 l) const;
/// Min/max over the whole level-0 buckets fully contained in [begin, end).
/// Partial edge buckets are excluded, never approximated; an empty result is a
/// degenerate Range with span() == 0.
/// Absolute level-`l` bucket index of level `l`'s first entry, and how many
/// entries it holds. They cover `[level_base(l), level_base(l) + level_size(l))`:
/// entry `i` is the aggregate of the absolute samples
/// `[i * (kBucket << l), (i + 1) * (kBucket << l))`. A level that exists but
/// holds nothing, or a level past level_count(), reports size 0 (its base is
/// then meaningless).
u64 level_base(u32 l) const noexcept;
u64 level_size(u32 l) const noexcept;
/// Aggregate of the absolute level-`l` bucket `i`. Precondition:
/// level_base(l) <= i < level_base(l) + level_size(l).
MinMax bucket(u32 l, u64 i) const;
/// Min/max over the whole level-0 buckets fully contained in the window range
/// [begin, end). Partial edge buckets are excluded, never approximated; an
/// empty result is a degenerate Range with span() == 0.
Range reduce_buckets(u64 begin, u64 end) const;
/// Sum of the samples in those same whole buckets, accumulated in double.
f64 sum_buckets(u64 begin, u64 end) const;
@@ -62,13 +108,32 @@ class MinMaxPyramid {
u64 snap(f64 x, f64 x0, f64 dx) const;
private:
void add_level();
/// One bucket: its extremes plus the raw-sum aggregates Series folds over.
struct Entry {
MinMax mm;
f64 sum = 0.0;
f64 sumsq = 0.0;
};
/// One level's complete buckets, in absolute bucket-index order.
struct Level {
std::deque<Entry> entries;
u64 base = 0; ///< absolute level-l bucket index of entries.front()
};
u64 size_ = 0;
u64 reserved_n_ = 0;
void add_level();
/// Appends `e` at absolute level-`l` bucket `k`, which must be one past the
/// level's last entry.
void push(u32 l, u64 k, const Entry& e);
/// Entry for absolute level-`l` bucket `i`; the caller has checked the range.
const Entry& at(u32 l, u64 i) const {
return levels_[l].entries[static_cast<usize>(i - levels_[l].base)];
}
u64 count_ = 0; ///< absolute index one past the last appended sample
u64 base_ = 0; ///< absolute index of the window's oldest sample
u32 live_levels_ = 1;
std::vector<std::vector<MinMax>> levels_; // levels_[0] = kBucket granularity
std::vector<std::vector<f64>> sums_, sumsqs_; // inclusive prefix sums per level
std::vector<Level> levels_{1};
Entry pending_; ///< the in-progress level-0 bucket, not yet an entry
};
} // namespace plot::data
+3 -1
View File
@@ -65,7 +65,9 @@ class Series {
u64 left_begin = 0, left_end = 0; // partial head bucket
u64 right_begin = 0, right_end = 0; // partial tail bucket
};
static QueryParts split_query(u64 begin, u64 end);
/// Needs the pyramid's window base: the bucket grid it aligns to is absolute,
/// while begin/end are window coordinates.
QueryParts split_query(u64 begin, u64 end) const;
/// Reads both edge spans through copy_raw() into `out` (which must hold at
/// least 2*kBucket samples) and returns how many were written.
u64 copy_edges(const QueryParts& parts, f32* out) const;
+16 -11
View File
@@ -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_{};
};