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:
1 parent
616cde5b13
commit
034c82f7d7
20 files changed
+516
-134
No files matched your search
+67
-15
@@ -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 --------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user