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

+35 -57
View File
@@ -6,14 +6,18 @@
namespace plot::data {
namespace {
/// Samples copied per ring read on the ingest path, so rebuilding a saturated
/// window costs no allocation.
/// Samples copied per ring read on the ingest path, so appending the samples
/// produced between two frames costs no allocation.
constexpr u64 kReadChunk = 256;
} // namespace
StreamSeries::StreamSeries(u64 capacity, f64 x0, f64 dx)
: ring_(capacity), x0_(x0), dx_(dx) {}
: ring_(capacity), x0_(x0), dx_(dx) {
// Size the pyramid's level table for a full window up front: the ring never
// holds more than capacity() samples, so appending never grows it.
pyramid_.reserve(capacity);
}
void StreamSeries::push(f32 y) { ring_.push(y); }
@@ -27,62 +31,35 @@ void StreamSeries::acquire() {
const u64 p = ring_.produced();
const u64 cap = ring_.capacity();
const u64 base = p > cap ? p - cap : 0; // oldest index the ring still holds
const u64 n = p - base; // retained, published sample count
if (base != base_) {
// The window slid: samples the pyramid covered are gone, and because bucket
// boundaries are absolute, extending it incrementally would mis-align every
// bucket. Rebuild from the retained samples instead.
pyramid_.clear();
pyramid_.reserve(n);
f32 lo = 0.0f, hi = 0.0f;
bool first = true;
f32 chunk[kReadChunk];
for (u64 i = 0; i < n;) {
const u64 take = std::min<u64>(kReadChunk, n - i);
ring_.copy_from(base + i, take, chunk);
for (u64 k = 0; k < take; ++k) {
const f32 v = chunk[k];
pyramid_.append(v);
if (first) {
lo = v;
hi = v;
first = false;
} else {
lo = std::min(lo, v);
hi = std::max(hi, v);
}
}
i += take;
}
y_bounds_ = first ? Range{0.0, 0.0} : Range{f64(lo), f64(hi)};
base_ = base;
} else {
// Contiguous: only the samples produced since the last acquire() are new.
if (n == 0) y_bounds_ = Range{0.0, 0.0};
f32 chunk[kReadChunk];
for (u64 i = end_ - base_; i < n;) {
const u64 take = std::min<u64>(kReadChunk, n - i);
ring_.copy_from(base + i, take, chunk);
for (u64 k = 0; k < take; ++k) {
const f32 v = chunk[k];
pyramid_.append(v);
if (i + k == 0) {
y_bounds_ = Range{f64(v), f64(v)};
} else {
y_bounds_.min = std::min(y_bounds_.min, f64(v));
y_bounds_.max = std::max(y_bounds_.max, f64(v));
}
}
i += take;
}
// Slide the pyramid's window: the buckets that covered samples the ring has
// overwritten are dropped, and the rest stay exactly as they were built --
// their boundaries are absolute, so nothing has to be recomputed. This is also
// where a producer that lapped the consumer by more than the ring holds is
// handled: the pyramid restarts at the new base.
pyramid_.drop_before(base);
// Append exactly the samples produced since the last acquire(). Normally that
// is one frame's worth; after a lapped producer it is the whole window.
f32 chunk[kReadChunk];
for (u64 a = pyramid_.window_end(); a < p;) {
const u64 take = std::min<u64>(kReadChunk, p - a);
ring_.copy_from(a, take, chunk);
for (u64 k = 0; k < take; ++k) pyramid_.append(chunk[k]);
a += take;
}
end_ = p;
// Exact bounds for the published window, in the same O(log n + kBucket) as any
// other exact query over it: the bucket-aligned interior comes from the
// pyramid and the two partial edge spans are scanned through copy_raw(). An
// incrementally extended bound cannot work here -- it would have to know
// whether the extreme it is holding has just slid out of the window.
y_bounds_ = reduce(0, size());
}
u64 StreamSeries::size() const { return end_ - base_; }
u64 StreamSeries::size() const { return pyramid_.size(); }
f64 StreamSeries::x0() const { return x0_ + f64(base_) * dx_; }
f64 StreamSeries::x0() const { return x0_ + f64(pyramid_.window_base()) * dx_; }
f64 StreamSeries::dx() const { return dx_; }
@@ -105,9 +82,10 @@ void StreamSeries::copy_raw(u64 begin, u64 end, f32* out) const {
const u64 n = size();
const u64 b = std::min(begin, n);
const u64 e = std::min(std::max(end, b), n);
// Retained index b is absolute base_ + b: fixed for the whole copy, so a
// pushing producer cannot shift the window midway.
ring_.copy_from(base_ + b, e - b, out);
// Retained index b is absolute window_base() + b: fixed for the whole copy, so
// a pushing producer cannot shift the window midway (the pyramid is written by
// this thread alone, so its window cannot move under the read either).
ring_.copy_from(pyramid_.window_base() + b, e - b, out);
std::fill(out + static_cast<std::ptrdiff_t>(e - b),
out + static_cast<std::ptrdiff_t>(want), 0.0f);
}