feat(data): SPSC ring buffer, incremental min/max LOD pyramid, series types
This commit is contained in:
1 parent
c93c98002b
commit
138fa478c3
15 files changed
+1182
-8
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user