perf: the measurements pane keys its shared walks by channel and HF corner

The pane shared its two walks per *channel*, but the HF corner is a per-measurement
setting: two measurements on one signal at different corners read two different signals
and cannot share a walk. `App::MeasureWindow` therefore carries `channel` and `fc`, the
container is a vector, and the grouping loop matches on both -- keeping its dedupe of the
level accumulators by `(pct, noise)`, since a type that does not read the band must not
split an accumulator over it.

Each window carries two filters, one per walk: they are separate walks, so separate state.
A filtered window also stops using the LOD pyramid, which one `if` does -- leaving
`buckets` empty makes `bulk_from == bulk_to == n`, so the raw walk covers the window and
`lo_search`/`hi_search` stay 0, which makes the pinning pass a no-op. Nothing downstream
needs to know.

The readout loop looks its window up by index rather than by channel, which is what the
`(channel, fc)` key needs.

The documented measurement baseline is unchanged, and it is the assertion:

    [measure-trace] average=0.000000 top=0.636620 peak to peak=2.000000 amplitude=1.000000
    [udpscope] measure cost: ... covered=75378013 buckets=1177164 raw=159674 (0.2%)

`top` is 2/pi to the digit -- the one reading a band leaking into the sums would move --
and `raw` at 0.2 % of `covered` says the fast path is still being used on an unfiltered
window.

The measure probe now enables `sine` itself, as the noise probe does: `enable_all_probe`
ends by switching a signal off, and with `sine` gone the probe measured `square`, where
`top` is 1.0 and the baseline above is unreachable.
This commit is contained in:
Martino Ferrari committed 2026-10-13 15:38:53 +02:00
1 parent 2730707372
commit 380ddb9a77
3 files changed
+78 -16

No files matched your search

+11 -1
View File
@@ -274,13 +274,23 @@ struct App {
/// them asked for. Several measurements on one signal must cost two walks, not two each -- and /// them asked for. Several measurements on one signal must cost two walks, not two each -- and
/// the window itself is never stored, which is what the chunked walks bought (see /// the window itself is never stored, which is what the chunked walks bought (see
/// `MeasureChunk`). /// `MeasureChunk`).
/// One channel's window, at one HF corner, with every level accumulator the pane asked for.
///
/// Keyed by `(channel, fc)` and not by channel alone: the corner is a per-measurement setting, so two
/// measurements on one signal at different corners are reading two different signals and cannot share a
/// walk.
struct MeasureWindow { struct MeasureWindow {
int channel = -1;
double fc = 0.0; ///< the HF corner this window was walked at; 0 is unfiltered
MeasureStats stats; MeasureStats stats;
std::vector<double> pct; ///< the level shares asked for, deduplicated std::vector<double> pct; ///< the level shares asked for, deduplicated
std::vector<double> noise; ///< each entry's band, as a percentage; parallel to `pct`
std::vector<MeasureLevel> level; ///< one accumulator per entry of `pct` std::vector<MeasureLevel> level; ///< one accumulator per entry of `pct`
MeasureFilter scan_filter; ///< pass one's state
MeasureFilter acc_filter; ///< pass two's; a separate walk, so a separate state
bool ok = false; bool ok = false;
}; };
std::unordered_map<int, MeasureWindow> measure_samples; std::vector<MeasureWindow> measure_samples;
/// The window's LOD buckets, reused across recomputes: a 1.7 M-sample window is 26 k of them, /// The window's LOD buckets, reused across recomputes: a 1.7 M-sample window is 26 k of them,
/// and both passes walk the same list. /// and both passes walk the same list.
std::vector<MeasureBucket> measure_bucket_scratch; std::vector<MeasureBucket> measure_bucket_scratch;
+57 -15
View File
@@ -261,6 +261,13 @@ void update_measures(App& app, bool force, bool accumulate) {
app.measure_stamp_valid = true; app.measure_stamp_valid = true;
const gint64 pass_start_us = g_get_monotonic_time(); const gint64 pass_start_us = g_get_monotonic_time();
app.measure_samples.clear(); app.measure_samples.clear();
// Before any `MeasureWindowReader` exists: the reader holds `model->mutex()` for a live window, and asking
// the model for anything while one is alive would deadlock.
double slot_dt = 0.0;
{
std::lock_guard<std::mutex> lock(app.model->mutex());
slot_dt = app.model->partition().slot_dt();
}
app.measure_results.assign(app.measures.size(), MeasureResult{}); app.measure_results.assign(app.measures.size(), MeasureResult{});
app.measure_last_valid.resize(app.measures.size()); app.measure_last_valid.resize(app.measures.size());
@@ -284,18 +291,42 @@ void update_measures(App& app, bool force, bool accumulate) {
// trace costs the same two walks as a single one. The common case is exactly one level: every // trace costs the same two walks as a single one. The common case is exactly one level: every
// crossing type reads the mid level and `Top`/`Bottom` default to it. // crossing type reads the mid level and `Top`/`Bottom` default to it.
std::vector<int> chan(app.measures.size(), -1); std::vector<int> chan(app.measures.size(), -1);
std::vector<int> want_window(app.measures.size(), -1);
std::vector<int> want_level(app.measures.size(), -1); std::vector<int> want_level(app.measures.size(), -1);
for (std::size_t i = 0; i < app.measures.size(); ++i) { for (std::size_t i = 0; i < app.measures.size(); ++i) {
const Measure& m = app.measures[i]; const Measure& m = app.measures[i];
const int channel = measure_channel(app, m.signal); const int channel = measure_channel(app, m.signal);
chan[i] = channel; chan[i] = channel;
if (channel < 0) continue; // unresolvable this pass: a rebuild has emptied the channel list if (channel < 0) continue; // unresolvable this pass: a rebuild has emptied the channel list
App::MeasureWindow& w = app.measure_samples[channel]; const double fc = m.options.hf_fc > 0.0 ? m.options.hf_fc : 0.0;
std::size_t wi = 0;
while (wi < app.measure_samples.size() &&
(app.measure_samples[wi].channel != channel || app.measure_samples[wi].fc != fc)) {
++wi;
}
if (wi == app.measure_samples.size()) {
App::MeasureWindow fresh;
fresh.channel = channel;
fresh.fc = fc;
measure_filter_set(fresh.scan_filter, fc, slot_dt);
measure_filter_set(fresh.acc_filter, fc, slot_dt);
app.measure_samples.push_back(fresh);
}
want_window[i] = static_cast<int>(wi);
App::MeasureWindow& w = app.measure_samples[wi];
double pct = 0.0; double pct = 0.0;
if (!measure_level_pct(m.type, m.options, pct)) continue; if (!measure_level_pct(m.type, m.options, pct)) continue;
// A type that does not read the band must not split the accumulator over it: its sums split at the bare
// level, so a `Top` is the same walk whatever the band is. Grouping those at the default is what keeps
// the common pane at exactly one level.
const double noise =
measure_uses_noise(m.type) ? m.options.noise_pct : MeasureOptions{}.noise_pct;
std::size_t k = 0; std::size_t k = 0;
while (k < w.pct.size() && w.pct[k] != pct) ++k; while (k < w.pct.size() && (w.pct[k] != pct || w.noise[k] != noise)) ++k;
if (k == w.pct.size()) w.pct.push_back(pct); if (k == w.pct.size()) {
w.pct.push_back(pct);
w.noise.push_back(noise);
}
want_level[i] = static_cast<int>(k); want_level[i] = static_cast<int>(k);
} }
@@ -313,10 +344,9 @@ void update_measures(App& app, bool force, bool accumulate) {
// degrades back to the O(window) cost and nothing else changes. // degrades back to the O(window) cost and nothing else changes.
constexpr std::size_t kChunk = 8192; constexpr std::size_t kChunk = 8192;
std::vector<MeasureBucket>& buckets = app.measure_bucket_scratch; std::vector<MeasureBucket>& buckets = app.measure_bucket_scratch;
for (auto& entry : app.measure_samples) { for (App::MeasureWindow& w : app.measure_samples) {
App::MeasureWindow& w = entry.second;
const gint64 fetch_start_us = g_get_monotonic_time(); const gint64 fetch_start_us = g_get_monotonic_time();
MeasureWindowReader reader(app, static_cast<std::size_t>(entry.first)); MeasureWindowReader reader(app, static_cast<std::size_t>(w.channel));
const std::size_t n = reader.size(); const std::size_t n = reader.size();
const std::function<double(std::size_t)> time_of = [&reader](std::size_t i) { const std::function<double(std::size_t)> time_of = [&reader](std::size_t i) {
return reader.time_of(i); return reader.time_of(i);
@@ -325,7 +355,12 @@ void update_measures(App& app, bool force, bool accumulate) {
u64 kb = 0; u64 kb = 0;
u64 ke = 0; u64 ke = 0;
buckets.clear(); buckets.clear();
if (reader.bucket_range(kb, ke)) { // A filtered window cannot use the pyramid: a bucket aggregates *unfiltered* samples, so its extremes
// and its sum describe a different signal. Leaving `buckets` empty is the whole disable -- `bulk_from ==
// bulk_to == n` makes the raw walk cover the window, and `lo_search`/`hi_search` stay 0, which makes
// `pin()` below a no-op. One `if` turns off both fast paths, and nothing downstream needs to know.
const bool filtered = w.fc > 0.0;
if (!filtered && reader.bucket_range(kb, ke)) {
buckets.reserve(static_cast<std::size_t>(ke - kb)); buckets.reserve(static_cast<std::size_t>(ke - kb));
for (u64 k = kb; k < ke; ++k) buckets.push_back(reader.bucket(k)); for (u64 k = kb; k < ke; ++k) buckets.push_back(reader.bucket(k));
} }
@@ -342,7 +377,7 @@ void update_measures(App& app, bool force, bool accumulate) {
} }
}; };
const auto scan = [&](const MeasureChunk& c) { measure_scan(c, w.stats); }; const auto scan = [&](const MeasureChunk& c) { measure_scan(c, w.stats, w.scan_filter); };
raw(0, bulk_from, scan); raw(0, bulk_from, scan);
if (!buckets.empty()) measure_scan_buckets(buckets.data(), buckets.size(), w.stats); if (!buckets.empty()) measure_scan_buckets(buckets.data(), buckets.size(), w.stats);
raw(bulk_to, n, scan); raw(bulk_to, n, scan);
@@ -351,8 +386,11 @@ void update_measures(App& app, bool force, bool accumulate) {
if (w.stats.valid) { if (w.stats.valid) {
const auto pin = [&](std::size_t& index, std::size_t& search, bool want_hi) { const auto pin = [&](std::size_t& index, std::size_t& search, bool want_hi) {
if (search == 0) return; if (search == 0) return;
// Only the bucket path leaves `search` non-zero, and a filtered window has no buckets, so this is
// reached pass-through only: a fresh filter here is the *same* filter.
MeasureFilter unfiltered;
MeasureStats one; MeasureStats one;
measure_scan(MeasureChunk{reader.values(index, search), search, index}, one); measure_scan(MeasureChunk{reader.values(index, search), search, index}, one, unfiltered);
if (one.valid) index = want_hi ? one.hi_i : one.lo_i; if (one.valid) index = want_hi ? one.hi_i : one.lo_i;
raw_read += search; raw_read += search;
search = 0; search = 0;
@@ -372,10 +410,10 @@ void update_measures(App& app, bool force, bool accumulate) {
// The level *and* its hysteresis band, in the engine: how wide the band is is a property of // The level *and* its hysteresis band, in the engine: how wide the band is is a property of
// how an edge is counted, not of the pane, and two expressions for it is how the walk and // how an edge is counted, not of the pane, and two expressions for it is how the walk and
// the bucket fast path end up disagreeing about where a crossing was. // the bucket fast path end up disagreeing about where a crossing was.
measure_set_level(w.level[k], w.stats, w.pct[k]); measure_set_level(w.level[k], w.stats, w.pct[k], 0.01 * w.noise[k]);
} }
const auto accumulate = [&](const MeasureChunk& c) { const auto accumulate = [&](const MeasureChunk& c) {
measure_accumulate(c, time_of, w.level.data(), w.level.size()); measure_accumulate(c, time_of, w.level.data(), w.level.size(), w.acc_filter);
}; };
raw(0, bulk_from, accumulate); raw(0, bulk_from, accumulate);
for (const MeasureBucket& b : buckets) { for (const MeasureBucket& b : buckets) {
@@ -388,7 +426,8 @@ void update_measures(App& app, bool force, bool accumulate) {
values = reader.values(b.offset, b.span); values = reader.values(b.offset, b.span);
raw_read += b.span; raw_read += b.span;
} }
measure_accumulate(MeasureChunk{values, b.span, b.offset}, time_of, &level, 1); measure_accumulate(MeasureChunk{values, b.span, b.offset}, time_of, &level, 1,
w.acc_filter);
} }
} }
raw(bulk_to, n, accumulate); raw(bulk_to, n, accumulate);
@@ -402,8 +441,11 @@ void update_measures(App& app, bool force, bool accumulate) {
for (std::size_t i = 0; i < app.measures.size(); ++i) { for (std::size_t i = 0; i < app.measures.size(); ++i) {
const Measure& m = app.measures[i]; const Measure& m = app.measures[i];
GtkWidget* label = app.measure_labels[i]; GtkWidget* label = app.measure_labels[i];
const auto it = chan[i] < 0 ? app.measure_samples.end() : app.measure_samples.find(chan[i]); const App::MeasureWindow* wp =
if (it == app.measure_samples.end() || !it->second.ok) { want_window[i] >= 0 && static_cast<std::size_t>(want_window[i]) < app.measure_samples.size()
? &app.measure_samples[static_cast<std::size_t>(want_window[i])]
: nullptr;
if (wp == nullptr || !wp->ok) {
// Its signal is momentarily unresolvable, or the window holds nothing -- so there is nothing // Its signal is momentarily unresolvable, or the window holds nothing -- so there is nothing
// to measure *this* pass. The marks still fall back (below); `continue`ing out of the marks // to measure *this* pass. The marks still fall back (below); `continue`ing out of the marks
// was the other half of the rulers blinking off. // was the other half of the rulers blinking off.
@@ -411,7 +453,7 @@ void update_measures(App& app, bool force, bool accumulate) {
if (label != nullptr) gtk_label_set_text(GTK_LABEL(label), "--"); if (label != nullptr) gtk_label_set_text(GTK_LABEL(label), "--");
continue; continue;
} }
const App::MeasureWindow& w = it->second; const App::MeasureWindow& w = *wp;
const MeasureLevel* level = const MeasureLevel* level =
want_level[i] >= 0 && static_cast<std::size_t>(want_level[i]) < w.level.size() want_level[i] >= 0 && static_cast<std::size_t>(want_level[i]) < w.level.size()
? &w.level[static_cast<std::size_t>(want_level[i])] ? &w.level[static_cast<std::size_t>(want_level[i])]
+10
View File
@@ -1000,6 +1000,16 @@ gboolean math_probe(gpointer user) {
gboolean measure_probe(gpointer user) { gboolean measure_probe(gpointer user) {
App* a = static_cast<App*>(user); App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE; if (a->window == nullptr) return G_SOURCE_REMOVE;
// The signal this probe measures is enabled *here*, for the same reason the noise probe enables its own:
// a probe must not depend on another probe's side effects. `enable_all_probe` ends by switching a signal
// off (it reads what a disabled signal does to the plots), and with `sine` gone the readings below were
// taken on `square` -- where `top` is 1.0, not the 2/pi the documented baseline is.
{
const std::vector<SignalInfo>& signals = a->model->signals();
for (std::size_t i = 0; i < signals.size(); ++i) {
if (signals[i].name == "sine") a->model->set_signal_enabled(i, true);
}
}
std::string name; std::string name;
{ {
std::lock_guard<std::mutex> lock(a->model->mutex()); std::lock_guard<std::mutex> lock(a->model->mutex());