Breaking: Series::gather emits x relative to x0() and takes a GatherMode; consumers reconstruct absolute x in f64 before mapping (series.hpp documents the contract). Bugs, each pinned by a fail-before regression test: - render: f32 staging quantised epoch-time x into 128 px columns; every data-space draw now stages to_space(x) - to_space(origin) with the origin folded into the offset uniform in f64 (epoch/zero-origin twins rasterise identically) - ticks: major test was ULP-fragile and blanked epoch axes; majors are an integer-index test and are never truncated by the minor cap - gather: LOD envelope emitted buckets straddling [begin,end); ceil/floor bucket edges + raw lead/trail spans - live follow: log x axes held a linear span and zoomed every frame; span and offset now live in axis space (plot + both figure link-group paths) - input: a zero-notch wheel disarmed auto_scale on every axis; dy==0 pans x, factor == 1 / inf refused in wheel and drag zoom - render: the single text flush painted legend names over the crosshair readout panel; two batches, under flushed before the panel - data: NaN dropped from min/max unless it seeded the fold; non-finite samples are now excluded from every aggregate and from cached bounds Robustness: viewport normalisation (no clamp UB), non-finite range refusal, pyramid level cap at 57 (no shift/div UB through public gather), rounded ring capacity documented + pre-sized, GL blend func and UNPACK_ALIGNMENT save/restore, Renderer::release_gl() teardown, u64 buffer epochs, annotation captions clamped, button-aware GLFW row capture, cached figure row rects (no per-event layout), ruler-pair measurement refused, span-nonfinite guards in axis_ends. Features: XY series (sorted x, binary-search neighbours/snap, LOD over sample index); StreamSeries::freeze(); ArraySeries::push(); per-trace dash and markers (scatter at width 0) with auto or literal stride; GatherMode Envelope/Nearest/Mean via bucket_sum/sumsq; Style::line_aa_px; per-glyph text colour (readout values in trace colour); wheel-dx x pan; arrow-key pan; Escape cancels a drag and restores captured ranges; modifier-masked box zoom (Shift both, Ctrl x, Alt y); double-click reset via PointerEvent::time_s; ImGui key repeat unified; pinch gestures on Qt and GTK; pixelDelta wheel path and DevicePixelRatioChange handling; GNUInstallDirs, SOVERSION, SameMinorVersion, generated plot/version.hpp, relocatable plot.pc, licence install, FILES_MATCHING, GitHub Actions CI (debug/coverage/asan). Suite 283 -> 350 cases, 0 failed; coverage 93.9% (3730/3971) over 24 files, floor 80/90 held; ASAN preset suite green; all four demos run headless (the GTK scripted-resize check needs a window manager and fails identically on the parent commit); readme_main.cpp committed and compiles with the README command; pkg-config and find_package consumption verified from a relocated install prefix.
214 lines
13 KiB
Markdown
214 lines
13 KiB
Markdown
# Data and performance
|
||
|
||
## The sample stores
|
||
|
||
All series derive from `plot::data::Series` (`include/plot/data/series.hpp`), which
|
||
describes the sample-indexing contract; a **uniform-grid** series additionally has
|
||
sample `i` at `x = x0() + i * dx()` (`ArraySeries`, `StreamSeries`), while
|
||
**`XYSeries`** carries its own sorted, ascending x array for data that is not on a
|
||
uniform grid.
|
||
|
||
```cpp
|
||
class Series {
|
||
public:
|
||
virtual u64 size() const;
|
||
virtual f64 x0() const; // x of sample 0 (the oldest retained sample)
|
||
virtual f64 dx() const; // 0 for XYSeries, which owns its x array
|
||
virtual Range x_bounds() const;
|
||
virtual Range y_bounds() const;
|
||
virtual const MinMaxPyramid& pyramid() const;
|
||
virtual bool streaming() const noexcept; // default false
|
||
virtual f64 x_at(u64 i) const; // x0() + i*dx(), or x()[i]
|
||
virtual bool neighbours(f64 x, u64& i0, u64& i1) const; // bracketing samples
|
||
virtual u64 snap_index(f64 x) const; // nearest sample
|
||
virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
||
GatherMode mode = GatherMode::Envelope) const;
|
||
|
||
Range reduce(u64 begin, u64 end) const; // exact min/max over [begin, end)
|
||
f64 sum(u64 begin, u64 end) const; // exact sum over [begin, end)
|
||
f64 sumsq(u64 begin, u64 end) const; // exact sum of squares
|
||
};
|
||
```
|
||
|
||
Indices are the series' **own window**: index 0 is the oldest sample it currently holds, so
|
||
`size()`, `x0()`, `reduce()`, `gather()` and `pyramid()` are all in the same coordinates.
|
||
|
||
**`ArraySeries`** (`array_series.hpp`) owns a `std::vector<f32>` of y samples plus an
|
||
`x0`/`dx` grid. Construct with `ArraySeries(std::vector<f32> y, f64 x0, f64 dx)`. The vector
|
||
is taken by value and owned, so the samples cannot change behind the series' back; the store
|
||
changes only through `reset(y, x0, dx)` (a whole new vector, then a rebuild), `commit()`,
|
||
which rebuilds the pyramid and cached bounds from the current `y`, or `push(y)`, which
|
||
appends one sample incrementally — the pyramid folds it in amortised O(1) and the bounds
|
||
extend in place, NaN-aware, instead of rebuilding.
|
||
|
||
**`StreamSeries`** (`stream_series.hpp`) is the single-producer / single-consumer streaming
|
||
store: `StreamSeries(u64 capacity, f64 x0, f64 dx)`. `capacity` is rounded up to a power of
|
||
two by the ring, `size()` saturates at that rounded `capacity()` (a `StreamSeries(100)`
|
||
retains up to 128 samples), and the pyramid is reserved for the rounded value up front.
|
||
`freeze()` copies the window published by the last `acquire()` into a fresh `ArraySeries`
|
||
(consumer thread only): a stable snapshot that no later production can move.
|
||
|
||
## Non-finite samples
|
||
|
||
The library-wide NaN policy: **a non-finite sample is excluded from every aggregate** —
|
||
min, max, sum, sum of squares and the pyramid's per-bucket finite count — and from the
|
||
cached bounds. A window with no finite samples at all aggregates to a **zero-span
|
||
`Range` / zero sums**, never to NaN. The policy is applied everywhere an aggregate is
|
||
built: `MinMaxPyramid::append` (a NaN occupies its absolute position, so bucket
|
||
boundaries stay positional, but folds nothing), the edge folds in `Series::reduce`,
|
||
`sum` and `sumsq`, `ArraySeries::commit` and `push` bounds, and
|
||
`StreamSeries::acquire` bounds. A bucket whose samples were all non-finite is stored as
|
||
an *empty* aggregate (count 0, zero sums) that every reader skips — it is absent, not
|
||
zero-valued. `gather()` follows suit: Envelope and Mean emit nothing for such a bucket,
|
||
and Nearest draws its centre sample raw. Hosts that want gaps in the data should draw
|
||
them with NaN samples and will get exactly that: excluded from statistics, present in
|
||
raw geometry.
|
||
|
||
## Gather x is relative, and what each mode emits
|
||
|
||
**`Series::gather` emits x relative to `x0()`** — exactly `i * dx` for a uniform
|
||
series, `x_at(i) - x0()` for `XYSeries` — never the absolute coordinate. y is
|
||
unchanged. The renderer reconstructs absolute data x in f64 (`x0() + emitted`) before
|
||
transforming, so no precision is lost at the f32 narrowing: the staged value is
|
||
O(visible pixels), not O(epoch). (Staging an absolute epoch x as f32 would quantise it
|
||
to a grid coarser than the pixel — f32 carries 24 bits, about 1 s at 1.7e9 — while the
|
||
relative value is small and exact.) The emitted geometry always spans
|
||
`[begin, end)` exactly and never reaches outside it: at level ≥ 1 the bucket edges are
|
||
selected ceil/floor (`first` = first bucket starting at or after `begin`, `last` = one
|
||
past the last bucket ending at or before `end`) and the two partial edge spans are
|
||
drawn from raw samples, which is what keeps an unaligned window envelope-exact
|
||
(previously the buckets straddling the range leaked samples from outside it into the
|
||
geometry). `level` is clamped to `kMaxPyramidLevel` (57 — the deepest level whose
|
||
bucket span fits a `u64`), so any `u32` level is safe.
|
||
|
||
The `GatherMode` decides what each bucket contributes, at the same level the renderer
|
||
already picked:
|
||
|
||
- **`Envelope`** (the default): one (min, max) pair per bucket, min drawn at the
|
||
bucket's first sample and max at its last. The dense, extreme-reading geometry a
|
||
time-series trace wants.
|
||
- **`Nearest`**: one raw sample per bucket — the centre sample, the pick
|
||
`pyramid().snap()` would make — drawn at its own x. Thinner geometry for traces
|
||
whose *shape* matters more than their extremes.
|
||
- **`Mean`**: one point per bucket, y = the bucket's finite samples' mean (the exact
|
||
`bucket_sum()` the pyramid stores divided by its finite count), x at the bucket
|
||
centre. A smoothed trace for noisy data.
|
||
|
||
Level 0 is the raw samples for every mode.
|
||
|
||
## The SPSC contract
|
||
|
||
One producer thread, one consumer thread, and the division is strict
|
||
(`stream_series.hpp`):
|
||
|
||
- **The producer may only call** `push(f32 y)`, `push(const f32* ys, u64 n)` and
|
||
`produced()`. Those touch the ring buffer and nothing else: **no lock, no pyramid, no GL**.
|
||
The acquisition thread can therefore never stall the renderer.
|
||
- **The consumer calls `acquire()`**, which snapshots the produced count and brings the
|
||
pyramid and bounds up to it, and then queries the published window. `size()`, `reduce()`,
|
||
`sum()`, `sumsq()`, `gather()` and `pyramid()` all use the window published by the last
|
||
`acquire()`.
|
||
|
||
`acquire()` costs O(samples produced since the last acquire), not O(capacity): the pyramid
|
||
is indexed in absolute sample space, so a sliding window only drops the leading buckets the
|
||
ring overwrote and appends what is new; aggregates still 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 missing samples cannot be represented and the pyramid
|
||
restarts at the new base, re-reading the whole window.
|
||
|
||
The ring overwrites the oldest samples — that is what keeps a live stream showing the newest
|
||
data — so it is **not a frozen window**. Those slots are relaxed atomics, so this is not a
|
||
data race and no value tears; but if the producer pushes `k` samples while a read is in
|
||
flight, the first `k` positions of that read can hold one of those `k` newer samples rather
|
||
than the one the snapshot named. Ingest with no reuse underneath a read at all needs the
|
||
producer to stop before unread data (drop-on-full) or a claim protocol — a different
|
||
contract from this one.
|
||
|
||
`streaming()` returns `true` for `StreamSeries` and `false` for everything else. Only a
|
||
streaming series has a **live edge** (the newest x it holds, which moves on its own), and it
|
||
is that property `XFollow::Live` follows (see [interaction.md](interaction.md)).
|
||
|
||
## The min/max LOD pyramid
|
||
|
||
`data::MinMaxPyramid` (`minmax_pyramid.hpp`) is the index that makes per-frame cost depend on
|
||
the visible pixels rather than on the sample count.
|
||
|
||
- Samples are grouped into buckets of `kBucket = 64` (**`kBucket`** is a fixed constant, so
|
||
all series share one LOD geometry). Level `l` bucket `k` covers the absolute samples
|
||
`[k * (kBucket << l), (k + 1) * (kBucket << l))`, and bucket boundaries are fixed for the
|
||
pyramid's life — appending never re-buckets existing aggregates, and a window can slide
|
||
over the same buckets.
|
||
- Each level holds its complete buckets in a `std::deque` (O(1) `push_back` and
|
||
`pop_front`) plus a `base`. `append()` folds a sample into the in-progress level-0 bucket
|
||
and, when it completes, pushes it and walks up, stopping at the first level that gains no
|
||
entry: an append costs **amortised O(1)**. `drop_before(u)` slides the window's leading
|
||
edge, bounding the memory by the retained window rather than by everything ever appended.
|
||
- An entry is 32 bytes (`MinMax` plus the exact `sum`/`sumsq` the exact range queries fold
|
||
over and the finite-sample `count` the mean folds by), so level 0 costs `0.5n` bytes and
|
||
the geometric tail brings the whole pyramid to about **`1n` bytes — roughly 25% of the
|
||
`4n` bytes of raw samples** (`f32`). A min/max-only index would be `0.25n` (~6%); the
|
||
extra words are the price of answering mean/RMS/integral from whole buckets instead of
|
||
raw samples.
|
||
|
||
Queries on the pyramid are **bucket-grained** and named `*_buckets` (`reduce_buckets`,
|
||
`sum_buckets`, `sumsq_buckets`): a pyramid stores aggregates, so it can only answer exactly
|
||
for whole buckets, and partial edge buckets are excluded rather than approximated. Exact
|
||
range queries live on `Series`, which owns the raw samples.
|
||
|
||
`level_for(u64 n, f64 samples_per_pixel)` returns the pyramid level whose buckets hold about
|
||
`samples_per_pixel` samples for a window of `n` samples (clamped to levels that exist, to
|
||
levels with more than one bucket, and to levels that give the window at least two buckets).
|
||
|
||
The renderer uses this: a trace's x range is mapped onto sample indices, and `Series::gather`
|
||
is asked for that window *at `pyramid().level_for(n, samples_per_pixel)`* whenever a pixel
|
||
covers `Style::dense_threshold_spp` samples or more (default 1.5). A 100-million-sample
|
||
series therefore stages O(visible pixels) vertices, not O(samples), and `FrameStats` records
|
||
which path each trace took. Below the threshold the raw samples are drawn instead; both
|
||
sides go through the same instanced anti-aliased quad pipeline.
|
||
|
||
## Exact versus interpolated
|
||
|
||
The library deliberately holds two conventions next to each other, and they do **not**
|
||
agree:
|
||
|
||
- **`Series::reduce` / `sum` / `sumsq` are exact over a sample range.** They answer "what
|
||
are the samples' extremes / sum / sum of squares over `[begin, end)`". Each is
|
||
implemented once over `copy_raw()`: the bucket-aligned interior comes from the pyramid
|
||
(O(log n) bucket aggregates) and at most two partial edge buckets are read raw and
|
||
scanned, so the cost is O(`kBucket`) worst case and **independent of the series length**,
|
||
and no query allocates. `Measure::evaluate` and `Plot::region_stats` are built on these,
|
||
never on the `*_buckets` forms, which would silently report wrong extremes for an
|
||
unaligned interval.
|
||
- **The crosshair readout is interpolated, and says so.** `crosshair_span(series, x)`
|
||
returns the two samples `x` lies between (`begin`, `end`, `t`), and
|
||
`crosshair_interpolate(y_begin, y_end, t)` does a linear interpolation: it answers "what
|
||
is the signal *at* this instant", which is generally between two samples — a different
|
||
question from an interval statistic. The lookup is O(1) (one division and a floor) and
|
||
allocation-free; `Plot::crosshair_value(trace, x, out_y)` exposes the same number to a
|
||
host. A row whose trace has no sample at the crosshair's x shows `kCrosshairNoValue`
|
||
(an em dash, `"—"`), and an x outside the series window yields no value rather than the
|
||
nearest sample's.
|
||
|
||
`Measurement` (from `Plot::measurement()`) documents `integral` as a **left-Riemann sum**,
|
||
`sum(begin, end) * dx` — the samples taken as the value at the left edge of each step. That
|
||
is part of the result's contract, not an implementation detail.
|
||
|
||
## Measured frame times
|
||
|
||
These are the demos' own frame summaries, taken from a `linux-release` build, **all of them
|
||
llvmpipe (Mesa software rasteriser) under Xvfb** — no run touched a GPU. Treat them as
|
||
upper bounds and as a record of the software path, not as hardware numbers.
|
||
|
||
| demo | window | mean ms/frame | work |
|
||
|---|---|---|---|
|
||
| GLFW (`plot_glfw_demo`) | 1280×720 | 0.42 | one 1 MS/s `StreamSeries` |
|
||
| ImGui (`plot_imgui_demo`) | 1280×720 | 0.37 | the same stream, embedded |
|
||
| multiplot (`plot_multiplot_demo`) | 1280×720 | 0.66 | 3 rows, 6 traces, 500 kS/s each |
|
||
| Qt6 (`plot_qt_demo`) | 820×588 at exit | 3.23 | one stream, `QOpenGLWidget` FBO |
|
||
| GTK4 (`plot_gtk_demo`) | 810×564 at exit | 2.85 | one stream, `GtkGLArea` FBO |
|
||
|
||
The multiplot run stages 18 651 segments for ~4.5 million visible samples, which is the
|
||
pyramid doing its job: the frame is bounded by the pixels, not the samples. The Qt and GTK
|
||
figures are dominated by their toolkit's texture-backed FBO under llvmpipe, not by the
|
||
library.
|