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).
This commit is contained in:
Martino Ferrari committed 2026-09-28 00:05:15 +02:00
1 parent 4b649a2783
commit 0ac5d47fa6
13 files changed
+769 -244

No files matched your search

@@ -1063,12 +1063,20 @@ Take every quantity from `Series` (Task 3), never from the pyramid directly: `s.
```cpp
struct PlotLayout {
Rect axes; // plot area (excludes tick labels)
Rect y_label_area, x_label_area;
f32 y_axis_width = 56.0f;
f32 x_axis_height = 22.0f;
Rect axes; // plot area: excludes tick labels AND titles
Rect y_label_area, x_label_area; // one text line each, for tick labels
Rect y_title_area, x_title_area; // one text line each, for axis titles
f32 y_axis_width = 56.0f;
f32 x_axis_height = 22.0f;
f32 y_title_width = 0.0f; // set from the title's measured extent
f32 x_title_height = 0.0f;
};
```
`layout()` reserves one text line for tick labels and a second for the axis title on
each occupied edge, and insets `axes` by all of them. A title drawn inside `axes`
sits on top of the data and is a defect — the reserved strips exist to make that
impossible rather than merely unlikely. An axis with no title reserves nothing.
`Plot` owns vectors of axes/traces/cursors/measures, computes `PlotLayout` from `viewport()` and the text backend metrics, and recomputes measurement results for every measure whose cursors moved (dirty flag, not every frame).
`Grid` produces tick-aligned line segments in data space; `Trace` binds a `Series` to a color/width/axis pair; `Cursor` carries `f64 x`, `SnapMode { Free, NearestSample }`, color and an optional label; `Annotation` is a pinned point with text.
@@ -82,8 +82,19 @@ approach that fails at high sample rates.
Vertex buffers hold raw *data* coordinates. Screen mapping is
`x' = x * sx + tx`, `y' = y * sy + ty` evaluated in the vertex shader from a
`Transform` uniform. Panning and zooming therefore touch one uniform and zero
vertex data. Only new samples or an LOD-level change cause an upload.
`Transform` uniform. The CPU never transforms a vertex, and a pan or zoom changes
one uniform and no vertex *values*.
The staged window is view-dependent: each frame the renderer gathers just the
visible sample range (or the visible buckets of the chosen LOD level) and uploads
it. That per-frame cost is **O(visible pixels)** — about two segments per pixel
column, flat in series length — so the property this section exists for holds.
A staging cache keyed on `(series, level, first, last, viewport)` would let a
static plot skip the redundant gather and upload entirely; it is not implemented,
because it changes no asymptotics and would add an invalidation surface (new
samples, LOD changes, series mutation, streaming window slide) whose bugs are
worse than the redundant work it removes. Recorded here as deliberate future
work, not as a missing feature.
Log axes are handled inside the same transform (`log10` before scaling) so no
separate code path or pre-transformed buffer is needed.
@@ -94,8 +105,18 @@ Every series owns a mip chain: level 0 buckets `kBucket = 64` consecutive sample
and stores one `(min, max)` pair per bucket; each subsequent level halves the
bucket count. Memory cost is `n/8` bytes at level 0 (2 floats per 64 samples) times the geometric sum `1 + ½ + ¼ + …`, giving `n/4` bytes total — ≈6% of the 4n bytes of raw samples, not 2×.
- Built incrementally: `append()` updates only the last bucket and the O(log n)
chain above it — amortised O(1) per sample.
- Built incrementally, in **absolute bucket space**: level `l` bucket `k` covers
samples `[k · (kBucket << l), (k + 1) · (kBucket << l))`. A bucket's span
therefore never depends on where the current window starts, which is what lets a
window slide over a pyramid instead of forcing it to be rebuilt. `append()`
folds a sample into the single in-progress level-0 bucket and, exactly when that
bucket completes, pushes it and merges upward, stopping at the first level that
gains no new entry: level `l` gains one entry per `kBucket << l` samples, so an
append costs amortised O(1) (`1/kBucket + 1/(2·kBucket) + … = 2/kBucket` merges)
and never walks the live levels.
- A sliding window (a `StreamSeries` ring) drops the leading buckets it no longer
holds and appends only the newly produced samples, so nothing is recomputed and
memory stays bounded by the retained window rather than the total ever pushed.
- The renderer picks the level where samples-per-pixel ≈ 2, so a 100M-sample
series rasterises ≈ `2 · viewport_width` vertices instead of 100M.
- Buckets preserve extremes, so spikes are never dropped by decimation.
@@ -127,6 +148,12 @@ otherwise silently include samples outside the range. Exactness therefore lives
on `Series` (which owns the raw samples), and `MinMaxPyramid` exposes only
bucket-exact queries, named `*_buckets` so they cannot be mistaken for exact ones.
The bucket grid is absolute while a sliding window's base is not, so that split is
computed in absolute coordinates — `Series` asks the pyramid for `window_base()`
and aligns `ceil`/`floor` there before mapping back to window indices. That is
also how a stream's `y_bounds()` stays exact in O(log n + kBucket) per frame: it
is `reduce(0, size())` over the window rather than a scan of it.
### 4.5 Decimation approach — decision
| | Approach | Verdict |
@@ -196,9 +223,17 @@ namespace data {
class MinMaxPyramid {
public:
void reserve(u64 n); void append(f32 v); void clear();
u64 size() const;
// Absolute bucket space: level l entry i covers the absolute samples
// [i * (kBucket << l), (i + 1) * (kBucket << l)). The window slides over the
// same buckets; queries are in window coordinates (index 0 = oldest retained).
void drop_before(u64 absolute_sample);
u64 size() const; // retained window size
u64 window_base() const; // abs index of index 0
u64 window_end() const; // abs index past the last
u32 level_count() const;
u64 level_base(u32 l) const, level_size(u32 l) const;
MinMax bucket(u32 l, u64 absolute_bucket) const;
u32 level_for(u64 n, f64 samples_per_pixel) const;
std::span<const MinMax> level(u32 l) const;
u64 snap(f64 x, f64 x0, f64 dx) const; // nearest sample index
// Whole-bucket only: aggregates over the level-0 buckets fully contained in
// [begin, end). Partial edges are excluded, not approximated. Callers wanting
@@ -308,8 +343,17 @@ namespace input {
- Producers write to `data::StreamSeries` from an acquisition thread through a
lock-free SPSC ring. No mutex on the sample path.
- A frame begins with `StreamSeries::acquire()`, which publishes a consistent
`[0, produced)` snapshot and extends the pyramid by exactly the newly produced
samples. Readers use the snapshot; the producer never blocks.
`[0, produced)` snapshot: it slides the pyramid's retained window to the new
base, dropping the leading buckets the ring has overwritten, then extends the
pyramid by exactly the newly produced samples. Its cost is **O(samples produced
since the last acquire)** — independent of both the window size and the series
length — and so is its memory, because the drops bound the pyramid to the
retained window instead of the total ever pushed. A frame's total cost is
therefore O(new samples + visible pixels), and no part of the frame path is
proportional to the window. The one exception is a producer that laps the
consumer by more than `capacity()` samples between two acquires: the samples in
between were never appended, so that acquire re-reads the retained window from
scratch. Readers use the snapshot; the producer never blocks.
- `Renderer`, `Plot`, and `Controller` are confined to the render thread.
## 8. Error handling