fixed plot flicker, measurement cost and toolbar icons

The flicker ("no data" at no constant frequency, worse with the measurements
and cursors panes up) was a torn read of the window's first stamp: acquire()
pins the window to the ring's oldest slot, and reading it at paint time races
the producer lapping it, returning a stamp one ring span too new. x0() is the
origin every trace point is reconstructed from, so one torn read produced a
refused neighbours(), a filter that dropped three quarters of the points, and
a polyline shifted off its axis. It is cached by acquire() under the lock now.

EventSeries::neighbours() answered from the (x - x0)/dx grid, whose drift from
the real arrival stamps arrives as a constant offset in seconds -- a doubling
share of the screen per zoom step, which is the "zooming into a held event
clips it more from the left" report. Both series binary-search the stamps now.

The measurements pane went through the LOD pyramid rather than reading its
window raw (19.7 ms per recompute to 1.2 ms, 0.1% of a 2 M-sample window) and
recomputes on a cadence; the math engine lost a per-slot heap allocation, a
per-sample source lookup by name and a fetch_add that cost more than the
arithmetic it counted.

The cursors pane's mix mode drew a line per *named* slot, so a slot holding a
signal that can never produce a channel -- the stream's timestamp -- got a row
that could only read "--", and such names were re-saved every session. It
counts bound traces now and apply_layout() refuses them.

Four toolbar icons are drawn for this app and compiled in as a GResource: the
stock ones were a record dot for the trigger and three magnifiers for cursors,
measurements and math.

New probes and dump lines for all of it (trace ink, measure cost, math cost,
event zoom, toolbar icons); README and CLAUDE.md record what they cure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-10-03 23:36:07 +02:00
1 parent 714d042809
commit a92a6b5bf0
20 files changed
+1945 -243

No files matched your search

+54 -13
View File
@@ -6,6 +6,7 @@
#include <udps_client.h>
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <stdexcept>
@@ -22,6 +23,23 @@ u64 pow2_floor(u64 v) {
return p;
}
std::uint64_t now_ns() {
return static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::nanoseconds>(
std::chrono::steady_clock::now().time_since_epoch())
.count());
}
/// One stored slot in this many is timed: see `ScopeModel::push_math_sample()`.
///
/// A *single* slot, deliberately, even though one slot's work is the same order as one
/// `clock_gettime` -- so the figure carries a clock read's own cost and is an upper bound. Timing a
/// *run* of consecutive slots to amortise that away was tried and is wrong: the producer gets its
/// slots a packet at a time, so a run long enough to be worth timing straddles a packet boundary
/// now and then and charges the measured slots the **wait for the next packet**. At 2000 samples a
/// packet a 64-slot run crosses one about 3% of the time, and a ~1.5 ms gap spread over 64 slots
/// reported 799 ns/slot against the true 78. One slot is always inside one packet.
constexpr std::uint64_t kMathCostSample = 1024;
} // namespace
u64 Partition::total_samples() const noexcept {
@@ -221,29 +239,52 @@ void ScopeModel::prepare_math_locked() {
// starting point for the new one. Both are reset here, together, so the two cannot drift apart.
math_rt_.assign(math_.size(), MathRuntime{});
const double dt = part_.slot_dt();
for (std::size_t i = 0; i < math_.size(); ++i) math_prepare(math_[i], math_rt_[i], dt);
// A `Source` node's channel index, resolved here so the per-sample step never looks a name up.
const auto index_of = [this](const std::string& name) -> int {
for (std::size_t c = 0; c < channels_.size(); ++c) {
if (channels_[c].name == name) return static_cast<int>(c);
}
return -1;
};
for (std::size_t i = 0; i < math_.size(); ++i) {
math_prepare(math_[i], math_rt_[i], dt, index_of);
}
}
void ScopeModel::push_math_sample(const std::vector<f32>& sample) {
// The common case is no derived channels at all, and this runs once per *stored slot* -- so the
// early-out is what keeps a stream with no math from paying for the feature.
if (math_.empty()) return;
// The cost is *sampled*: a run of `kMathCostRun` consecutive slots every `kMathCostPeriod`. This
// runs up to the wire rate, where a slot's own work is tens of nanoseconds -- so timing every
// slot is itself able to drop packets, and timing one slot at a time charges it a whole clock
// read. A run amortises both: one pair of reads per 64 measured slots, one pair per 65536 slots
// overall, and the figure converges over the millions this counter sees.
const std::uint64_t slot = math_slot_count_++;
math_slots_.store(math_slot_count_, std::memory_order_relaxed);
const bool timed = slot % kMathCostSample == 0;
if (timed) math_cost_t0_ = now_ns();
// Values for this slot, extended as the derived ones are computed: a graph may name another math
// channel, and nodes are evaluated in list order, so by the time one is asked for it is there.
std::vector<f32> values = sample;
const auto value_of = [&](const std::string& name) -> double {
for (std::size_t c = 0; c < channels_.size(); ++c) {
if (channels_[c].name != name) continue;
return c < values.size() ? static_cast<double>(values[c]) : std::nan("");
}
return std::nan("");
};
// `assign()` into a member reuses the capacity -- a local vector here was a heap allocation and
// free per stored slot, i.e. up to a million pairs a second on a fast stream.
math_values_.assign(sample.begin(), sample.end());
if (math_values_.size() < channels_.size()) math_values_.resize(channels_.size(), 0.0f);
for (std::size_t m = 0; m < math_.size(); ++m) {
const int channel = m < math_channel_.size() ? math_channel_[m] : -1;
if (channel < 0 || m >= math_rt_.size()) continue;
const double out = math_step(math_[m], math_rt_[m], value_of);
const double out =
math_step(math_[m], math_rt_[m], math_values_.data(), math_values_.size());
live().values(static_cast<std::size_t>(channel)).push(static_cast<f32>(out));
if (values.size() <= static_cast<std::size_t>(channel)) {
values.resize(static_cast<std::size_t>(channel) + 1, 0.0f);
if (static_cast<std::size_t>(channel) < math_values_.size()) {
math_values_[static_cast<std::size_t>(channel)] = static_cast<f32>(out);
}
values[static_cast<std::size_t>(channel)] = static_cast<f32>(out);
}
if (timed) {
math_cost_count_ += now_ns() - math_cost_t0_;
++math_timed_count_;
math_cost_ns_.store(math_cost_count_, std::memory_order_relaxed);
math_timed_.store(math_timed_count_, std::memory_order_relaxed);
}
}