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
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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