Files
plotter/src/data/stream_series.cpp
T
Martino Ferrari 0ac5d47fa6 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).
2026-09-28 00:05:15 +02:00

94 lines
3.5 KiB
C++

#include <plot/data/stream_series.hpp>
#include <algorithm>
#include <cstddef>
namespace plot::data {
namespace {
/// 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) {
// 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); }
void StreamSeries::push(const f32* ys, u64 n) { ring_.push(ys, n); }
u64 StreamSeries::produced() const { return ring_.produced(); }
void StreamSeries::acquire() {
// One atomic read: every absolute index below is relative to this single count,
// so the producer may keep pushing without the snapshot shifting under us.
const u64 p = ring_.produced();
const u64 cap = ring_.capacity();
const u64 base = p > cap ? p - cap : 0; // oldest index the ring still holds
// 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;
}
// 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 pyramid_.size(); }
f64 StreamSeries::x0() const { return x0_ + f64(pyramid_.window_base()) * dx_; }
f64 StreamSeries::dx() const { return dx_; }
Range StreamSeries::x_bounds() const {
const f64 n = f64(size());
return n == 0.0 ? Range{x0(), x0()} : Range{x0(), x0() + (n - 1.0) * dx_};
}
Range StreamSeries::y_bounds() const { return y_bounds_; }
const MinMaxPyramid& StreamSeries::pyramid() const { return pyramid_; }
void StreamSeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const {
gather_impl(level, begin, end, out_xy);
}
void StreamSeries::copy_raw(u64 begin, u64 end, f32* out) const {
const u64 want = end > begin ? end - begin : 0;
if (want == 0) return;
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 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);
}
} // namespace plot::data