perf: a measurement pane is priced by its window, and its fold is inline

A pane is recomputed over the whole visible window on a cadence, and at a 15 s window
(11.8 M samples, 184 k level-0 level buckets, `--buffer-mb 4000` so the stride stays 1)
that is priced per *measured channel* and per *distinct share*. Measured with the new
`[levels-probe]` sweep, per recompute: one window with one level 6.58 ms, four shares
10.28 ms, sixteen shares 24.55 ms, and four measurements one share each over four
channels **35.69 ms** -- against a tick mean of 1.34 ms with an empty pane and 4.58 ms
with that one. That is the reported "very slow with multiple measurements": the pass
runs inside `tick()`, so it is a *frame* cost, and not an ingest one -- `gaps` and
`packets` stay clean even though each `MeasureWindowReader` holds the model mutex for
its whole life, because a socket buffer absorbs a few tens of milliseconds of producer.

The largest single term inside a pass was a call. `measure_accumulate_bucket()` folds
one bucket into one level, and it is the innermost loop of a walk whose iteration count
is the window's buckets *times* the pane's levels; it lived out of line in
`measure.cpp`, so every one of those iterations was a cross-translation-unit call.
Moving it, and the crossing state it updates, into `measure.hpp` as `inline` takes
sixteen shares on one channel from 24.55 to **17.25 ms** a recompute -- with every value
in the dump's `measurements:` unchanged. Two levers remain and both are measured: the
fetch's three out-of-line pyramid accessors plus a 48-byte copy per bucket (3.3 ms of an
11.8 M-sample window's 11.6 ms pass) needs a combined accessor upstream, and `raw` is
the *signal's* share (4.6 % on the mock's noisy channel against 0.1 % on its sine),
because a level inside a bucket's own `[lo, hi]` has to be walked sample by sample.

Bisected with the pane populated (the earlier bisect had it empty, which cannot see
this): the parent of the recent range and HEAD read **8.29** and **7.92 ms** a
recompute for the same four-type pane at the same window, and neither of the two
commits between them touches `ui_measure.cpp` or `measure.cpp` at all. So the cost is
the scale and not a regression, and the fix makes a pass cheaper rather than removing
one -- the plot's own per-frame cost was checked the same way (four commits, equal
within 3 %, and unchanged by the window, the ring size and the visible sample count).

`UDPSCOPE_PROBE_LEVELS_MS` with `UDPSCOPE_PROBE_LEVELS` and
`UDPSCOPE_PROBE_LEVELS_CHANNELS` is the sweep; `docs/claude/invariants-measurements.md`
records the curve and the inline rule, `verification.md` and `probe-readings.md` the
probe and what its numbers must read, and the README the pane's cost at this scale.
This commit is contained in:
Martino Ferrari committed 2026-10-16 08:00:54 +02:00
1 parent 0a209becb1
commit fc4700d1c9
9 files changed
+242 -61

No files matched your search

-61
View File
@@ -14,20 +14,6 @@ namespace {
/// in. Shared by the raw walk and the bucket walk so the two cannot disagree about what an edge
/// records -- an interval's width is the distance from the edge that entered it to the edge that
/// left it, which is the pairing the pulse duration averages.
void enter_side(MeasureLevel& acc, std::size_t e, double t) {
const std::size_t other = 1 - e;
if (acc.enters[e] == 0) acc.first_enter[e] = t;
acc.last_enter[e] = t;
++acc.enters[e];
if (acc.open[other]) {
acc.width_sum[other] += t - acc.open_at[other];
++acc.widths[other];
acc.open[other] = false;
}
acc.open_at[e] = t;
acc.open[e] = true;
}
// The hysteresis band and its rationale moved to `MeasureOptions::noise_pct`: it is a per-measurement
// setting now, and a second copy of the number here would be a second thing to keep in step.
constexpr double kPi = 3.14159265358979323846;
@@ -240,51 +226,6 @@ void measure_accumulate(const MeasureChunk& chunk,
}
}
bool measure_accumulate_bucket(const MeasureBucket& k,
const std::function<double(std::size_t)>& time_of,
MeasureLevel& acc) {
if (k.count == 0) return true;
// A non-finite sample in the bucket would make the edge's position a guess: the raw walk takes
// the first *finite* sample of the new side, and an aggregate cannot say which that is.
if (k.count != k.span) return false;
// The *sums* need only the bare level: strictly on one side of it, so every sample decides the
// same bin and none is *at* it, which is what makes the fold exact rather than an approximation.
const bool above = k.lo > acc.level;
const bool below = k.hi < acc.level;
if (!above && !below) return false;
const std::size_t e = above ? 1u : 0u;
// The band is deliberately *not* folded into the test above. Tightening the whole condition to
// `level +/- h` refuses every bucket lying inside the band -- a tenth of the amplitude either side
// of the mid level is where a waveform moves fastest, so those are exactly the buckets a crossing
// passes through -- which hands the raw walk a share of the window rather than a bucket or two per
// crossing, and breaks the "`raw` stays at a fraction of a percent of `covered`" invariant.
// Instead: a bucket on one side of the level can always be *binned*, and only a *side change*
// whose position is inside the bucket is refused.
const double top = acc.level + acc.hysteresis;
const double bot = acc.level - acc.hysteresis;
// Does the bucket reach past the band at all? One wholly inside it decides no side, exactly as a
// raw sample inside it decides none, so the walk state is left alone.
const bool reaches = above ? (k.hi > top) : (k.lo < bot);
const bool change = reaches && acc.have_side && e != (acc.side ? 1u : 0u);
// A bucket that *enters* a side while straddling its own threshold holds the crossing somewhere
// inside itself, and only the raw walk can say where. One wholly past the threshold holds it at
// the bucket's first sample, exactly, which is where the raw walk would have found it.
const bool fully_outside = above ? (k.lo > top) : (k.hi < bot);
// Nothing may be folded before this point: a refusal hands the bucket to `measure_accumulate()`,
// which adds the same samples again. Sums added above the refusal double-counted every opened
// bucket -- which read as `top` 0.614304 on a unit sine against the 2/pi a mean over a half then
// gave, and which is now the sums `Top`/`Bottom` fall back to on a window with no histogram, so
// a wrong one is a wrong answer waiting for that window.
if (change && !fully_outside) return false;
acc.sum[e] += k.sum;
acc.count[e] += k.count;
if (change) enter_side(acc, e, time_of(k.offset));
if (reaches) {
acc.side = e != 0;
acc.have_side = true;
}
return true;
}
MeasureFlatLevels measure_flat_levels(const MeasureStats& b, double pct) {
MeasureFlatLevels out;
@@ -375,7 +316,6 @@ int measure_level_pcts(MeasureType type, const MeasureOptions& options, double p
}
}
const char* measure_type_name(MeasureType type) {
switch (type) {
case MeasureType::Average: return "average";
@@ -598,5 +538,4 @@ MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* const l
return out;
}
} // namespace udpscope
+60
View File
@@ -349,6 +349,66 @@ bool measure_accumulate_bucket(const MeasureBucket& bucket,
const std::function<double(std::size_t)>& time_of,
MeasureLevel& level);
inline void enter_side(MeasureLevel& acc, std::size_t e, double t) {
const std::size_t other = 1 - e;
if (acc.enters[e] == 0) acc.first_enter[e] = t;
acc.last_enter[e] = t;
++acc.enters[e];
if (acc.open[other]) {
acc.width_sum[other] += t - acc.open_at[other];
++acc.widths[other];
acc.open[other] = false;
}
acc.open_at[e] = t;
acc.open[e] = true;
}
inline bool measure_accumulate_bucket(const MeasureBucket& k,
const std::function<double(std::size_t)>& time_of,
MeasureLevel& acc) {
if (k.count == 0) return true;
// A non-finite sample in the bucket would make the edge's position a guess: the raw walk takes
// the first *finite* sample of the new side, and an aggregate cannot say which that is.
if (k.count != k.span) return false;
// The *sums* need only the bare level: strictly on one side of it, so every sample decides the
// same bin and none is *at* it, which is what makes the fold exact rather than an approximation.
const bool above = k.lo > acc.level;
const bool below = k.hi < acc.level;
if (!above && !below) return false;
const std::size_t e = above ? 1u : 0u;
// The band is deliberately *not* folded into the test above. Tightening the whole condition to
// `level +/- h` refuses every bucket lying inside the band -- a tenth of the amplitude either side
// of the mid level is where a waveform moves fastest, so those are exactly the buckets a crossing
// passes through -- which hands the raw walk a share of the window rather than a bucket or two per
// crossing, and breaks the "`raw` stays at a fraction of a percent of `covered`" invariant.
// Instead: a bucket on one side of the level can always be *binned*, and only a *side change*
// whose position is inside the bucket is refused.
const double top = acc.level + acc.hysteresis;
const double bot = acc.level - acc.hysteresis;
// Does the bucket reach past the band at all? One wholly inside it decides no side, exactly as a
// raw sample inside it decides none, so the walk state is left alone.
const bool reaches = above ? (k.hi > top) : (k.lo < bot);
const bool change = reaches && acc.have_side && e != (acc.side ? 1u : 0u);
// A bucket that *enters* a side while straddling its own threshold holds the crossing somewhere
// inside itself, and only the raw walk can say where. One wholly past the threshold holds it at
// the bucket's first sample, exactly, which is where the raw walk would have found it.
const bool fully_outside = above ? (k.lo > top) : (k.hi < bot);
// Nothing may be folded before this point: a refusal hands the bucket to `measure_accumulate()`,
// which adds the same samples again. Sums added above the refusal double-counted every opened
// bucket -- which read as `top` 0.614304 on a unit sine against the 2/pi a mean over a half then
// gave, and which is now the sums `Top`/`Bottom` fall back to on a window with no histogram, so
// a wrong one is a wrong answer waiting for that window.
if (change && !fully_outside) return false;
acc.sum[e] += k.sum;
acc.count[e] += k.count;
if (change) enter_side(acc, e, time_of(k.offset));
if (reaches) {
acc.side = e != 0;
acc.have_side = true;
}
return true;
}
/// The levels a type reads, as shares of the window's own min..max, **lower first**, or 0 when it
/// reads none (the pure statistics, answered from `MeasureStats` alone). `pct` must hold two: a
/// level type fills `pct[0]` and answers 1, `Between` fills both and answers 2.
+3
View File
@@ -5831,6 +5831,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
if (const char* env = g_getenv("UDPSCOPE_PROBE_MEASURE_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), measure_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_LEVELS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), levels_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_MEASUREPIN_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), measurepin_probe, &app);
}
+1
View File
@@ -1730,6 +1730,7 @@ gboolean mathwrap_probe(gpointer user);
gboolean mathwrap_probe_wait(gpointer user);
gboolean mathwrap_probe_report(gpointer user);
gboolean measure_probe(gpointer user);
gboolean levels_probe(gpointer user);
gboolean measurepin_probe(gpointer user);
gboolean measurepin_held_wait(gpointer user);
gboolean measurepin_pause_wait(gpointer user);
+65
View File
@@ -1326,6 +1326,71 @@ gboolean measure_probe(gpointer user) {
return G_SOURCE_REMOVE;
}
/// `UDPSCOPE_PROBE_LEVELS_MS`: puts a pane of **N** measurements on screen -- one `above` per
/// share in `UDPSCOPE_PROBE_LEVELS` (comma separated, default `50`) -- spread in turn over the
/// first `UDPSCOPE_PROBE_LEVELS_CHANNELS` channels (default 1), and says how many *windows* and
/// *levels* the pass then had to walk.
///
/// The pane's cost has three factors and a count alone names none of them: the window's samples
/// (one fetch and one scan per **measured channel**, whatever the pane holds), the number of
/// *distinct levels* in it (measurements that share a share share the walk), and the rows that
/// are only labels over the two. `50,50,50,50` is four rows and one walk; `5,10,15,20` is four
/// rows and four; and the same four shares on four signals is four windows *and* four walks.
/// This probe is the only way to vary the three apart, which is what a report of "it is slow
/// with many measurements" needs before it is a number.
gboolean levels_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
std::vector<std::string> names;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
for (const ChannelInfo& c : a->model->channels()) names.push_back(c.name);
}
if (names.empty()) return G_SOURCE_REMOVE;
const char* chan_env = g_getenv("UDPSCOPE_PROBE_LEVELS_CHANNELS");
const long want_channels = chan_env != nullptr ? std::atol(chan_env) : 1;
const std::size_t channels =
want_channels > 0 ? std::min<std::size_t>(static_cast<std::size_t>(want_channels),
names.size())
: 1u;
const char* list = g_getenv("UDPSCOPE_PROBE_LEVELS");
const std::string shares = list != nullptr && list[0] != '\0' ? list : "50";
std::size_t rows = 0;
for (std::size_t at = 0; at <= shares.size();) {
const std::size_t comma = shares.find(',', at);
const std::string field =
shares.substr(at, comma == std::string::npos ? std::string::npos : comma - at);
at = comma == std::string::npos ? shares.size() + 1u : comma + 1u;
char* end = nullptr;
const double pct = std::strtod(field.c_str(), &end);
if (end == field.c_str() || !(pct >= 0.0)) continue;
Measure m;
m.signal = names[rows % channels];
m.type = MeasureType::Above;
m.options.threshold_pct = pct;
a->measures.push_back(std::move(m));
++rows;
}
rebuild_measures_pane(*a);
update_measures(*a, /*force=*/true, /*accumulate=*/true);
// What that pass walked: the level accumulators the pane deduplicated to, and the windows they
// hang off. Read without the model lock -- `measure_samples` is the *tick*'s own state, not the
// producer's.
std::size_t levels = 0;
std::size_t windows = 0;
for (const App::MeasureWindow& w : a->measure_samples) {
++windows;
levels += w.pct.size();
}
std::fprintf(stderr,
"[levels-probe] %zu measurement(s) over %zu channel(s), shares='%s' -> %zu "
"window(s), %zu level walk(s)\n",
rows, channels, shares.c_str(), windows, levels);
std::fflush(stderr);
g_timeout_add(250, measure_trace, a);
return G_SOURCE_REMOVE;
}
/// `UDPSCOPE_PROBE_MEASUREPIN_MS`: on a **still frame** the pane describes the frame and not the
/// viewport, so a pan or a zoom changes no number -- and on a live view the numbers still follow
/// their window.