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
+92
-41
@@ -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 "
|
||||
|
||||
Reference in new issue
Block a user