An LOD envelope staged as a polyline moirés. level_for() sizes a bucket at one to two pixel columns and Envelope emits two points per bucket, so a column carries three or four near-vertical hairlines at as many sub-pixel phases; under src-alpha compositing its brightness is then a non-linear function of those phases, the pattern repeats at the beat between the bucket pitch and the pixel pitch, and a pan sweeps the beat so the banding crawls. draw_traces now reduces such a window onto the pixel grid: one exactly vertical segment per integer column of the axes, from the lowest to the highest screen y any gathered point put in it, flushed in framebuffer pixels with the snap on. One phase for the whole trace, so there is nothing left to beat -- and the geometry becomes O(axes.w) rather than O(buckets). Gated on the gathered density rather than on the level (the raw path beats the same way), and on Envelope alone. A hole stays a hole, disjoint neighbours are joined so a fast edge keeps it, and a span under the stroke is drawn as the stroke since the shader caps nothing. It is the one carve-out in u_snap's data/chrome rule: a column is a pixel column, and the y bounds it reports are untouched. Measured by the new test as the number of distinct coverage levels in the ink -- 4 against the polyline's 126, with 627 segments against 60 054. A count of partially covered pixels was tried first and separates nothing (577 against 652): a 1.5 px stroke's fringe always spills into the next column. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
272 lines
17 KiB
Markdown
272 lines
17 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.
|
||
|
||
## Dense traces are reduced onto the pixel grid
|
||
|
||
A gathered window denser than the axis is **not** staged as a polyline. For each integer
|
||
column of `layout.axes`, `Renderer::draw_traces` emits one exactly vertical segment from the
|
||
lowest to the highest screen y any gathered point put in that column, flushed in framebuffer
|
||
pixels with `u_snap` on. `FrameStats::column_traces` counts the traces that took this path.
|
||
|
||
It exists because a polyline over such a window **moirés**, and three things compound to make
|
||
it. `MinMaxPyramid::level_for()` picks the level whose buckets hold about
|
||
`samples_per_pixel` samples, so a bucket spans **one to two pixel columns**;
|
||
`GatherMode::Envelope` emits two points per bucket, so each bucket contributes a near-vertical
|
||
min→max hairline *and* a near-vertical connector to the next — about 3.6 near-vertical
|
||
hairlines per column; and each of those lands at a **fractional** x, so with ordinary
|
||
src-alpha compositing a column's brightness is a non-linear function of the sub-pixel phases
|
||
overlapping it. The phase pattern repeats at the beat between the bucket pitch and the pixel
|
||
pitch, which is a visible banding, and panning sweeps the beat so it crawls. Reducing to one
|
||
segment per column leaves the trace **one** sub-pixel phase, so there is nothing left to beat,
|
||
and the snap then puts that one phase on the grid.
|
||
|
||
Four properties of the reduction are load-bearing:
|
||
|
||
- **It is gated on the gathered *density*, not on the level.** It runs when the window is
|
||
`GatherMode::Envelope` and carries at least two gathered points per column of the axes —
|
||
the raw path at 1.5–64 samples per pixel has the same beat and the same cure. The density
|
||
term is also what keeps it away from a *sparse* window, whose trace is drawn by the two
|
||
segments that enter and leave the frame (`a_sparse_window_between_samples_still_inks_both_margins`);
|
||
`Nearest` and `Mean` are excluded because they are one-point-per-bucket modes a band defeats.
|
||
- **A hole stays a hole.** A column no gathered point landed in emits nothing. Bridging it
|
||
would draw a vertical line over x the series says nothing about.
|
||
- **Disjoint columns are joined.** Two neighbouring columns' extremes need not overlap — a
|
||
fast edge rises through a whole column — and two disjoint vertical segments read as a broken
|
||
trace, which is what the polyline's connector prevented. The later column is extended to
|
||
meet the earlier one's nearest bound, so the join is made inside the column that moved.
|
||
- **A span shorter than the stroke is drawn *as* the stroke.** The line fragment shader ramps
|
||
on the distance to the segment's axis and does not cap its ends, so a vertical segment
|
||
shorter than its own width rasterises a sliver of itself, and a flat trace's column, where
|
||
the two bounds are equal, nothing at all.
|
||
|
||
This is the **one carve-out** in `u_snap`'s rule that chrome may be snapped and data may not
|
||
(see `LineProgram::set_snap()`): a column *is* a pixel column and its x is already a screen
|
||
coordinate, so there is no data position to move it off, and the y bounds the segment reports
|
||
are untouched by the snap.
|
||
|
||
`a_dense_trace_is_drawn_as_whole_pixel_columns` is the guard, and what it measures is the
|
||
**number of distinct coverage levels** in the ink: 4 with the reduction against 126 with the
|
||
polyline, over one sine period across 60 000 samples. A count of *partially covered pixels* is
|
||
**not** the reading and was tried first — a 1.5 px stroke with a 1 px anti-aliasing fringe
|
||
always spills into its neighbour column, so the two renderings measure 577 and 652, which
|
||
separates nothing. The frame's whole geometry collapses with it: 627 segments against 60 054.
|
||
|
||
## One sample or a statistic of many
|
||
|
||
Every number the library reports is one the samples actually hold. What differs between
|
||
these two is *how many* samples answer the question:
|
||
|
||
- **`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 a single sample — the nearest one — by default.**
|
||
`crosshair_span(series, x)` returns the two samples `x` lies between (`begin`, `end`, `t`),
|
||
and `CrosshairValue::NearestSample` takes whichever of the two is nearer: `t <= 0.5` the
|
||
earlier, otherwise the later. It answers "which datum is under the pointer", not "what is
|
||
the signal *at* this instant" — a linearly interpolated y is a number the series does not
|
||
contain, and on a source whose samples are already averaged or decimated at ingest it is a
|
||
claim about a signal nobody measured. That is why it is the default; a host whose x is a
|
||
quantity it is willing to interpolate states `CrosshairValue::Interpolated` and gets
|
||
`crosshair_interpolate(y_begin, y_end, t)` instead, at the crosshair's own x. The lookup is
|
||
O(1) and allocation-free either way, and it reads level 0, so the value is a stored sample
|
||
(or a blend of two) and never an LOD bucket's aggregate. `Plot::crosshair_point(trace, x,
|
||
out_x, out_y)` exposes **both** coordinates to a host and `crosshair_value()` the y alone;
|
||
`CrosshairRow::x`/`y` and its `marker` are placed from that same pair, so in
|
||
`NearestSample` the mark snaps onto the quoted sample and the dot and the number cannot
|
||
disagree. 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
|
||
clamping to the first or last sample.
|
||
|
||
`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.
|