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

8.8 KiB
Raw Blame History

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().

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).

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.