Files
plotter/include/plot/data/stream_series.hpp
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

91 lines
4.1 KiB
C++

#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.
///
/// The pyramid behind those queries is indexed in absolute sample space, so a
/// window that slides only makes it drop the leading buckets the ring has
/// overwritten and append the samples produced since the last acquire(): acquire()
/// costs O(samples produced), not O(capacity()), and the aggregates that stay in
/// the window are never recomputed. The one exception is a producer that laps the
/// consumer -- more than capacity() samples between two acquires -- where the
/// samples in between were never appended and the pyramid restarts at the new
/// base, so that acquire re-reads the whole window.
///
/// 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 can overwrite the slots a consumer is reading even while it holds a
/// snapshot. Those slots are relaxed atomics (see RingBuffer), so this is not a
/// data race and no value tears; what it is not is a frozen window.
///
/// The bound is the sample count, not the geometry. A snapshot count fixes *which
/// absolute indices* a read walks, not that the slots still hold those samples:
/// every push after the snapshot overwrites exactly one slot, the one belonging to
/// the sample capacity() positions behind it. So if the producer pushes k samples
/// while a read is in flight, the first k positions of that read window can hold
/// one of those k newer samples (or a still-newer sample, once the producer has
/// lapped again) instead of the sample the snapshot names. Nothing else moves: the
/// count, the absolute index -> x mapping and the newest end of the window are
/// derived from one published count, and the next acquire() slides the pyramid's
/// window to the base it observes (dropping the leading buckets, appending what is
/// new), so the pyramid never mixes two windows. Ingest with no reuse
/// underneath a read at all 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;
// The pyramid owns the published window: its window_base() is the absolute
// index of retained index 0 and its size()/window_end() are the snapshot's
// extent, so there is no second copy of that state to keep in step.
MinMaxPyramid pyramid_;
Range y_bounds_{};
};
} // namespace plot::data