docs: a user guide covering getting started, architecture, embedding, data, interaction, styling and testing
This commit is contained in:
1 parent
d03f9ce51a
commit
b7d0f901cc
7 files changed
+1082
No files matched your search
@@ -0,0 +1,151 @@
|
||||
# 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.
|
||||
Reference in new issue
Block a user