Files
plotter/docs/data-and-performance.md
T

152 lines
8.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Data and performance
## The sample stores
All three series derive from `plot::data::Series` (`include/plot/data/series.hpp`), which
describes a **uniform-grid** series: sample `i` lives at `x = x0() + i * dx()`.
```cpp
class Series {
public:
virtual u64 size() const;
virtual f64 x0() const; // x of sample 0 (the oldest retained sample)
virtual f64 dx() const;
virtual Range x_bounds() const;
virtual Range y_bounds() const;
virtual const MinMaxPyramid& pyramid() const;
virtual bool streaming() const noexcept; // default false
virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) 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) or `commit()`,
which rebuilds the pyramid and cached bounds from the current `y`.
**`StreamSeries`** (`stream_series.hpp`) is the single-producer / single-consumer streaming
store: `StreamSeries(u64 capacity, f64 x0, f64 dx)`.
## 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 24 bytes (`MinMax` plus the exact `sum`/`sumsq` the exact range queries fold
over), so level 0 costs `0.375n` bytes and the geometric tail brings the whole pyramid to
about **`0.75n` bytes — roughly 19% of the `4n` bytes of raw samples** (`f32`). A
min/max-only index would be `0.25n` (~6%); the two doubles 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.