Budget the sample buffer in samples per signal, under a hard memory limit

--buffer-samples (k/M/G) replaces --buffer-mb: the request is rounded down to a
power of two for every signal and the shared time ring. A separate memory limit
(--memory-limit-mb, a Preferences row, default 8 GiB) halves the plan until the
real ring bytes fit and says so on stderr and in the status line. The per-source
key buffer_mb becomes buffer_samples; an old buffer_mb is ignored.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-11-03 02:47:04 +01:00
1 parent 616cde5b13
commit 034c82f7d7
20 files changed
+516 -134

No files matched your search

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