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:
1 parent
4b649a2783
commit
0ac5d47fa6
13 files changed
+769
-244
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user