Feature/source plugins #1

Merged
martino merged 130 commits from feature/source-plugins into main 2026-11-04 07:35:21 +01:00
20 changed files with 516 additions and 134 deletions
Showing only changes of commit 034c82f7d7 - Show all commits

No files matched your search

+34 -5
View File
@@ -39,6 +39,25 @@ double parse_double(const char* name, const std::string& v) {
}
}
/// A count with an optional decimal suffix: `250000`, `64k`, `100M`, `1.5G`. k, M and G are 10^3, 10^6
/// and 10^9 -- a *sample* count, not a byte size -- and either case is accepted.
std::uint64_t parse_count(const char* name, const std::string& v) {
std::string digits = v;
double scale = 1.0;
if (!digits.empty()) {
switch (digits.back()) {
case 'k': case 'K': scale = 1.0e3; break;
case 'm': case 'M': scale = 1.0e6; break;
case 'g': case 'G': scale = 1.0e9; break;
default: break;
}
if (scale != 1.0) digits.pop_back();
}
const double d = parse_double(name, digits) * scale;
if (!(d >= 1.0) || d > 1.0e12) fail(std::string(name) + " out of range: " + v);
return static_cast<std::uint64_t>(d + 0.5);
}
} // namespace
SourceConfig Config::source_config() const {
@@ -70,7 +89,11 @@ void print_usage(std::FILE* out, const char* argv0) {
" --source NAME source kind (%s; default udps). --server/--port/\n"
" --multicast/--interface configure udps\n"
" --source-opt K=V a setting for the source kind; repeatable\n"
" --buffer-mb MB size of each of the two sample buffers (default 200)\n"
" --buffer-samples N samples kept per acquired signal, e.g. 100M or 250000 (k, M\n"
" and G are decimal; default 10M). Rounded down to a power\n"
" of two: 100M gives 67108864\n"
" --memory-limit-mb MB hard ceiling on the sample rings, all signals together\n"
" (default 8192); the request is halved until it fits\n"
" --max-window SEC largest time window the toolbar offers (default 600)\n"
" --holdoff SEC default trigger hold-off (default 0.010)\n"
" --recv-buffer BYTES SO_RCVBUF for the receiver socket (default 8388608)\n"
@@ -125,10 +148,16 @@ Config parse_args(int argc, char** argv) {
} else if (a == "--source-opt" || a.rfind("--source-opt=", 0) == 0) {
const std::string v = value_of(argc, argv, i, "--source-opt");
if (!c.source_opts.parse_assignment(v)) fail("--source-opt must be key=value: " + v);
} else if (a == "--buffer-mb" || a.rfind("--buffer-mb=", 0) == 0) {
c.buffer_mb = parse_double("--buffer-mb", value_of(argc, argv, i, "--buffer-mb"));
c.buffer_mb_specified = true;
if (c.buffer_mb < 1.0) fail("--buffer-mb must be at least 1");
} else if (a == "--buffer-samples" || a.rfind("--buffer-samples=", 0) == 0) {
c.buffer_samples = parse_count("--buffer-samples", value_of(argc, argv, i, "--buffer-samples"));
c.buffer_samples_specified = true;
if (c.buffer_samples < 128) fail("--buffer-samples must be at least 128");
} else if (a == "--memory-limit-mb" || a.rfind("--memory-limit-mb=", 0) == 0) {
const double mb =
parse_double("--memory-limit-mb", value_of(argc, argv, i, "--memory-limit-mb"));
if (!(mb >= 1.0) || mb > 1.0e9) fail("--memory-limit-mb must be 1..1000000000 (MiB)");
c.memory_limit_mb = static_cast<std::uint64_t>(mb);
c.memory_limit_specified = true;
} else if (a == "--max-window" || a.rfind("--max-window=", 0) == 0) {
c.max_window_s = parse_double("--max-window", value_of(argc, argv, i, "--max-window"));
if (c.max_window_s <= 0.0) fail("--max-window must be positive");
+16 -8
View File
@@ -11,7 +11,7 @@
namespace udpscope {
/// Everything the command line can say. Defaults are the ones the spec names
/// (200 MB per buffer, 10 ms trigger hold-off) unless the user overrides them.
/// (10 M samples per signal, 10 ms trigger hold-off) unless the user overrides them.
struct Config {
std::string server = "127.0.0.1"; ///< IPv4 dotted quad of the stream source.
std::uint16_t port = 8000; ///< UDP port (unicast) or TCP port (multicast).
@@ -40,13 +40,21 @@ struct Config {
std::string session_host() const;
std::uint16_t session_port() const;
/// Size of *each* of the two sample buffers, in megabytes. The application
/// always allocates two identical buffers (spec item 1).
double buffer_mb = 200.0;
/// Whether `--buffer-mb` was given. A stored configuration's buffer size fills in only when the
/// flag was *absent*: an explicit flag beats a saved setting, and "not asked" is not the same
/// question as "asked for 200".
bool buffer_mb_specified = false;
/// The sample budget: how many samples to keep for **every acquired signal**, and for the shared
/// time ring. The rings are powers of two, so the request is rounded *down* to one (`buffer_ticks()`
/// in model.hpp) -- "up to" this many -- and the UI shows both figures. 10 M asks for 8 388 608.
std::uint64_t buffer_samples = 10'000'000;
/// Whether `--buffer-samples` was given. A stored configuration's sample budget fills in only when
/// the flag was *absent*: an explicit flag beats a saved setting, and "not asked" is not the same
/// question as "asked for 10 M".
bool buffer_samples_specified = false;
/// The hard ceiling on the ring storage, in MiB, for the whole application rather than per source.
/// `plan_partition()` halves the rings until they fit under it, so it can clip `buffer_samples`.
/// Default 8 GiB; the preferences carry it between runs and `--memory-limit-mb` overrides it.
std::uint64_t memory_limit_mb = 8192;
/// Whether `--memory-limit-mb` was given, so the stored preference only fills the gap.
bool memory_limit_specified = false;
/// Largest time window the toolbar offers, in seconds. The buffer partition is
/// sized to hold at least this much time (spec: decimation).
+7 -5
View File
@@ -328,7 +328,7 @@ std::vector<KnownSource> known_sources() {
s.port = port;
s.config = name;
s.comment = st.comment;
s.buffer_mb = st.buffer_mb;
s.buffer_samples = st.buffer_samples;
s.relative_time = st.relative_time;
s.capture_packets = st.capture_packets;
s.multicast = st.multicast;
@@ -347,7 +347,7 @@ std::vector<KnownSource> known_sources() {
// session file per probe run and every one holds `signals = ["", "", "", ""]`. One that
// carries a *setting* is worth a row even before it has ever streamed -- that is how a host is
// set up from the chooser's form, and the settings are the whole point of editing one.
const bool configured = s.signals > 0 || s.buffer_mb > 0.0 || !st.comment.empty() ||
const bool configured = s.signals > 0 || s.buffer_samples > 0 || !st.comment.empty() ||
st.relative_time || st.capture_packets > 0;
if (!configured) continue;
struct stat fb {};
@@ -583,9 +583,11 @@ bool load_layout(const std::string& path, LayoutState& out) {
if (SourceConfig::valid_key(k)) st.source_opts.set(k, strip_quote(value));
} else if (key == "comment") {
st.comment = strip_quote(value);
} else if (key == "buffer_mb") {
} else if (key == "buffer_samples") {
try {
st.buffer_mb = std::stod(value);
// A negative or absurd count is "not stored", not a request: `stoull` wraps a minus sign.
const unsigned long long n = value.find('-') == std::string::npos ? std::stoull(value) : 0;
st.buffer_samples = n >= 128 && n <= 1000000000000ull ? n : 0;
} catch (...) {
}
} else if (key == "relative_time") {
@@ -768,7 +770,7 @@ bool save_layout(const std::string& path, const LayoutState& state) {
o << "trigger_density_tau = " << state.trigger.density_tau_s << "\n";
o << "trigger_density_factor = " << state.trigger.density_factor << "\n";
if (!state.comment.empty()) o << "comment = " << toml_escape(state.comment) << "\n";
if (state.buffer_mb > 0.0) o << "buffer_mb = " << state.buffer_mb << "\n";
if (state.buffer_samples > 0) o << "buffer_samples = " << state.buffer_samples << "\n";
o << "relative_time = " << (state.relative_time ? "true" : "false") << "\n";
if (state.capture_packets > 0) o << "capture_packets = " << state.capture_packets << "\n";
for (const auto& row : state.rows) {
+7 -4
View File
@@ -139,9 +139,12 @@ struct LayoutState {
/// The startup settings the configuration carries, applied when the chooser opens it -- which is
/// why they live here and not only on the command line: two sources on one machine want different
/// buffers and different clocks, and retyping the flags to look at the second one is how a
/// setting gets forgotten. A zero `buffer_mb` and an empty `capture_packets` mean "not stored":
/// the command line's value stands, so an old file cannot silently shrink a buffer.
double buffer_mb = 0.0;
/// setting gets forgotten. A zero `buffer_samples` and an empty `capture_packets` mean "not stored":
/// the command line's value stands, so an old file cannot silently shrink a buffer. The budget is
/// samples per acquired signal (the memory limit is the application's, not a source's). A file from
/// before that unit carries `buffer_mb`, which is not read: it reads as "not stored" and the
/// default stands, rather than a megabyte count being taken for a sample count.
std::uint64_t buffer_samples = 0;
bool relative_time = false;
std::uint32_t capture_packets = 0;
};
@@ -162,7 +165,7 @@ struct KnownSource {
std::string config;
std::string comment; ///< the configuration's note, shown beside its name
/// The startup settings this configuration carries, so the chooser can apply them.
double buffer_mb = 0.0;
std::uint64_t buffer_samples = 0;
bool relative_time = false;
std::uint32_t capture_packets = 0;
};
+5 -1
View File
@@ -24,7 +24,11 @@ int main(int argc, char** argv) {
// The preferences fill in what the command line did not decide. `--relative-time` is an explicit
// instruction for this run, so a preference can only turn the mode on, never off -- which is also
// why this is read here, before the buffers are planned: the mode changes how they are filled.
if (udpscope::load_settings().relative_time) cfg.relative_time = true;
const udpscope::Settings prefs = udpscope::load_settings();
if (prefs.relative_time) cfg.relative_time = true;
// The memory limit is the same kind of statement, the other way round: the flag is the explicit
// instruction for this run and the preference only fills in when it was absent.
if (!cfg.memory_limit_specified) cfg.memory_limit_mb = static_cast<std::uint64_t>(prefs.memory_limit_mb);
} catch (const std::exception& e) {
std::fprintf(stderr, "udpscope: %s\n", e.what());
return 2;
+92 -41
View File
@@ -3,6 +3,8 @@
#include "model.hpp"
#include "status_text.hpp" // `format_samples()` / `format_bytes()`, for the one line a clipped plan prints
#include <algorithm>
#include <chrono>
#include <cmath>
@@ -40,12 +42,24 @@ constexpr std::uint64_t kMathCostSample = 1024;
} // namespace
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes,
double target_span_s, bool with_abs_time) {
u64 buffer_ticks(u64 requested_samples) {
// 2^40 slots is already 8 TiB of clock alone: a request past it is nonsense and the cap keeps
// `bytes_for()` far from overflowing.
constexpr u64 kMaxTicks = u64(1) << 40;
return std::min(pow2_floor(std::max<u64>(requested_samples, 128)), kMaxTicks);
}
u64 planned_bytes(u64 ticks, std::size_t channels, bool with_abs_time) {
return ticks * sizeof(f64) * (with_abs_time ? 2u : 1u) + ticks * sizeof(f32) * channels;
}
Partition plan_partition(const std::vector<double>& channel_dt, u64 samples_per_signal,
u64 max_bytes, double target_span_s, bool with_abs_time) {
Partition p;
const std::size_t n = channel_dt.size();
p.stride.assign(n, 1);
p.value_capacity.assign(n, 0);
p.requested_samples = samples_per_signal;
if (n == 0) return p;
// The fastest channel drives the shared time base: every other channel is
@@ -69,25 +83,13 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
p.abs_time = with_abs_time;
const u64 budget_bytes = static_cast<u64>(std::max(1.0, buffer_bytes));
// Weighted in *bytes*, not in slots: the shared clock is f64 and the values are f32
// (see `BufferStore::time()`), so a time slot costs twice a value sample and the two
// slices are not interchangeable. Counting both as one f32 is what would make
// `--buffer-mb` quietly understate the time ring by its own size.
double weight = static_cast<double>(sizeof(f64)) * (with_abs_time ? 2.0 : 1.0); // the time slice(s)
for (u64 s : p.stride) weight += static_cast<double>(sizeof(f32)) / static_cast<double>(s);
const u64 slots = static_cast<u64>(static_cast<double>(budget_bytes) / weight);
u64 time_capacity = pow2_floor(std::max<u64>(slots, 128));
if (time_capacity == 0) {
throw std::runtime_error("buffer too small for one time-base sample per channel");
}
// The target span sets a floor, not a ceiling: the rest of the budget buys a
// longer ring, which is strictly more history to scroll back through. Growth
// stops before the total would exceed the budget. A channel's slots are rounded
// *up* on the power of two `RingBuffer` will round them to anyway, so every
// channel covers at least the time ring's span.
// The ring is a power of two, so the user's count is a ceiling and the plan takes the largest
// power of two under it. The budget is in *samples* now: it is the memory limit, not the request,
// that is in bytes -- and a time slot (f64) and a value sample (f32) are not the same size, which
// is why the limit below is weighed with `bytes_for()` and not as a count of slots.
//
// A channel's slots are rounded *down* on the power of two `RingBuffer` will round them to
// anyway, so every channel covers at most the time ring's span.
// Floor, not ceiling: a channel's window must not be *longer* than the time ring
// it reads its stamps from. Rounding up (with a stride that is not a power of two)
// gave a channel more samples than the shared timeline covers, and the oldest of
@@ -103,19 +105,24 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
for (u64 s : p.stride) total += capacity_for(ticks, s) * sizeof(f32);
return total;
};
// `bytes_for` bounds the doubling on its own: it is at least `2 * ticks * sizeof(f64)`,
// so a pass means `2 * ticks` is well inside the budget and cannot overflow.
while (bytes_for(time_capacity * 2) <= budget_bytes) {
time_capacity *= 2;
u64 time_capacity = buffer_ticks(samples_per_signal);
const u64 wanted_ticks = time_capacity;
// The memory limit halves the request until the rings fit, never the other way: a bigger plan than
// asked for is not "up to".
while (time_capacity > 128 && bytes_for(time_capacity) > max_bytes) time_capacity /= 2;
if (bytes_for(time_capacity) > max_bytes) {
throw std::runtime_error("memory limit too small for the sample rings of " +
std::to_string(n) + " channels");
}
p.memory_limited = time_capacity < wanted_ticks;
p.time_capacity = time_capacity;
for (std::size_t i = 0; i < n; ++i) {
p.value_capacity[i] = capacity_for(p.time_capacity, p.stride[i]);
}
// Only now, with the ring as long as the budget can buy, decimate the shared clock
// Only now, with the ring as long as the request and the memory limit allow, decimate the shared clock
// enough for the requested window to fit in it: deciding this earlier would over-
// decimate by whatever factor the growth loop was about to add. A power of two keeps
// decimate by whatever the ring's final length would have made unnecessary. A power of two keeps
// the ring capacities (themselves powers of two) in step and lets the producer's group
// test be a mask (`Acquisition::acc_mask_`) rather than a modulo -- a modulo on a stride
// this function chose at runtime is a real 64-bit division, which the test *was* paying
@@ -178,6 +185,7 @@ void BufferStore::acquire_all() {
// --- ScopeModel -------------------------------------------------------------
ScopeModel::ScopeModel(const Config& cfg) : cfg_(cfg) {
memory_limit_bytes_.store(cfg.memory_limit_mb * 1024u * 1024u, std::memory_order_relaxed);
trigger_.holdoff_s = cfg.trigger_holdoff_s;
}
@@ -567,22 +575,16 @@ void ScopeModel::rebuild_locked() {
holes_.clear();
++plan_generation_;
std::vector<double> dts;
dts.reserve(channels_.size());
for (const ChannelInfo& c : channels_) dts.push_back(c.dt);
if (dts.empty()) {
if (channels_.empty()) {
part_ = Partition{};
store_.reset();
return;
}
const Partition want = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0,
target_span_.load(std::memory_order_relaxed),
cfg_.relative_time);
const Partition want = plan_locked(target_span_.load(std::memory_order_relaxed));
// A plan that describes the storage already allocated is cleared, not rebuilt. Allocating
// a store is what costs -- its rings are value-initialised, so it touches the whole
// `--buffer-mb` -- and most structural changes do not move the plan at all: a calibration
// a store is what costs -- its rings are value-initialised, so it touches all of the
// planned rings -- and most structural changes do not move the plan at all: a calibration
// keeps the channel set, and so does leaving an event for the live view. Startup asked for
// sixteen of these and eight of them planned the same buffer twice. Clearing has the same
// meaning (`BufferStore::reset()`): the history is gone either way, which is why
@@ -601,6 +603,17 @@ void ScopeModel::rebuild_locked() {
// the defect the line exists to name (it was fifteen of them at startup).
std::fprintf(stderr, "[udpscope] buffer allocated: %zu channels, %.3g s span, %.0f ms\n",
channels_.size(), part_.span_s, double(now_ns() - t0) / 1.0e6);
if (part_.memory_limited) {
// The one line that says the request was not honoured, and why: a smaller ring than the source
// form promised is otherwise explained nowhere.
std::fprintf(stderr,
"[udpscope] buffer limited to %s samples per signal (%s requested) by the %s "
"memory limit; the rings take %s\n",
format_samples(part_.time_capacity).c_str(),
format_samples(part_.requested_samples).c_str(),
format_bytes(memory_limit_bytes()).c_str(),
format_bytes(part_.bytes()).c_str());
}
std::fflush(stderr);
}
@@ -608,6 +621,47 @@ void ScopeModel::rebuild_locked() {
prepare_math_locked();
}
Partition ScopeModel::plan_locked(double seconds) const {
std::vector<double> dts;
dts.reserve(channels_.size());
for (const ChannelInfo& c : channels_) dts.push_back(c.dt);
return plan_partition(dts, cfg_.buffer_samples, memory_limit_bytes(), seconds, cfg_.relative_time);
}
bool ScopeModel::set_memory_limit_bytes(std::uint64_t bytes, bool replan) {
std::lock_guard<std::mutex> lock(mutex_);
const std::uint64_t previous = memory_limit_bytes_.load(std::memory_order_relaxed);
if (bytes == previous) return false;
if (channels_.empty() || !replan) {
memory_limit_bytes_.store(bytes, std::memory_order_relaxed);
return false;
}
memory_limit_bytes_.store(bytes, std::memory_order_relaxed);
Partition want;
try {
want = plan_locked(target_span_.load(std::memory_order_relaxed));
} catch (const std::exception& e) {
memory_limit_bytes_.store(previous, std::memory_order_relaxed);
std::fprintf(stderr, "[udpscope] memory limit %s refused: %s\n", format_bytes(bytes).c_str(),
e.what());
std::fflush(stderr);
return false;
}
if (store_ && part_.same_storage_as(want)) {
// The same rings: only what the plan *says* about itself moved. Written in place, because the
// producer reads `part_` under the lock we hold and nothing here reallocates it.
part_.requested_samples = want.requested_samples;
part_.memory_limited = want.memory_limited;
return false;
}
std::fprintf(stderr, "[udpscope] memory limit %s -> %s: buffer re-planned\n",
format_bytes(previous).c_str(), format_bytes(bytes).c_str());
std::fflush(stderr);
rebuild_locked();
generation_.fetch_add(1, std::memory_order_release);
return true;
}
bool ScopeModel::set_target_span(double seconds) {
if (!(seconds > 0.0)) return false;
std::lock_guard<std::mutex> lock(mutex_);
@@ -616,10 +670,7 @@ bool ScopeModel::set_target_span(double seconds) {
// Re-plan only if the factor moves. The planner is cheap, so ask it rather than
// reimplementing its arithmetic here -- two expressions for the same decision is how
// the buffer and the view end up disagreeing about the stored rate.
std::vector<double> dts;
dts.reserve(channels_.size());
for (const ChannelInfo& c : channels_) dts.push_back(c.dt);
const Partition want = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0, seconds, cfg_.relative_time);
const Partition want = plan_locked(seconds);
if (want.time_stride == part_.time_stride) return false;
std::fprintf(stderr,
"[udpscope] window %.4f s -> %.4f s: decimation %llu -> %llu, stored rate "
+67 -15
View File
@@ -104,9 +104,27 @@ struct Partition {
/// True when a second f64 ring parallel to the clock was planned and paid for in the
/// byte budget. Relative mode only -- in stream mode `time()` already *is* the absolute
/// clock and a copy would cost ~22% of the user's `--buffer-mb` for nothing.
/// clock and a copy would cost ~22% of the user's sample budget for nothing.
bool abs_time = false;
/// The samples-per-signal the user asked for, as given (not rounded). `time_capacity` is what
/// the plan actually holds: the request rounded down to a power of two, then halved further
/// while the rings did not fit the memory limit. These two are readings, not storage -- they are
/// not in `same_storage_as()` -- so a re-plan that only moves them does not drop history.
u64 requested_samples = 0;
/// True when the memory limit, and not the power-of-two rounding, made `time_capacity` smaller than
/// the request: the one case the UI has to explain.
bool memory_limited = false;
/// The bytes the rings of this plan occupy: the time ring (twice in relative mode) plus every
/// value ring. This is the figure to quote and to budget against; a count of the user's
/// request is not, because the rings round and the limit can clip.
u64 bytes() const noexcept {
u64 total = time_capacity * sizeof(f64) * (abs_time ? 2u : 1u);
for (u64 c : value_capacity) total += c * sizeof(f32);
return total;
}
/// Seconds between two time-base slots (the sample interval of every channel).
double slot_dt() const noexcept { return base_dt * static_cast<double>(time_stride); }
/// The rate samples are actually stored at, in samples per second.
@@ -137,13 +155,24 @@ struct Partition {
}
};
/// Plans a partition for `channel_dt` cadences within `buffer_bytes`, covering at least
/// `target_span_s` seconds of stream -- decimating (with the ingest filter) by whatever
/// power of two that takes. Throws std::runtime_error when the budget cannot hold even
/// one sample per channel. `with_abs_time` adds a second f64 per time slot to the budget
/// and sets `Partition::abs_time`.
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes,
double target_span_s, bool with_abs_time = false);
/// The ring length a request of `requested_samples` per signal buys: the largest power of two not
/// above it (at least 128). The rings are powers of two, so the user's number is a ceiling.
u64 buffer_ticks(u64 requested_samples);
/// What `ticks` slots cost for `channels` unit-stride channels: the f64 time ring (twice with the
/// absolute clock) plus one f32 ring per channel. The planner's own byte formula, for a caller
/// that has no `Partition` yet (the source form, the probes).
u64 planned_bytes(u64 ticks, std::size_t channels, bool with_abs_time);
/// Plans a partition for `channel_dt` cadences: `samples_per_signal` slots for the time ring and for
/// every channel, rounded down to a power of two, then halved while the rings (`planned_bytes()`)
/// exceed `max_bytes` -- the memory limit. Covers at least `target_span_s` seconds of stream,
/// decimating (with the ingest filter) by whatever power of two that takes. Throws
/// std::runtime_error when even 128 slots do not fit `max_bytes`. `with_abs_time` adds a second f64
/// per time slot to the budget and sets `Partition::abs_time`. `Partition::memory_limited` says
/// whether the limit clipped the request.
Partition plan_partition(const std::vector<double>& channel_dt, u64 samples_per_signal,
u64 max_bytes, double target_span_s, bool with_abs_time = false);
/// One of the two identical sample buffers: a shared time-base ring plus one
/// value ring and one `ChannelBuffer` per channel.
@@ -187,7 +216,7 @@ class BufferStore {
///
/// Why it exists: constructing a store *is* its cost. A ring is a
/// `vector<std::atomic<T>>`, which is value-initialised, so building one touches every
/// byte of the user's `--buffer-mb` -- about 300 ms per GB, nearly all of it first-touch
/// byte of the planned buffer (`--buffer-samples`) -- about 300 ms per GB, nearly all of it first-touch
/// page faults. Clearing is a count store per ring. Most structural changes (a
/// calibration, a re-enable of what was just disabled, going back to live) re-plan to the
/// *same* partition, and paying a second full allocation for them is what made a large
@@ -546,13 +575,13 @@ class ScopeModel {
std::uint64_t persist_dropped() const noexcept {
return persist_dropped_.load(std::memory_order_relaxed);
}
/// The bytes persistence may retain, which is the ring's own byte budget (`--buffer-mb`): the
/// snapshots are copies *of* what the rings hold, so holding as much again as the buffer does is
/// the honest ceiling for a display of it -- and it is the bound that cannot be driven into an
/// OOM by a long window on a fast stream, whatever depth the panel asks for.
/// The bytes persistence may retain, which is the rings' own planned byte total
/// (`Partition::bytes()`): the snapshots are copies *of* what the rings hold, so holding as much
/// again as the buffer does is the honest ceiling for a display of it -- and it is the bound that
/// cannot be driven into an OOM by a long window on a fast stream, whatever depth the panel asks
/// for. Zero until a stream has planned a buffer. Read under `mutex()`, like `partition()`.
std::size_t persist_budget_bytes() const noexcept {
const int mb = cfg_.buffer_mb > 0 ? cfg_.buffer_mb : 0;
return static_cast<std::size_t>(mb) * 1024u * 1024u;
return static_cast<std::size_t>(part_.bytes());
}
/// Sets how many acquisitions the persistence display keeps, clamped to `kMaxPersistDepth`, and
@@ -671,6 +700,22 @@ class ScopeModel {
/// `mutex()` itself; do not call it holding the lock.
/// Returns true when the buffer was rebuilt.
bool set_target_span(double seconds);
/// The memory limit the planner honours, in bytes. Starts as `Config::memory_limit_mb` and is the
/// one planner input that changes while the application runs (the preferences). Atomic so the
/// status and the probes can read it without the lock.
std::uint64_t memory_limit_bytes() const noexcept {
return memory_limit_bytes_.load(std::memory_order_relaxed);
}
/// Changes the memory limit and re-plans. The buffer is rebuilt -- and the history dropped -- only
/// when the new limit changes the *storage* (`Partition::same_storage_as()`): a limit that still
/// leaves room for the plan updates `Partition::memory_limited` in place and costs nothing.
/// Returns true when the buffer was rebuilt. Takes `mutex()` itself; do not call it holding the
/// lock. A limit too small for even 128 slots per channel is refused (returns false, keeps the old
/// limit) and said on stderr.
/// With `replan` false the limit is only stored, for the next plan: a file or a replayed event
/// lives in the buffer, and re-planning it would clear what the user is looking at.
bool set_memory_limit_bytes(std::uint64_t bytes, bool replan = true);
double target_span() const noexcept { return target_span_.load(std::memory_order_relaxed); }
/// Trigger state for the UI, published by the acquisition thread. Atomics
@@ -734,7 +779,14 @@ class ScopeModel {
/// so a window change that moves the decimation re-prepares the filters exactly as an edit does.
void prepare_math_locked();
/// Plans the partition for the current channels at `seconds` of window with the current sample
/// budget and memory limit. The one place the planner's inputs are assembled, so the re-plan on a
/// window change, a structural change and a memory-limit change cannot disagree. Throws what
/// `plan_partition()` throws. Caller holds `mutex()`.
Partition plan_locked(double seconds) const;
Config cfg_;
std::atomic<std::uint64_t> memory_limit_bytes_{0};
std::vector<SignalInfo> signals_;
std::vector<ChannelInfo> channels_;
// --- math channels --------------------------------------------------------
+5
View File
@@ -3,6 +3,7 @@
#include "settings.hpp"
#include <algorithm>
#include <cstdlib>
#include <cstring>
#include <fstream>
@@ -64,6 +65,9 @@ Settings load_settings() {
s.event_cache_max = std::max(0, std::atoi(value.c_str()));
} else if (key == "relative_time") {
s.relative_time = parse_bool(value, s.relative_time);
} else if (key == "memory_limit_mb") {
const long mb = std::strtol(value.c_str(), nullptr, 10);
if (mb >= 16) s.memory_limit_mb = static_cast<int>(std::min<long>(mb, 1000000000L));
} else if (key == "theme") {
if (value == "auto" || value == "light" || value == "dark") s.theme = value;
} else if (key == "contextual_help") {
@@ -83,6 +87,7 @@ bool save_settings(const Settings& settings) {
out << "event_cache_dir = \"" << settings.event_cache_dir << "\"\n";
out << "event_cache_max = " << settings.event_cache_max << "\n";
out << "relative_time = " << (settings.relative_time ? "true" : "false") << "\n";
out << "memory_limit_mb = " << settings.memory_limit_mb << "\n";
out << "theme = \"" << settings.theme << "\"\n";
// The two help keys are the *app's* memory rather than the dialog's: the contextual-help rows --
// the Help menu's and the source chooser's own -- write the first, and the first-run tour writes the
+6
View File
@@ -30,6 +30,12 @@ struct Settings {
/// buffers are planned before any stream is known, so it takes effect on the next start (the
/// preferences dialog says so, like the configurations dialog does).
bool relative_time = false;
/// The hard ceiling on the sample rings' memory, in MiB, for the whole application rather than per
/// source (`--memory-limit-mb` overrides it for one run). The planner halves a source's sample
/// budget until the rings fit under it. Unlike the cache directory and relative time it applies as
/// it is changed: a running scope re-plans its buffer (and so drops its history) when the new limit
/// changes the rings.
int memory_limit_mb = 8192;
/// "auto", "light" or "dark": what the interface should look like. Kept as the three names libadwaita
/// uses, so the file says what the setting is rather than what an enum happens to be numbered.
std::string theme = "auto";
+73 -27
View File
@@ -7,7 +7,9 @@
#include "help_window.hpp"
#include "layout_store.hpp"
#include "model.hpp" // `buffer_ticks()` / `planned_bytes()`: what a request becomes
#include "settings.hpp"
#include "status_text.hpp" // `format_samples()` and friends
#include "source/registry.hpp"
// The app's shared chrome -- `ui_impl::menu_button()`/`menu_toggle()` and `install_css()` -- so the
// chooser's help menu is the *same* menu the window's Help view is, not a second one that merely
@@ -21,6 +23,7 @@
#include <unistd.h>
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
@@ -47,7 +50,7 @@ void apply_source(Config& cfg, const KnownSource& src) {
// The configuration's own startup settings. They are read *before* the model exists, which is the
// only moment a buffer size can be applied at all -- and the reason they are stored per
// configuration rather than only passed on the command line.
if (src.buffer_mb > 0.0) cfg.buffer_mb = src.buffer_mb;
if (src.buffer_samples > 0) cfg.buffer_samples = src.buffer_samples;
// The list's own copy of the clock, but only where nothing newer has said: with the preferences
// open from this dialog a user can state relative time a minute before clicking the row, and that
// statement must survive the click that follows it.
@@ -59,8 +62,9 @@ void apply_source(Config& cfg, const KnownSource& src) {
void report_sources(const std::vector<KnownSource>& known) {
std::fprintf(stderr, "[sources] %zu known:", known.size());
for (const KnownSource& s : known) {
std::fprintf(stderr, " %s:%u/%s plots=%d signals=%d buffer=%.6g relative=%d capture=%u",
s.host.c_str(), s.port, s.config.c_str(), s.rows, s.signals, s.buffer_mb,
std::fprintf(stderr, " %s:%u/%s plots=%d signals=%d samples=%llu relative=%d capture=%u",
s.host.c_str(), s.port, s.config.c_str(), s.rows, s.signals,
static_cast<unsigned long long>(s.buffer_samples),
s.relative_time ? 1 : 0, s.capture_packets);
if (!s.comment.empty()) std::fprintf(stderr, " note='%s'", s.comment.c_str());
if (!s.multicast.empty()) std::fprintf(stderr, " multicast=%s", s.multicast.c_str());
@@ -405,9 +409,7 @@ void chooser_populate(Chooser& c) {
std::string sub = std::to_string(s.rows) + (s.rows == 1 ? " plot, " : " plots, ") +
std::to_string(s.signals) + (s.signals == 1 ? " signal" : " signals");
if (s.buffer_mb > 0.0) {
sub += ", buffer " + std::to_string(static_cast<int>(s.buffer_mb)) + " MB";
}
if (s.buffer_samples > 0) sub += ", buffer " + format_samples(s.buffer_samples) + " samples";
if (s.relative_time) sub += ", relative time";
if (s.capture_packets > 0) {
sub += ", capture " + std::to_string(s.capture_packets) + " packets";
@@ -442,6 +444,32 @@ void chooser_populate(Chooser& c) {
}
}
/// The form's sample budget as a count: the spin is in millions, and a count is what is stored.
std::uint64_t form_buffer_samples(const Chooser& c) {
const double m = adw_spin_row_get_value(c.form_buffer);
return static_cast<std::uint64_t>(std::max(128.0, std::round(m * 1.0e6)));
}
/// Says under the "Samples per signal" row what the number will become: the rings are powers of two,
/// so the request is a ceiling, and the memory limit can lower it further. The limit is judged for
/// *one* signal here -- the channel count is not known until a stream declares it -- so a clip shown
/// is certain, and the status line says what a real stream got. The cost line gives the two figures
/// the user can multiply by their own signal count.
void update_buffer_note(Chooser& c) {
if (c.form_buffer == nullptr) return;
const std::uint64_t requested = form_buffer_samples(c);
const bool relative = adw_switch_row_get_active(c.form_relative) != FALSE;
const std::uint64_t limit = c.cfg->memory_limit_mb * 1024u * 1024u;
const std::uint64_t wanted = buffer_ticks(requested);
std::uint64_t used = wanted;
while (used > 128 && planned_bytes(used, 1, relative) > limit) used /= 2;
const std::string note =
describe_buffer(requested, used, used < wanted, limit) + ". The rings take " +
format_bytes(used * sizeof(double) * (relative ? 2 : 1)) + " for the clock and " +
format_bytes(used * sizeof(float)) + " per signal.";
adw_action_row_set_subtitle(ADW_ACTION_ROW(c.form_buffer), note.c_str());
}
void form_close(Chooser& c) {
// The form's own `closed` handler clears the pointer, so this is the only place that has to know how
// an `AdwDialog` closes.
@@ -497,7 +525,7 @@ void on_form_connect(GtkButton*, gpointer user) {
const std::string iface = udps ? scfg.get("interface") : std::string();
std::string name = gtk_editable_get_text(GTK_EDITABLE(c.form_name));
const std::string note = gtk_editable_get_text(GTK_EDITABLE(c.form_note));
const double buffer_mb = adw_spin_row_get_value(c.form_buffer);
const std::uint64_t buffer_samples = form_buffer_samples(c);
const bool relative = adw_switch_row_get_active(c.form_relative) != FALSE;
if (name.empty()) name = "default";
@@ -513,7 +541,7 @@ void on_form_connect(GtkButton*, gpointer user) {
}
// The startup settings. Applied here because this runs *before* `ScopeModel` exists, which is the
// only moment a buffer size can be applied at all -- and stored, so the next run gets them back.
c.cfg->buffer_mb = buffer_mb;
c.cfg->buffer_samples = buffer_samples;
c.cfg->relative_time = relative;
LayoutState st;
@@ -523,7 +551,7 @@ void on_form_connect(GtkButton*, gpointer user) {
load_layout(configuration_path(c.edit_host, c.edit_port, c.edit_config), st);
}
st.comment = note;
st.buffer_mb = buffer_mb;
st.buffer_samples = buffer_samples;
st.relative_time = relative;
st.multicast = group;
st.interface_addr = iface;
@@ -541,9 +569,9 @@ void on_form_connect(GtkButton*, gpointer user) {
c.saved_config = name;
c.saved_note = note;
std::fprintf(stderr,
"[sources] configuration %s: %s:%u/%s note='%s' buffer=%.6g relative=%d\n",
"[sources] configuration %s: %s:%u/%s note='%s' samples=%llu relative=%d\n",
editing ? "saved" : "created", host.c_str(), port, name.c_str(), note.c_str(),
buffer_mb, relative ? 1 : 0);
static_cast<unsigned long long>(buffer_samples), relative ? 1 : 0);
std::fflush(stderr);
} else {
std::fprintf(stderr, "[sources] configuration write failed: %s:%u/%s\n", host.c_str(), port,
@@ -624,12 +652,24 @@ void open_form(Chooser& c, int edit_index = -1) {
// the rows' subtitles, so an empty field still says what empty means.
gtk_editable_set_text(GTK_EDITABLE(c.form_name), src != nullptr ? src->config.c_str() : "default");
gtk_editable_set_text(GTK_EDITABLE(c.form_note), src != nullptr ? src->comment.c_str() : "");
adw_spin_row_set_value(c.form_buffer, src != nullptr && src->buffer_mb > 0.0
? src->buffer_mb
: std::max(1.0, c.cfg->buffer_mb));
adw_spin_row_set_value(c.form_buffer, double(src != nullptr && src->buffer_samples > 0
? src->buffer_samples
: c.cfg->buffer_samples) /
1.0e6);
update_buffer_note(c);
g_signal_connect(c.form_buffer, "notify::value", G_CALLBACK(+[](GObject*, GParamSpec*, gpointer user) {
update_buffer_note(*static_cast<Chooser*>(user));
}),
&c);
adw_switch_row_set_active(c.form_relative,
(src != nullptr ? src->relative_time : c.cfg->relative_time) ? TRUE
: FALSE);
// The relative clock doubles the time ring, so the note under the sample budget follows it.
g_signal_connect(c.form_relative, "notify::active", G_CALLBACK(+[](GObject*, GParamSpec*, gpointer user) {
update_buffer_note(*static_cast<Chooser*>(user));
}),
&c);
update_buffer_note(c);
// The dropdown lists the registered kinds. A new host starts on the kind the command line named
// (UDPS by default); an edit starts on the configuration's own kind and cannot change it --
@@ -701,10 +741,11 @@ void report_pick(const Chooser& c) {
// size would look identical here otherwise, and the buffer is the one of them a user notices
// only after a long capture has been sized wrong.
std::fprintf(stderr,
"[sources] picked %s:%u multicast=%s interface=%s buffer=%.6g relative=%d "
"[sources] picked %s:%u multicast=%s interface=%s samples=%llu relative=%d "
"capture=%u config=%s note='%s'\n",
c.cfg->server.c_str(), c.cfg->port, c.cfg->multicast.c_str(),
c.cfg->interface_addr.c_str(), c.cfg->buffer_mb, c.cfg->relative_time ? 1 : 0,
c.cfg->interface_addr.c_str(),
static_cast<unsigned long long>(c.cfg->buffer_samples), c.cfg->relative_time ? 1 : 0,
c.cfg->capture_packets,
(c.saved_config.empty() ? (c.known.empty() ? "-" : c.known.front().config)
: c.saved_config)
@@ -748,6 +789,10 @@ void open_chooser_preferences(Chooser& c) {
// stored configuration, and it works in both directions -- turning it on *and* off.
c.cfg->relative_time = s.relative_time;
c.cfg->relative_time_specified = true;
// The memory limit is read by the model when it is built, which is after this returns, so the
// chooser's own change is the newest word on it for the run about to start.
c.cfg->memory_limit_mb = static_cast<std::uint64_t>(s.memory_limit_mb);
c.cfg->memory_limit_specified = true;
if (!written) {
std::fprintf(stderr, "[sources] prefs: could not write %s\n", settings_path().c_str());
std::fflush(stderr);
@@ -1171,7 +1216,7 @@ gboolean pick_probe(gpointer user) {
c.form_cfg.write(sc);
gtk_editable_set_text(GTK_EDITABLE(c.form_name), "probe-host");
gtk_editable_set_text(GTK_EDITABLE(c.form_note), "made by the new-host probe");
adw_spin_row_set_value(c.form_buffer, 32);
adw_spin_row_set_value(c.form_buffer, 0.5);
adw_switch_row_set_active(c.form_relative, TRUE);
// 9/9 for UDPS: the form's row titles are mnemonics (`Source _type`, `_Server`, `_Port`, ...). The
// total follows the kind's row count: five rows are the chooser's, the rest the kind's own.
@@ -1183,7 +1228,7 @@ gboolean pick_probe(gpointer user) {
std::fprintf(stderr, "[sources] form mnemonics=%d/%zu\n", underlines,
5 + c.form_cfg.rows.size());
std::fprintf(stderr,
"[sources] form: name='%s' note='%s' buffer=%.6g relative=%d "
"[sources] form: name='%s' note='%s' samples_m=%.6g relative=%d "
"connect_sensitive=%d\n",
gtk_editable_get_text(GTK_EDITABLE(c.form_name)),
gtk_editable_get_text(GTK_EDITABLE(c.form_note)),
@@ -1216,14 +1261,14 @@ gboolean pick_probe(gpointer user) {
// The reading is the *pre-fill*: an edit form that opens empty is the defect this asserts
// against, and the button's label says which mode it is in.
std::fprintf(stderr,
"[sources] edit form: name='%s' note='%s' buffer=%.6g relative=%d button='%s'\n",
"[sources] edit form: name='%s' note='%s' samples_m=%.6g relative=%d button='%s'\n",
gtk_editable_get_text(GTK_EDITABLE(c.form_name)),
gtk_editable_get_text(GTK_EDITABLE(c.form_note)),
adw_spin_row_get_value(c.form_buffer),
adw_switch_row_get_active(c.form_relative) ? 1 : 0,
gtk_button_get_label(GTK_BUTTON(c.form_connect)));
gtk_editable_set_text(GTK_EDITABLE(c.form_note), "edited by the probe");
adw_spin_row_set_value(c.form_buffer, 16);
adw_spin_row_set_value(c.form_buffer, 0.25);
adw_switch_row_set_active(c.form_relative, FALSE);
// `UDPSCOPE_PROBE_EDITNAME=new-name` also renames it, which is a *move*: the old file must not
// survive, or the chooser lists the same session twice.
@@ -1263,16 +1308,17 @@ void apply_stored_settings(Config& cfg) {
// Only where nothing newer was said: a flag the user typed, or the preferences they just applied,
// wins -- because it is the more recent statement of intent, and the file is not a place to hide a
// surprise from whoever just made it.
if (!cfg.buffer_mb_specified && st.buffer_mb > 0.0) cfg.buffer_mb = st.buffer_mb;
if (!cfg.buffer_samples_specified && st.buffer_samples > 0) cfg.buffer_samples = st.buffer_samples;
const bool relative = !cfg.relative_time_specified && st.relative_time;
if (relative) cfg.relative_time = true;
if (cfg.capture_packets == 0 && st.capture_packets > 0) cfg.capture_packets = st.capture_packets;
const bool applied = (!cfg.buffer_mb_specified && st.buffer_mb > 0.0) || relative ||
const bool applied = (!cfg.buffer_samples_specified && st.buffer_samples > 0) || relative ||
st.capture_packets > 0;
if (applied) {
std::fprintf(stderr,
"[sources] settings from configuration '%s': buffer=%.6g relative=%d capture=%u\n",
want.c_str(), cfg.buffer_mb, cfg.relative_time ? 1 : 0, cfg.capture_packets);
"[sources] settings from configuration '%s': samples=%llu relative=%d capture=%u\n",
want.c_str(), static_cast<unsigned long long>(cfg.buffer_samples),
cfg.relative_time ? 1 : 0, cfg.capture_packets);
std::fflush(stderr);
}
}
@@ -1330,9 +1376,9 @@ void loading_show(const Config& cfg) {
head = "Connecting to " + cfg.server + ":" + std::to_string(cfg.port);
if (!cfg.multicast.empty()) head += " (" + cfg.multicast + ")";
}
char buf[128];
std::snprintf(buf, sizeof(buf), "planning the %.0f MB sample buffer", cfg.buffer_mb);
sub = buf;
sub = "planning the buffer: " + format_samples(buffer_ticks(cfg.buffer_samples)) +
" samples per signal, " + format_bytes(cfg.memory_limit_mb * 1024u * 1024u) +
" memory limit";
} else {
const std::size_t slash = cfg.open_waveform.rfind('/');
head = "Opening " + (slash == std::string::npos ? cfg.open_waveform
+49 -4
View File
@@ -41,6 +41,43 @@ std::string format_rate(double per_s) {
return buf;
}
std::string format_samples(std::uint64_t n) {
char buf[32];
const double d = static_cast<double>(n);
if (n >= 1000000000ull) {
std::snprintf(buf, sizeof(buf), "%.3g G", d / 1.0e9);
} else if (n >= 1000000ull) {
std::snprintf(buf, sizeof(buf), "%.3g M", d / 1.0e6);
} else if (n >= 1000ull) {
std::snprintf(buf, sizeof(buf), "%.3g k", d / 1.0e3);
} else {
std::snprintf(buf, sizeof(buf), "%llu", static_cast<unsigned long long>(n));
}
return buf;
}
std::string format_bytes(std::uint64_t bytes) {
char buf[32];
const double d = static_cast<double>(bytes);
if (bytes >= (1ull << 30)) {
std::snprintf(buf, sizeof(buf), "%.3g GiB", d / double(1ull << 30));
} else if (bytes >= (1ull << 20)) {
std::snprintf(buf, sizeof(buf), "%.3g MiB", d / double(1ull << 20));
} else if (bytes >= (1ull << 10)) {
std::snprintf(buf, sizeof(buf), "%.3g KiB", d / double(1ull << 10));
} else {
std::snprintf(buf, sizeof(buf), "%llu B", static_cast<unsigned long long>(bytes));
}
return buf;
}
std::string describe_buffer(std::uint64_t requested, std::uint64_t used, bool memory_limited,
std::uint64_t memory_limit_bytes) {
std::string out = format_samples(requested) + " requested, " + format_samples(used) + " used";
if (memory_limited) out += " (limited by the " + format_bytes(memory_limit_bytes) + " memory limit)";
return out;
}
std::string format_glance(const StatusSnapshot& s) {
std::string out = s.state_word;
out += " · ";
@@ -68,17 +105,25 @@ std::string format_detail(const StatusSnapshot& s) {
char buf[1024];
std::snprintf(buf, sizeof(buf),
"[%s] %s %s | rate %s | packets %llu frames %llu gaps %llu | %zu signals, "
"%zu channels | plot 1: %d trace(s), %zu samples | buffer %.1f MB, %.2f s "
"span, time x%llu",
"%zu channels | plot 1: %d trace(s), %zu samples | buffer %s samples/signal "
"(%s), %.2f s span, time x%llu",
s.state_detail.c_str(), s.connected ? "connected" : "waiting",
s.have_config ? "configured" : "-",
s.rate_text.empty() ? "--" : s.rate_text.c_str(),
static_cast<unsigned long long>(s.packets),
static_cast<unsigned long long>(s.frames),
static_cast<unsigned long long>(s.gaps), s.signals, s.channels,
s.plot1_traces, s.plot1_samples, s.buffer_mb, s.span_s,
static_cast<unsigned long long>(s.time_stride));
s.plot1_traces, s.plot1_samples, format_samples(s.buffer_samples).c_str(),
s.buffer_bytes > 0 ? format_bytes(s.buffer_bytes).c_str() : "not planned",
s.span_s, static_cast<unsigned long long>(s.time_stride));
std::string out = buf;
// The request is quoted only where it differs from what was planned, and the limit only where it
// is the reason: a clean run says the figure once, and a clipped one says why in the same breath.
if (s.memory_limited) {
out += " | limited to " + format_samples(s.buffer_samples) + " samples by the " +
format_bytes(s.memory_limit_bytes) + " memory limit (" +
format_samples(s.buffer_requested) + " requested)";
}
// Nothing about resequencing is said at all while all six read zero, which is a stream that
// never lost ordering: the field would otherwise be six zeros on every clean run, and this
// string is read by eye out of the exit dump.
+22 -1
View File
@@ -41,7 +41,15 @@ struct StatusSnapshot {
std::size_t channels = 0;
int plot1_traces = 0;
std::size_t plot1_samples = 0;
double buffer_mb = 0.0;
/// The sample budget as planned: slots per signal the rings hold (the request rounded down to a
/// power of two and clipped by the memory limit), what was asked for, and the bytes those rings
/// occupy -- 0 until a stream has planned a buffer. `memory_limited` says the limit, and not the
/// rounding, made `buffer_samples` smaller than the request.
std::uint64_t buffer_samples = 0;
std::uint64_t buffer_requested = 0;
std::uint64_t buffer_bytes = 0;
std::uint64_t memory_limit_bytes = 0;
bool memory_limited = false;
double span_s = 0.0;
std::uint64_t time_stride = 1;
bool have_event = false;
@@ -82,6 +90,19 @@ std::string format_detail(const StatusSnapshot& s);
/// cannot track the stream. Moved here from ui.cpp, which still calls it.
std::string format_rate(double hz);
/// A sample count as "67.1 M": `%.3g` of the count in the unit it reads shortest in (k, M, G; decimal,
/// like `--buffer-samples`). Under a thousand it is the bare number.
std::string format_samples(std::uint64_t n);
/// A byte count in binary units, "168 MiB" / "8 GiB", `%.3g` so the width does not track the figure.
std::string format_bytes(std::uint64_t bytes);
/// The sentence the source form, the status popover and the exit dump share for a sample budget:
/// "100 M requested, 67.1 M used", and " (limited by the 1 GiB memory limit)" when the limit clipped it.
/// `requested` is the user's number, `used` the planned slots per signal.
std::string describe_buffer(std::uint64_t requested, std::uint64_t used, bool memory_limited,
std::uint64_t memory_limit_bytes);
/// An elapsed time, stepping to a coarser unit rather than growing digits, so its width
/// cannot track the stream. Moved here from ui.cpp.
std::string format_elapsed(double seconds);
+50 -3
View File
@@ -4397,7 +4397,16 @@ gboolean tick(gpointer user) {
snap.channels = channel_count;
snap.plot1_traces = bound;
snap.plot1_samples = samples;
snap.buffer_mb = double(model.config().buffer_mb);
{
// The planned buffer when there is one; before the first CONFIG there is no plan, and the
// request (rounded as the ring will round it) is the honest figure, with no bytes to quote.
const Partition& part = model.partition();
snap.buffer_requested = model.config().buffer_samples;
snap.buffer_samples = part.time_capacity > 0 ? part.time_capacity : buffer_ticks(snap.buffer_requested);
snap.buffer_bytes = part.bytes();
snap.memory_limit_bytes = model.memory_limit_bytes();
snap.memory_limited = part.memory_limited;
}
snap.span_s = span_s;
snap.time_stride = time_stride;
snap.have_event = app->last_latch_us != 0;
@@ -5281,8 +5290,9 @@ void on_activate(GtkApplication* application, gpointer user) {
if (app->waveform_name.empty()) {
const Config& cfg = app->model->config();
char buf[192];
std::snprintf(buf, sizeof(buf), "connecting to %s:%u -- planning the %.0f MB buffer",
cfg.server.c_str(), unsigned(cfg.port), cfg.buffer_mb);
std::snprintf(buf, sizeof(buf), "connecting to %s:%u -- planning the buffer: %s samples per signal",
cfg.server.c_str(), unsigned(cfg.port),
format_samples(buffer_ticks(cfg.buffer_samples)).c_str());
set_notice(*app, buf, 6);
}
}
@@ -6081,6 +6091,43 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
static_cast<unsigned long long>(model.live().values(i).produced()),
static_cast<unsigned long long>(model.live().channel(i).size()));
}
// The sample budget as this run planned it, and the planner on fixed inputs. The first line is
// the live buffer: `used` is the request rounded down to a power of two and clipped by the memory
// limit, `bytes` the rings' real size. The second feeds `plan_partition()` three channels and a
// 100 M request three ways -- room to spare, a 1 GiB limit that must clip, a limit too small for
// even 128 slots -- so a change to the rounding, the halving or the byte formula reads here even
// when the run's own plan happens not to be clipped.
std::printf("[udpscope] buffer: requested=%llu used=%llu bytes=%llu limit=%llu limited=%d\n",
static_cast<unsigned long long>(model.config().buffer_samples),
static_cast<unsigned long long>(part.time_capacity),
static_cast<unsigned long long>(part.bytes()),
static_cast<unsigned long long>(model.memory_limit_bytes()),
part.memory_limited ? 1 : 0);
{
const std::vector<double> dts(3, 1.0e-4);
const auto pow2 = [](unsigned long long v) { return v != 0 && (v & (v - 1)) == 0; };
const Partition open = plan_partition(dts, 100000000ull, 16ull << 30, 0.0);
constexpr unsigned long long kGiB = 1ull << 30;
const Partition clip = plan_partition(dts, 100000000ull, kGiB, 0.0);
const Partition clip_rel = plan_partition(dts, 100000000ull, kGiB, 0.0, true);
bool thrown = false;
try {
(void)plan_partition(dts, 100000000ull, 1000, 0.0);
} catch (const std::exception&) {
thrown = true;
}
std::printf("[udpscope] planner: unclipped got=%llu pow2=%d limited=%d | clipped(1GiB) "
"got=%llu bytes=%llu fits=%d pow2=%d limited=%d | clipped_rel got=%llu "
"bytes=%llu fits=%d | too_small_throws=%d\n",
static_cast<unsigned long long>(open.time_capacity),
pow2(open.time_capacity) ? 1 : 0, open.memory_limited ? 1 : 0,
static_cast<unsigned long long>(clip.time_capacity),
static_cast<unsigned long long>(clip.bytes()), clip.bytes() <= kGiB ? 1 : 0,
pow2(clip.time_capacity) ? 1 : 0, clip.memory_limited ? 1 : 0,
static_cast<unsigned long long>(clip_rel.time_capacity),
static_cast<unsigned long long>(clip_rel.bytes()),
clip_rel.bytes() <= kGiB ? 1 : 0, thrown ? 1 : 0);
}
// `frames_painted` is the area's, read from the snapshot taken before the window was
// destroyed (the PlotArea wrapper dies with its widget): one figure, one render per
// frame, so it is the same count for every row.
+22 -1
View File
@@ -13,7 +13,7 @@
show-apply-button is off here: the write is a few bytes to one file, so a keystroke is not worth
batching). The two settings that cannot take effect immediately — the cache directory and relative
time, both of which are planned before any source is known — say so in their subtitles instead of
being held back by a button.
being held back by a button. The memory limit does take effect at once: a running buffer re-plans.
Nothing here carries a value: `ui_prefs.cpp` fills the rows from `Settings` when the dialog opens,
and writes it back on every change. This file is the shape, that is the state.
@@ -77,6 +77,27 @@
</child>
</object>
</child>
<child>
<object class="AdwPreferencesGroup">
<property name="title" translatable="yes">Sample buffer</property>
<child>
<object class="AdwSpinRow" id="memory_row">
<property name="title" translatable="yes">_Memory limit (MB)</property>
<property name="use-underline">True</property>
<property name="subtitle" translatable="yes">Hard ceiling on the memory of the sample rings, all signals together. A source asks for a number of samples per signal; if that does not fit under this limit it is halved until it does, and the status line says so. Applies at once: a running buffer is re-planned, which clears its history.</property>
<property name="adjustment">
<object class="GtkAdjustment">
<property name="lower">16</property>
<property name="upper">1048576</property>
<property name="step-increment">256</property>
<property name="page-increment">1024</property>
<property name="value">8192</property>
</object>
</property>
</object>
</child>
</object>
</child>
</object>
</child>
<child>
+7 -5
View File
@@ -85,15 +85,17 @@
</child>
<child>
<object class="AdwSpinRow" id="buffer_row">
<property name="title" translatable="yes">_Buffer MB</property>
<property name="title" translatable="yes">_Samples per signal (millions)</property>
<property name="use-underline">True</property>
<property name="subtitle" translatable="yes">sample buffer per source, applied when this source is opened</property>
<property name="subtitle" translatable="yes">samples kept for every signal, rounded down to a power of two; applied when this source is opened</property>
<property name="digits">3</property>
<property name="adjustment">
<object class="GtkAdjustment">
<property name="lower">1</property>
<property name="upper">65536</property>
<property name="lower">0.001</property>
<property name="upper">4096</property>
<property name="step-increment">1</property>
<property name="value">64</property>
<property name="page-increment">10</property>
<property name="value">10</property>
</object>
</property>
</object>
+5 -7
View File
@@ -103,7 +103,7 @@ LayoutState collect_layout(App& app) {
// *startup* parameter -- the rings are allocated before any stream is known -- so a configuration
// is the only place it can be remembered per source, and the chooser applies it on open.
st.comment = app.config_comment;
st.buffer_mb = app.model->config().buffer_mb;
st.buffer_samples = app.model->config().buffer_samples;
st.relative_time = app.model->config().relative_time;
st.capture_packets = app.model->config().capture_packets;
return st;
@@ -135,8 +135,8 @@ void on_config_row_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) {
// a configuration that appears to half-apply.
const Config& cfg = app.model->config();
std::string later;
if (st.buffer_mb > 0.0 && st.buffer_mb != cfg.buffer_mb) {
later += "buffer " + std::to_string(static_cast<int>(st.buffer_mb)) + " MB";
if (st.buffer_samples > 0 && st.buffer_samples != cfg.buffer_samples) {
later += "buffer " + format_samples(st.buffer_samples) + " samples";
}
if (st.relative_time != cfg.relative_time) {
later += later.empty() ? "" : ", ";
@@ -237,9 +237,7 @@ void config_dialog_refresh(ConfigDialog& d) {
}
std::string sub = std::to_string(st.rows.size()) + " plots, " + std::to_string(signals) +
" signals";
if (st.buffer_mb > 0.0) {
sub += ", buffer " + std::to_string(static_cast<int>(st.buffer_mb)) + " MB";
}
if (st.buffer_samples > 0) sub += ", buffer " + format_samples(st.buffer_samples) + " samples";
if (st.relative_time) sub += ", relative time";
GtkWidget* counts = gtk_label_new(sub.c_str());
gtk_widget_add_css_class(counts, "dim-label");
@@ -410,7 +408,7 @@ void apply_layout(App& app, const LayoutState& st) {
// What the session had on, collected and enabled **once**, at the end of this function.
// Each enable re-plans the partition, and a re-plan allocates -- and so touches -- the whole
// of `--buffer-mb`: seven signals one at a time is seven allocations before a single sample
// of the planned rings: seven signals one at a time is seven allocations before a single sample
// is kept, which is the ten seconds of blank window a large buffer used to start with. The
// math channels are not collected here: their enable state is the session's `math` and
// `set_math_channels()` has already restored it, so listing them again could turn on one
+29 -1
View File
@@ -4,6 +4,7 @@
#include "ui_prefs.hpp"
#include "event_store.hpp" // `default_events_dir()`, so the row shows the path the cache uses
#include "status_text.hpp" // `format_bytes()`
#include "ui_app.hpp" // the application's half of the contract, at the bottom of this file
#include <adwaita.h>
@@ -36,6 +37,7 @@ Settings read_rows(const PrefsForm& f) {
s.event_cache_dir = gtk_editable_get_text(GTK_EDITABLE(f.dir_row));
s.event_cache_max = static_cast<int>(adw_spin_row_get_value(f.keep_row));
s.relative_time = adw_switch_row_get_active(f.relative_row) != FALSE;
s.memory_limit_mb = static_cast<int>(adw_spin_row_get_value(f.memory_row));
s.theme = theme_name(adw_combo_row_get_selected(f.theme_row));
// The row shows the *effective* directory, so the default is written out in full rather than
// described. Leaving it at that default is not a choice to pin it: the file keeps "unset", and the
@@ -63,6 +65,7 @@ void fill_rows(PrefsForm& f, const Settings& s) {
(s.event_cache_dir.empty() ? default_events_dir() : s.event_cache_dir).c_str());
adw_spin_row_set_value(f.keep_row, s.event_cache_max < 0 ? 0.0 : s.event_cache_max);
adw_switch_row_set_active(f.relative_row, s.relative_time ? TRUE : FALSE);
adw_spin_row_set_value(f.memory_row, s.memory_limit_mb);
adw_combo_row_set_selected(f.theme_row, s.theme == "light" ? 1 : (s.theme == "dark" ? 2 : 0));
f.filling = false;
}
@@ -141,6 +144,7 @@ void on_closed(AdwDialog*, gpointer user) {
f.dir_row = nullptr;
f.keep_row = nullptr;
f.relative_row = nullptr;
f.memory_row = nullptr;
f.theme_row = nullptr;
f.browse = nullptr;
f.parent = nullptr;
@@ -155,12 +159,14 @@ void resolve_rows(PrefsForm& f) {
f.keep_row = ADW_SPIN_ROW(gtk_builder_get_object(f.builder, "keep_row"));
f.relative_row = ADW_SWITCH_ROW(gtk_builder_get_object(f.builder, "relative_row"));
f.theme_row = ADW_COMBO_ROW(gtk_builder_get_object(f.builder, "theme_row"));
f.memory_row = ADW_SPIN_ROW(gtk_builder_get_object(f.builder, "memory_row"));
if (f.dir_row == nullptr || f.keep_row == nullptr || f.relative_row == nullptr ||
f.theme_row == nullptr) {
f.theme_row == nullptr || f.memory_row == nullptr) {
f.dir_row = nullptr;
f.keep_row = nullptr;
f.relative_row = nullptr;
f.theme_row = nullptr;
f.memory_row = nullptr;
g_clear_object(&f.builder);
}
}
@@ -214,6 +220,7 @@ void prefs_open(PrefsForm& form, GtkWidget* parent, const Settings& current,
g_signal_connect(form.dir_row, "changed", G_CALLBACK(on_entry_changed), &form);
g_signal_connect(form.keep_row, "notify::value", G_CALLBACK(on_row_notify), &form);
g_signal_connect(form.relative_row, "notify::active", G_CALLBACK(on_row_notify), &form);
g_signal_connect(form.memory_row, "notify::value", G_CALLBACK(on_row_notify), &form);
g_signal_connect(form.theme_row, "notify::selected", G_CALLBACK(on_row_notify), &form);
if (GObject* reset = gtk_builder_get_object(form.builder, "reset_row"); reset != nullptr) {
g_signal_connect(reset, "activated", G_CALLBACK(on_reset), &form);
@@ -239,9 +246,29 @@ void prefs_probe_change(PrefsForm& form) {
gtk_editable_set_text(GTK_EDITABLE(form.dir_row), "/tmp/udpscope-prefs-events");
adw_spin_row_set_value(form.keep_row, 7.0);
adw_switch_row_set_active(form.relative_row, TRUE);
adw_spin_row_set_value(form.memory_row, 4096.0);
adw_combo_row_set_selected(form.theme_row, 2);
}
/// A changed memory limit reaches the running model. Only when it differs from the model's own (every
/// preference arrives through the same callback, and a theme change is not a re-plan), and without
/// re-planning while a file or a replayed event is what the buffer holds -- clearing those would
/// throw away what the user opened, so the limit is stored and the next plan honours it.
static void apply_memory_limit(App& app, int mb) {
if (app.model == nullptr || mb <= 0) return;
const std::uint64_t bytes = static_cast<std::uint64_t>(mb) * 1024u * 1024u;
if (bytes == app.model->memory_limit_bytes()) return;
const bool holds_a_view = !app.waveform_name.empty() || app.replaying;
const bool rebuilt = app.model->set_memory_limit_bytes(bytes, !holds_a_view);
std::fprintf(stderr, "[udpscope] memory limit now %s (rebuilt=%d%s)\n",
format_bytes(bytes).c_str(), rebuilt ? 1 : 0,
holds_a_view ? ", applies to the next plan" : "");
std::fflush(stderr);
if (rebuilt) {
set_notice(app, "memory limit " + format_bytes(bytes) + ": buffer re-planned, history cleared", 5);
}
}
/// The application's entry point: **Preferences…** in the overflow menu. Kept here rather than in
/// `ui.cpp` with the rest of the chrome, because it is the application's half of *this* form's
/// contract -- what a preference changing means to a running scope -- and reading it beside the form is
@@ -249,6 +276,7 @@ void prefs_probe_change(PrefsForm& form) {
void open_preferences(App& app) {
prefs_open(app.prefs, app.window, app.settings, [&app](const Settings& s, bool written) {
app.settings = s;
apply_memory_limit(app, s.memory_limit_mb);
// Only a failure is announced. A change arrives once per keystroke in the directory row, and a
// status line that flashed on each one would be the app shouting about its own bookkeeping.
if (!written) set_notice(app, "could not write the preferences file", 4);
+1
View File
@@ -62,6 +62,7 @@ struct PrefsForm {
AdwEntryRow* dir_row = nullptr;
AdwSpinRow* keep_row = nullptr;
AdwSwitchRow* relative_row = nullptr;
AdwSpinRow* memory_row = nullptr;
AdwComboRow* theme_row = nullptr;
/// The folder chooser beside the directory row. A text field alone asks a user to know a path and
/// type it correctly; the chooser is how they find one, and what it returns goes into that row -- so
+5 -4
View File
@@ -1182,7 +1182,7 @@ gboolean math_probe(gpointer user) {
// The one state `math_probe` structurally cannot reach: an edit of a graph whose ring has already
// *wrapped*. Every edit that probe makes follows a structural change, which replans the buffer and
// restarts every ring at absolute 0 -- and at `base == 0` the recompute's two renumbering bugs are
// both identities. Run it with a small `--buffer-mb` and give it time to saturate; `base=0` in its
// both identities. Run it with a small `--buffer-samples` and give it time to saturate; `base=0` in its
// second line means the run was too short or the buffer too large to have read anything.
int g_mathwrap_index = -1;
double g_mathwrap_y_before = 0.0;
@@ -1231,7 +1231,7 @@ gboolean mathwrap_probe_wait(gpointer user) {
g_mathwrap_y_before = a->model->live().channel(idx).y_bounds().max;
}
}
// Polled rather than timed: the wrap is a function of the rate and of `--buffer-mb`, and a fixed
// Polled rather than timed: the wrap is a function of the rate and of `--buffer-samples`, and a fixed
// delay that fell short would print a passing line having tested nothing.
if (base == 0 && ++g_mathwrap_polls < 200) return G_SOURCE_CONTINUE;
std::fprintf(stderr, "[mathwrap-probe] wrapped: base=%llu slots=%llu (want base > 0)\n",
@@ -5384,9 +5384,10 @@ gboolean configs_probe(gpointer user) {
const double noise_back = st.measures.empty() ? -1.0 : st.measures.front().options.noise_pct;
const double hf_back = st.measures.empty() ? -1.0 : st.measures.front().options.hf_fc;
std::fprintf(stderr,
"[configs] round trip read=%d comment='%s' buffer_mb=%.6g relative=%d "
"[configs] round trip read=%d comment='%s' buffer_samples=%llu relative=%d "
"capture=%u plots=%zu signals=%d measures=%zu math=%zu noise=%.6g hf=%.6g\n",
read ? 1 : 0, st.comment.c_str(), st.buffer_mb, st.relative_time ? 1 : 0,
read ? 1 : 0, st.comment.c_str(),
static_cast<unsigned long long>(st.buffer_samples), st.relative_time ? 1 : 0,
st.capture_packets, st.rows.size(), signals, st.measures.size(), st.math.size(),
noise_back, hf_back);
+14 -2
View File
@@ -106,9 +106,21 @@ void update_status_popover(App& app, const udpscope::StatusSnapshot& s) {
std::snprintf(buf, sizeof(buf), "%zu signals · %zu channels", s.signals, s.channels);
gtk_label_set_text(GTK_LABEL(app.popover_label[6]), buf);
std::snprintf(buf, sizeof(buf), "%.1f MB · %.0f s span · time x%llu", s.buffer_mb, s.span_s,
static_cast<unsigned long long>(s.time_stride));
std::snprintf(buf, sizeof(buf), "%s samples/signal · %s · %.0f s span · time x%llu",
udpscope::format_samples(s.buffer_samples).c_str(),
s.buffer_bytes > 0 ? udpscope::format_bytes(s.buffer_bytes).c_str() : "not planned",
s.span_s, static_cast<unsigned long long>(s.time_stride));
gtk_label_set_text(GTK_LABEL(app.popover_label[7]), buf);
// The reason the figure is not the one asked for, where there is one: a smaller ring than the
// source form promised, with the limit that did it, as the label's tooltip.
gtk_widget_set_tooltip_text(
app.popover_label[7],
s.memory_limited
? ("limited to " + udpscope::format_samples(s.buffer_samples) + " samples by the " +
udpscope::format_bytes(s.memory_limit_bytes) + " memory limit (" +
udpscope::format_samples(s.buffer_requested) + " requested)")
.c_str()
: nullptr);
const std::string stored = udpscope::format_rate(s.stored_rate_hz);
const std::string wire = udpscope::format_rate(s.declared_rate_hz);