feat(data): SPSC ring buffer, incremental min/max LOD pyramid, series types

This commit is contained in:
Martino Ferrari committed 2026-09-27 21:29:59 +02:00
1 parent c93c98002b
commit 138fa478c3
15 files changed
+1182 -8

No files matched your search

+43
View File
@@ -0,0 +1,43 @@
#pragma once
#include <vector>
#include <plot/core/types.hpp>
#include <plot/data/series.hpp>
namespace plot::data {
/// Series over a uniform-dx sample vector.
///
/// The vector is taken by value and owned from then on, so the caller cannot
/// mutate the samples behind the series' back; the store changes only through
/// reset() (a whole new vector) or commit(), which rebuilds the pyramid and the
/// cached bounds for whatever y_ currently holds.
class ArraySeries final : public Series {
public:
ArraySeries(std::vector<f32> y, f64 x0, f64 dx);
/// Replaces samples and grid, then rebuilds derived state (== commit()).
void reset(std::vector<f32> y, f64 x0, f64 dx);
/// Rebuilds the pyramid and cached bounds from y_ as it stands now.
void commit();
u64 size() const override;
f64 x0() const override;
f64 dx() const override;
Range x_bounds() const override;
Range y_bounds() const override;
const MinMaxPyramid& pyramid() const override;
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const override;
protected:
void copy_raw(u64 begin, u64 end, f32* out) const override;
private:
std::vector<f32> y_;
f64 x0_ = 0.0;
f64 dx_ = 1.0;
MinMaxPyramid pyramid_;
Range x_bounds_{};
Range y_bounds_{};
};
} // namespace plot::data
+82
View File
@@ -0,0 +1,82 @@
#pragma once
#include <span>
#include <vector>
#include <plot/core/types.hpp>
namespace plot::data {
/// Aggregate of one bucket: the extremes of the samples it covers.
struct MinMax {
f32 min = 0.0f;
f32 max = 0.0f;
};
/// Samples per level-0 bucket. Fixed so the LOD geometry is identical for every
/// series and the level maths is a shift.
inline constexpr u32 kBucket = 64;
/// Incremental min/max LOD pyramid.
///
/// 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.
///
/// A pyramid stores *aggregates*, so it can only answer exactly for whole
/// buckets; a range with partial edge buckets would silently pull in samples
/// outside the range. Every query here is therefore bucket-grained and named
/// *_buckets, and exact range queries live on Series, which owns the raw samples.
/// reduce() is the one non-*_buckets query: it is the *covering* aggregate over
/// the level-0 buckets that [begin, end) touches. Use it to ask which buckets a
/// range spans, never to measure a range.
class MinMaxPyramid {
public:
MinMaxPyramid() { add_level(); }
/// Sizes every level (buckets and prefix sums) for `n` samples so a following
/// run of appends performs no reallocation. The pyramid itself stays empty.
void reserve(u64 n);
void append(f32 v);
void clear();
u64 size() const noexcept { return size_; }
/// 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.
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;
/// Sum of squares of the samples in those same whole buckets.
f64 sumsq_buckets(u64 begin, u64 end) const;
/// Min/max over the covering level-0 buckets: every bucket that holds a sample
/// of [begin, end). Exact only when the range is bucket-aligned.
Range reduce(u64 begin, u64 end) const;
/// Pyramid level whose buckets hold about `samples_per_pixel` samples, for a
/// window of `n` samples: 0 when a pixel covers at most one bucket. Clamped to
/// the levels that exist, to levels with more than one bucket, and to levels
/// that give the window at least two buckets.
u32 level_for(u64 n, f64 samples_per_pixel) const;
/// Index of the sample nearest x on the uniform grid (x0 + i*dx), clamped to
/// [0, size() - 1]. Returns 0 for an empty pyramid or a non-positive dx.
u64 snap(f64 x, f64 x0, f64 dx) const;
private:
void add_level();
u64 size_ = 0;
u64 reserved_n_ = 0;
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
};
} // namespace plot::data
+101
View File
@@ -0,0 +1,101 @@
#pragma once
#include <algorithm>
#include <atomic>
#include <bit>
#include <cstddef>
#include <vector>
#include <plot/core/types.hpp>
namespace plot::data {
/// Single-producer / single-consumer ring of at most `capacity` elements.
///
/// The capacity is rounded up to a power of two so slot addressing is a mask
/// instead of a modulo. The producer writes a slot and then publishes the new
/// sample count with a release store; a consumer that observes that count with an
/// acquire load is guaranteed to see the slot contents written before it.
///
/// A push into a full ring silently discards the oldest element: for a live
/// plotting stream the newest sample is the interesting one and the retained
/// window slides forward.
///
/// Two indexing conventions, deliberately named apart:
/// - retained-window (operator[], index 0 = oldest element still held) is a
/// single-threaded view: it re-derives the window from the live count, so a
/// producer pushing concurrently would move it under the reader's feet.
/// - absolute (base(), copy_from()) fixes one count for a whole read, which is
/// what a consumer holding a snapshot must use. StreamSeries::acquire() takes
/// such a snapshot and then reads only absolute indices.
template <typename T>
class RingBuffer {
public:
explicit RingBuffer(u64 capacity)
: cap_(std::bit_ceil(capacity < 1 ? u64{1} : capacity)),
mask_(cap_ - 1),
buf_(static_cast<usize>(cap_)) {}
/// Producer thread only.
void push(T v) {
const u64 i = produced_.load(std::memory_order_relaxed);
buf_[static_cast<usize>(i & mask_)] = v;
produced_.store(i + 1, std::memory_order_release);
}
/// Producer thread only. Equivalent to n calls to push().
void push(const T* vs, u64 n) {
for (u64 i = 0; i < n; ++i) push(vs[i]);
}
/// Consumer: retained element count, saturating at capacity().
u64 size() const noexcept {
const u64 p = produced_.load(std::memory_order_acquire);
return p < cap_ ? p : cap_;
}
/// Consumer: total number of elements ever pushed (monotonic).
u64 produced() const noexcept { return produced_.load(std::memory_order_acquire); }
u64 capacity() const noexcept { return cap_; }
/// Consumer: absolute index of the oldest element still retained. 0 until the
/// ring has wrapped.
u64 base() const noexcept {
const u64 p = produced_.load(std::memory_order_acquire);
return p > cap_ ? p - cap_ : 0;
}
/// Single-threaded: retained-window index (0 = oldest retained). `i` must be
/// < size().
const T& operator[](u64 i) const noexcept {
return buf_[static_cast<usize>((base() + i) & mask_)];
}
/// Consumer: copies the `n` elements at absolute indices [absolute, absolute+n)
/// into `out`, splitting the copy at the ring's wrap point. The caller must hold
/// a snapshot that retains those indices; this never consults the live count, so
/// a pushing producer cannot shift the read.
void copy_from(u64 absolute, u64 n, T* out) const {
if (n == 0) return;
const u64 s = absolute & mask_;
const u64 run = std::min(n, cap_ - s);
const auto first = buf_.begin() + static_cast<std::ptrdiff_t>(s);
std::copy(first, first + static_cast<std::ptrdiff_t>(run), out);
if (run < n) {
std::copy(buf_.begin(), buf_.begin() + static_cast<std::ptrdiff_t>(n - run),
out + static_cast<std::ptrdiff_t>(run));
}
}
/// Consumer thread only: discards every element (not safe while a producer is
/// pushing).
void clear() noexcept { produced_.store(0, std::memory_order_relaxed); }
private:
u64 cap_;
u64 mask_;
std::vector<T> buf_;
std::atomic<u64> produced_{0};
};
} // namespace plot::data
+74
View File
@@ -0,0 +1,74 @@
#pragma once
#include <vector>
#include <plot/core/types.hpp>
#include <plot/data/minmax_pyramid.hpp>
namespace plot::data {
/// A uniform-grid series of y samples: sample i lives at x = x0() + i*dx().
///
/// Every concrete series answers range queries exactly, and never by scanning the
/// whole range: the bucket-aligned interior comes from the pyramid (O(log n)
/// bucket aggregates) and at most two partial edge buckets are read through
/// copy_raw() and scanned. Cost is independent of series length and no query
/// allocates.
///
/// Indexing is over the series' own window: index 0 is the oldest sample the
/// series currently holds (for a ring-backed series that is the oldest retained
/// one), so reduce(start, end) and gather() are in the same coordinates as
/// size(), x0() and pyramid().
class Series {
public:
virtual ~Series() = default;
virtual u64 size() const = 0;
/// x of sample 0, i.e. of the oldest sample currently held.
virtual f64 x0() const = 0;
/// Uniform x spacing between consecutive samples.
virtual f64 dx() const = 0;
virtual Range x_bounds() const = 0;
virtual Range y_bounds() const = 0;
virtual const MinMaxPyramid& pyramid() const = 0;
/// Appends interleaved (x, y) pairs in data coordinates for bucket-aligned
/// level `level`; level 0 emits raw samples. begin/end are expected to be
/// bucket-aligned at that level; an unaligned end is widened to the bucket that
/// holds it (so passing end == size() includes the trailing partial bucket).
/// Pairs are appended to out_xy, which is never cleared.
virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const = 0;
/// Exact min/max, sum and sum-of-squares over [begin, end).
/// Preconditions: begin < end, end <= size(); a degenerate range yields a
/// zero-span Range and zero sums. Implemented once, non-virtually, over
/// copy_raw() in src/data/series.cpp.
Range reduce(u64 begin, u64 end) const;
f64 sum(u64 begin, u64 end) const;
f64 sumsq(u64 begin, u64 end) const;
protected:
/// Copies raw y samples for [begin, end) into `out`, which the caller sized to
/// (end - begin). Handles ring wraparound. Only ever called with the small edge
/// spans of the queries above, never on a per-sample path. Out-of-range spans
/// are clamped and the unwritten tail is zero-filled.
virtual void copy_raw(u64 begin, u64 end, f32* out) const = 0;
/// The shared body of the concrete gather() overrides: raw samples at level 0,
/// bucket min/max envelope above it.
void gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const;
private:
/// [begin, end) split into the bucket-aligned interior the pyramid can answer
/// exactly, plus the at most two partial edge spans it cannot.
struct QueryParts {
u64 interior_begin = 0, interior_end = 0; // whole level-0 buckets, may be empty
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);
/// 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;
};
} // namespace plot::data
+77
View File
@@ -0,0 +1,77 @@
#pragma once
#include <vector>
#include <plot/core/types.hpp>
#include <plot/data/minmax_pyramid.hpp>
#include <plot/data/ring_buffer.hpp>
#include <plot/data/series.hpp>
namespace plot::data {
/// Single-producer / single-consumer streaming series.
///
/// The producer thread only ever calls push()/push(ys, n) (and produced()): those
/// touch the ring buffer and nothing else -- no lock, no pyramid, no GL, so the
/// acquisition thread can never stall the renderer. The consumer thread calls
/// acquire() to publish a coherent snapshot, then queries it.
///
/// 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()).
///
/// x0() is therefore the x of the oldest retained sample, x_bounds().min == x0(),
/// and y_bounds() covers exactly the published samples.
///
/// THREADING NOTE. The ring overwrites the oldest samples -- that is what keeps a
/// live stream showing the newest data, and what the drop test pins down -- so a
/// producer racing past capacity() can write the very slot the consumer is reading
/// at the window's oldest edge. That read is not ordered against the write, so a
/// race detector flags it: the value is still a single f32 (no tearing on
/// supported targets) and the worst case is one stale or too-new sample at the
/// oldest edge, corrected by the next acquire() once it sees the base moved. The
/// pyramid itself never mixes windows, because acquire() derives the whole window
/// from one count. Ingest that is race-free even at the edge 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.
class StreamSeries final : public Series {
public:
explicit StreamSeries(u64 capacity, f64 x0, f64 dx);
/// Producer thread only.
void push(f32 y);
/// Producer thread only.
void push(const f32* ys, u64 n);
/// Producer thread only: number of samples ever pushed (monotonic).
u64 produced() const;
/// Consumer thread only: snapshots the produced count and brings the pyramid
/// and bounds up to it.
void acquire();
u64 size() const override;
f64 x0() const override;
f64 dx() const override;
Range x_bounds() const override;
Range y_bounds() const override;
const MinMaxPyramid& pyramid() const override;
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const override;
protected:
void copy_raw(u64 begin, u64 end, f32* out) const override;
private:
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
MinMaxPyramid pyramid_;
Range y_bounds_{};
};
} // namespace plot::data