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
+79 -17

No files matched your search

+57 -15
View File
@@ -261,6 +261,13 @@ void update_measures(App& app, bool force, bool accumulate) {
app.measure_stamp_valid = true;
const gint64 pass_start_us = g_get_monotonic_time();
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_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
// crossing type reads the mid level and `Top`/`Bottom` default to it.
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);
for (std::size_t i = 0; i < app.measures.size(); ++i) {
const Measure& m = app.measures[i];
const int channel = measure_channel(app, m.signal);
chan[i] = channel;
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;
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;
while (k < w.pct.size() && w.pct[k] != pct) ++k;
if (k == w.pct.size()) w.pct.push_back(pct);
while (k < w.pct.size() && (w.pct[k] != pct || w.noise[k] != noise)) ++k;
if (k == w.pct.size()) {
w.pct.push_back(pct);
w.noise.push_back(noise);
}
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.
constexpr std::size_t kChunk = 8192;
std::vector<MeasureBucket>& buckets = app.measure_bucket_scratch;
for (auto& entry : app.measure_samples) {
App::MeasureWindow& w = entry.second;
for (App::MeasureWindow& w : app.measure_samples) {
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::function<double(std::size_t)> time_of = [&reader](std::size_t i) {
return reader.time_of(i);
@@ -325,7 +355,12 @@ void update_measures(App& app, bool force, bool accumulate) {
u64 kb = 0;
u64 ke = 0;
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));
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);
if (!buckets.empty()) measure_scan_buckets(buckets.data(), buckets.size(), w.stats);
raw(bulk_to, n, scan);
@@ -351,8 +386,11 @@ void update_measures(App& app, bool force, bool accumulate) {
if (w.stats.valid) {
const auto pin = [&](std::size_t& index, std::size_t& search, bool want_hi) {
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;
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;
raw_read += search;
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
// 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.
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) {
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);
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);
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);
@@ -402,8 +441,11 @@ void update_measures(App& app, bool force, bool accumulate) {
for (std::size_t i = 0; i < app.measures.size(); ++i) {
const Measure& m = app.measures[i];
GtkWidget* label = app.measure_labels[i];
const auto it = chan[i] < 0 ? app.measure_samples.end() : app.measure_samples.find(chan[i]);
if (it == app.measure_samples.end() || !it->second.ok) {
const App::MeasureWindow* wp =
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
// 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.
@@ -411,7 +453,7 @@ void update_measures(App& app, bool force, bool accumulate) {
if (label != nullptr) gtk_label_set_text(GTK_LABEL(label), "--");
continue;
}
const App::MeasureWindow& w = it->second;
const App::MeasureWindow& w = *wp;
const MeasureLevel* level =
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])]