diff --git a/src/config.cpp b/src/config.cpp index 73ab661..eba38d1 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -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(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(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"); diff --git a/src/config.hpp b/src/config.hpp index 7144a0b..58a5f42 100644 --- a/src/config.hpp +++ b/src/config.hpp @@ -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). diff --git a/src/layout_store.cpp b/src/layout_store.cpp index 502f9aa..c6987f9 100644 --- a/src/layout_store.cpp +++ b/src/layout_store.cpp @@ -328,7 +328,7 @@ std::vector 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 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) { diff --git a/src/layout_store.hpp b/src/layout_store.hpp index 38264d7..fff8a93 100644 --- a/src/layout_store.hpp +++ b/src/layout_store.hpp @@ -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; }; diff --git a/src/main.cpp b/src/main.cpp index c6be5c6..7f16a52 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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(prefs.memory_limit_mb); } catch (const std::exception& e) { std::fprintf(stderr, "udpscope: %s\n", e.what()); return 2; diff --git a/src/model.cpp b/src/model.cpp index 765459f..7535a9d 100644 --- a/src/model.cpp +++ b/src/model.cpp @@ -3,6 +3,8 @@ #include "model.hpp" +#include "status_text.hpp" // `format_samples()` / `format_bytes()`, for the one line a clipped plan prints + #include #include #include @@ -40,12 +42,24 @@ constexpr std::uint64_t kMathCostSample = 1024; } // namespace -Partition plan_partition(const std::vector& 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(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& 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& channel_dt, double buffer_by p.abs_time = with_abs_time; - const u64 budget_bytes = static_cast(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(sizeof(f64)) * (with_abs_time ? 2.0 : 1.0); // the time slice(s) - for (u64 s : p.stride) weight += static_cast(sizeof(f32)) / static_cast(s); - - const u64 slots = static_cast(static_cast(budget_bytes) / weight); - u64 time_capacity = pow2_floor(std::max(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& 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 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 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 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 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 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 " diff --git a/src/model.hpp b/src/model.hpp index 88e4def..23daf4c 100644 --- a/src/model.hpp +++ b/src/model.hpp @@ -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(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& 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& 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>`, 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(mb) * 1024u * 1024u; + return static_cast(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 memory_limit_bytes_{0}; std::vector signals_; std::vector channels_; // --- math channels -------------------------------------------------------- diff --git a/src/settings.cpp b/src/settings.cpp index 53fd6a8..c9bbdf8 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -3,6 +3,7 @@ #include "settings.hpp" +#include #include #include #include @@ -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(std::min(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 diff --git a/src/settings.hpp b/src/settings.hpp index 2dbd672..a302949 100644 --- a/src/settings.hpp +++ b/src/settings.hpp @@ -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"; diff --git a/src/source_dialog.cpp b/src/source_dialog.cpp index 70a1c71..84753dc 100644 --- a/src/source_dialog.cpp +++ b/src/source_dialog.cpp @@ -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 #include +#include #include #include #include @@ -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& 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(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(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::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(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(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(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(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(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(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 diff --git a/src/status_text.cpp b/src/status_text.cpp index b78c911..74efad5 100644 --- a/src/status_text.cpp +++ b/src/status_text.cpp @@ -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(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(n)); + } + return buf; +} + +std::string format_bytes(std::uint64_t bytes) { + char buf[32]; + const double d = static_cast(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(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(s.packets), static_cast(s.frames), static_cast(s.gaps), s.signals, s.channels, - s.plot1_traces, s.plot1_samples, s.buffer_mb, s.span_s, - static_cast(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(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. diff --git a/src/status_text.hpp b/src/status_text.hpp index 48c2378..6d38077 100644 --- a/src/status_text.hpp +++ b/src/status_text.hpp @@ -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); diff --git a/src/ui.cpp b/src/ui.cpp index d8d8555..71808c9 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -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(model.live().values(i).produced()), static_cast(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(model.config().buffer_samples), + static_cast(part.time_capacity), + static_cast(part.bytes()), + static_cast(model.memory_limit_bytes()), + part.memory_limited ? 1 : 0); + { + const std::vector 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(open.time_capacity), + pow2(open.time_capacity) ? 1 : 0, open.memory_limited ? 1 : 0, + static_cast(clip.time_capacity), + static_cast(clip.bytes()), clip.bytes() <= kGiB ? 1 : 0, + pow2(clip.time_capacity) ? 1 : 0, clip.memory_limited ? 1 : 0, + static_cast(clip_rel.time_capacity), + static_cast(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. diff --git a/src/ui/preferences.ui b/src/ui/preferences.ui index 3c84219..837b77a 100644 --- a/src/ui/preferences.ui +++ b/src/ui/preferences.ui @@ -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 @@ + + + Sample buffer + + + _Memory limit (MB) + True + 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. + + + 16 + 1048576 + 256 + 1024 + 8192 + + + + + + diff --git a/src/ui/source-form.ui b/src/ui/source-form.ui index b0f2797..1e44a44 100644 --- a/src/ui/source-form.ui +++ b/src/ui/source-form.ui @@ -85,15 +85,17 @@ - _Buffer MB + _Samples per signal (millions) True - sample buffer per source, applied when this source is opened + samples kept for every signal, rounded down to a power of two; applied when this source is opened + 3 - 1 - 65536 + 0.001 + 4096 1 - 64 + 10 + 10 diff --git a/src/ui_layout.cpp b/src/ui_layout.cpp index e3c66d6..122082f 100644 --- a/src/ui_layout.cpp +++ b/src/ui_layout.cpp @@ -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(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(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 diff --git a/src/ui_prefs.cpp b/src/ui_prefs.cpp index 8a87fa9..5a7852e 100644 --- a/src/ui_prefs.cpp +++ b/src/ui_prefs.cpp @@ -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 @@ -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(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(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(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); diff --git a/src/ui_prefs.hpp b/src/ui_prefs.hpp index 69be0ae..927366a 100644 --- a/src/ui_prefs.hpp +++ b/src/ui_prefs.hpp @@ -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 diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index 228ffdd..22defae 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -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(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); diff --git a/src/ui_status.cpp b/src/ui_status.cpp index 5dfbef5..038f959 100644 --- a/src/ui_status.cpp +++ b/src/ui_status.cpp @@ -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(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(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);