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).
94 lines
3.5 KiB
C++
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
|