fix: store the stream clock as f64, so a stamp's precision does not decay with uptime
The time ring was f32 like the values, holding each stamp relative to an epoch fixed at the first sample ever seen -- so the number stored grew for as long as the app ran while the interval it had to resolve stayed one sample period. At 1 MS/s the f32 ULP reaches the sample period after about sixteen seconds, so two to four consecutive samples rounded onto one stamp after half a minute and eight after a minute. On screen that is a plateau the LOD envelope draws as a zero-width segment -- a vertical spike where the waveform should be -- and in an export it was three distinct diffs at 2, 2.5 and 3 ULP instead of one clock. A value's precision is a property of the signal; a timestamp's is a property of how long the scope has been running. Only the clock widens: the values are the memory budget. That makes the two slices different sizes per slot, so plan_partition() weighs its growth loop in bytes rather than in slots (and bytes_for() is what bounds the doubling); live_window_locked() loses the f32 round-trip of its bounds, which existed only to keep a sample sitting exactly on one; and EventSnapshot::stamps is f64 because a pause view is not rebased and so carries full stream time. Verified against the fast mock: np.diff over two million exported rows now holds exactly one step (1e-06, 1999999 times), no duplicate or backwards stamps, and the 200 MB ring still spans 8.39 s. Every documented probe assertion unchanged. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
eb4372242c
commit
ec93a9831d
10 files changed
+159
-81
No files matched your search
@@ -553,6 +553,11 @@ Two lines of the self-check output are assertions in disguise:
|
||||
README) and a per-sample step derived from the client's `lost` counter (now the
|
||||
stream's measured arrival cadence). A `rows=` count far below the span times the rate
|
||||
is the other signature, that of a table cut from the LOD pyramid.
|
||||
**`set(np.diff(t))` must hold exactly one value**, and this is the check that needs a long
|
||||
run at a high rate: a *plateau* of equal stamps, or a diff taking two or three values a few
|
||||
hundred nanoseconds apart, is the time ring losing precision with uptime (see the `f64`
|
||||
time-ring invariant below — the figure to compute is the ULP of `t[0]`, since the defect
|
||||
grows with how long the app has run, not with the export's own length).
|
||||
- `badge frames: lit=L dim=D idle=I` — the activity badge's phase per frame. Over a run
|
||||
that acquired anything **both `lit` and `dim` must be non-zero**: that is what "it
|
||||
blinks" means, and a badge stuck lit is indistinguishable from a working one in a
|
||||
@@ -898,6 +903,29 @@ far the largest file.
|
||||
draw a bucket's extremes. It needs no index rebasing, unlike the ring: the snapshot's
|
||||
pyramid is built from sample 0 and nothing is ever dropped from it, so `window_base()` is
|
||||
0 and a window index *is* the absolute one.
|
||||
- **The time ring is `f64` and the value rings are `f32`, and the asymmetry is the point.**
|
||||
A value's precision is a property of the signal (24 bits is plenty for a sample); a
|
||||
timestamp's is a property of *how long the scope has been running*, because the stamps are
|
||||
stored relative to an epoch fixed at the first sample ever seen and so grow without bound
|
||||
while the interval they must resolve stays one sample period. `f32`'s ULP is 1.9 µs at 16 s
|
||||
of uptime, 3.8 µs at 32 s, 7.6 µs at 64 s and 61 µs over a 600 s window — so at 1 MS/s two
|
||||
to four consecutive samples shared a stamp after half a minute, eight after a minute, and
|
||||
the LOD envelope over such a plateau drew a zero-width segment, i.e. a vertical spike where
|
||||
the waveform should be. That is "zoom in to ~10 µs and three to five points share a
|
||||
timestamp, and min/max produces artefacts". **Do not widen the values to match**: they are
|
||||
the memory budget, and nothing about them is magnitude-bound this way. Three consequences:
|
||||
`plan_partition()` can no longer count slots in its growth loop (a time slot and a value
|
||||
sample are different sizes, so it weighs the budget in **bytes** — `sizeof(f64)` for the
|
||||
clock plus `sizeof(f32) / stride[i]` per channel — and `bytes_for()` is also what bounds
|
||||
the doubling against overflow); `live_window_locked()` compares its bounds in the ring's own
|
||||
type with **no rounding step**, the old `f32` round-trip having existed only so that a
|
||||
sample sitting exactly on a bound was kept whatever the stream's absolute time was; and
|
||||
`EventSnapshot::stamps` is `f64` because a **pause** view is deliberately *not* rebased and
|
||||
therefore carries full stream time (an event's own stamps are edge-relative and small — the
|
||||
symmetry is not the reason). `RingBuffer<T>` stores slots as `std::atomic<T>` behind a
|
||||
`static_assert(is_always_lock_free)`, which `double` satisfies on every supported target.
|
||||
The guard is the **export**: `np.diff(t)` over a multi-million-row `.npy` must hold exactly
|
||||
one distinct step. Three steps at 2, 2.5 and 3 ULP of the stamps' magnitude is this defect.
|
||||
- **A dropped time slot is clamped, never answered with a sentinel.** `acquire()` pins
|
||||
the published window to the value ring's base and both rings keep sliding afterwards —
|
||||
their capacities are equal, so there is no slack — which means the oldest few samples
|
||||
|
||||
@@ -343,7 +343,9 @@ clock is resampled too (`Partition::time_stride`, an averaging decimation — se
|
||||
"A window longer than the buffer is resampled"). Because the split is by
|
||||
*time span* rather than by bytes, every channel and the time base retain the same
|
||||
history -- which is what makes one shared time slice meaningful. With no
|
||||
decimation the two rules coincide and every slice is the same size.
|
||||
decimation the two rules coincide and every slice holds the same number of samples
|
||||
(not the same number of bytes: the shared clock is `f64` and the values are `f32`, so
|
||||
the growth loop weighs the budget in bytes -- see "The time ring is `f64`" below).
|
||||
|
||||
The ring is always filling, whatever the trigger is doing. The pre-trigger history
|
||||
has to already be there when an edge arrives; while acquisition followed the
|
||||
@@ -456,11 +458,12 @@ the post window had been waiting for. That cost one sample of twenty thousand on
|
||||
|
||||
The axis is still pinned to what the snapshot actually captured, read off its own
|
||||
stamps rather than off the request: the two can differ by a sample at each end, and
|
||||
pinning the request would claim coverage the data does not have. The window's bounds
|
||||
are rounded to the time ring's own `f32` before being compared against it, so a sample
|
||||
sitting exactly on a bound is kept whatever the stream's absolute time happens to be —
|
||||
in f64 that decision fell to how the stamp rounded, which cost a sample at 10 kS/s and
|
||||
a third of the capture at 1 S/s.
|
||||
pinning the request would claim coverage the data does not have. The cut's bounds are
|
||||
compared in the ring's own type, which is now the `f64` they arrive in — see "The time
|
||||
ring is `f64`" below. While the ring was `f32` the bounds had to be *rounded to it*
|
||||
before comparing, or a sample sitting exactly on a bound fell to how its stamp
|
||||
rounded, which cost a sample at 10 kS/s and a third of the capture at 1 S/s. That
|
||||
rule is gone with its cause.
|
||||
|
||||
**Where the capture is drawn was a separate bug from what it contained**, and it
|
||||
outlived the fixes above. The plotting library's `gather()` emits x *relative to*
|
||||
@@ -791,6 +794,40 @@ signature. The same torn read still exists for a small `i > 0` — the oldest fr
|
||||
renderer's increasing-suffix filter exists to absorb, so it is left alone rather than fixed
|
||||
speculatively.
|
||||
|
||||
**The time ring is `f64`; the value rings stay `f32`.** It was `f32` like everything else,
|
||||
with the stamps stored relative to an epoch fixed at the first sample ever seen — so the
|
||||
number being stored grew for as long as the app ran while the interval it had to resolve
|
||||
stayed one sample period. `f32` carries 24 bits of mantissa, which is a *relative*
|
||||
precision, so the ULP at 16 s of uptime is 1.9 µs, at 32 s 3.8 µs, at 64 s 7.6 µs and over
|
||||
a 600 s window 61 µs. At 1 MS/s the sample period is 1 µs, so after half a minute two to
|
||||
four consecutive samples rounded onto the same stamp, after a minute eight, and so on
|
||||
without bound. That is the "zoom in to about 10 µs and there are three to five points with
|
||||
the same timestamp" report, and the export showed it unambiguously: `np.diff(t)` over two
|
||||
million rows held **three** distinct steps, 0.953 µs, 1.192 µs and 1.431 µs — exactly
|
||||
2, 2.5 and 3 ULP of the magnitude the stamps had reached, a stair instead of a clock. The
|
||||
min/max artefacts are the same cause read through the LOD pyramid: a bucket of samples
|
||||
sharing one x is a zero-width segment, so an envelope drew as a vertical spike at a
|
||||
plateau's edge rather than as the waveform.
|
||||
|
||||
Nothing was wrong with the arithmetic, so no amount of care at a call site could have
|
||||
fixed it; the type was. **A value's precision is a property of the signal and a
|
||||
timestamp's is a property of how long the scope has been running** — 24 bits is plenty for
|
||||
a sample and runs out on a clock. Widening only the clock keeps the cost where it belongs:
|
||||
the rings are the memory budget, and the values are the overwhelming majority of them. The
|
||||
one consequence worth knowing is that `plan_partition()`'s growth loop can no longer count
|
||||
slots, because a time slot and a value sample are now different sizes — it weighs the
|
||||
budget in **bytes** (`sizeof(f64)` for the shared clock plus `sizeof(f32) / stride[i]` per
|
||||
channel) and the power-of-two rounding absorbs the rest: a three-channel stream at
|
||||
`--buffer-mb 200` retains the same 8.39 s span it did before. `EventSnapshot::stamps` is
|
||||
`f64` for the same reason rather than for symmetry — an *event's* stamps are rebased onto
|
||||
its edge and so are small, but a *pause* view is deliberately not rebased and carries full
|
||||
stream time, which quantised at exactly the rate the live ring did.
|
||||
|
||||
`RingBuffer<T>` stores its slots as `std::atomic<T>` behind a
|
||||
`static_assert(is_always_lock_free)`, so this was a question worth asking rather than
|
||||
assuming: `std::atomic<double>` is lock-free on every target this app builds for, and the
|
||||
assertion is a compile-time guard rather than a silent fallback.
|
||||
|
||||
**A held event carries its own LOD pyramid**, and the lack of one is what "the plot gets
|
||||
laggy with large data" was. The renderer picks a level per trace per frame with
|
||||
`MinMaxPyramid::level_for()`, which descends to level 0 — *raw samples* — on a pyramid
|
||||
|
||||
+1
-1
@@ -518,7 +518,7 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
||||
time_acc_ += rel;
|
||||
++time_acc_n_;
|
||||
if (tick % g == g - 1) {
|
||||
store.time().push(static_cast<f32>(time_acc_ / static_cast<double>(time_acc_n_)));
|
||||
store.time().push(time_acc_ / static_cast<double>(time_acc_n_));
|
||||
time_acc_ = 0.0;
|
||||
time_acc_n_ = 0;
|
||||
}
|
||||
|
||||
+8
-8
@@ -24,7 +24,7 @@ const plot::data::MinMaxPyramid& null_pyramid() {
|
||||
// --- ChannelBuffer ----------------------------------------------------------
|
||||
|
||||
ChannelBuffer::ChannelBuffer(std::shared_ptr<const plot::data::RingBuffer<f32>> values,
|
||||
std::shared_ptr<const plot::data::RingBuffer<f32>> times, u64 stride,
|
||||
std::shared_ptr<const plot::data::RingBuffer<f64>> times, u64 stride,
|
||||
f64 epoch, f64 nominal_dt)
|
||||
: values_(std::move(values)), times_(std::move(times)), stride_(stride < 1 ? 1 : stride),
|
||||
epoch_(epoch), dt_(nominal_dt) {}
|
||||
@@ -64,11 +64,11 @@ void ChannelBuffer::acquire() noexcept {
|
||||
|
||||
void ChannelBuffer::invalidate() noexcept {
|
||||
published_ = 0;
|
||||
window_x0_ = 0.0f;
|
||||
window_x0_ = 0.0;
|
||||
pyramid_.clear();
|
||||
}
|
||||
|
||||
f32 ChannelBuffer::time_of(u64 absolute_index) const noexcept {
|
||||
f64 ChannelBuffer::time_of(u64 absolute_index) const noexcept {
|
||||
// Channel sample `j` is produced on the tick that also pushes time slot
|
||||
// `j*stride + stride - 1` (see `Acquisition::on_data`): the producer stamps the
|
||||
// time ring every `time_stride` ticks and the channel every `stride` of those,
|
||||
@@ -102,10 +102,10 @@ f32 ChannelBuffer::time_of(u64 absolute_index) const noexcept {
|
||||
// whole trace's geometry collapsing.
|
||||
const u64 base = times_->base();
|
||||
const u64 produced = times_->produced();
|
||||
if (produced == 0) return 0.0f;
|
||||
if (produced == 0) return 0.0;
|
||||
u64 slot = index < base ? base : index;
|
||||
if (slot >= produced) slot = produced - 1;
|
||||
f32 out = 0.0f;
|
||||
f64 out = 0.0;
|
||||
times_->copy_from(slot, 1, &out);
|
||||
return out;
|
||||
}
|
||||
@@ -121,7 +121,7 @@ f64 ChannelBuffer::dx() const {
|
||||
|
||||
f64 ChannelBuffer::x0() const {
|
||||
if (pyramid_.size() == 0) return epoch_;
|
||||
return epoch_ + f64(window_x0_);
|
||||
return epoch_ + window_x0_;
|
||||
}
|
||||
|
||||
f64 ChannelBuffer::x_at(u64 i) const {
|
||||
@@ -131,7 +131,7 @@ f64 ChannelBuffer::x_at(u64 i) const {
|
||||
// ring-span too new made it refuse the whole visible range -- which left the renderer
|
||||
// gathering the entire ring at a coarse level and drawing it off the axis.
|
||||
if (i == 0) return x0();
|
||||
return epoch_ + f64(time_of(absolute(i)));
|
||||
return epoch_ + time_of(absolute(i));
|
||||
}
|
||||
|
||||
bool ChannelBuffer::neighbours(f64 x, u64& i0, u64& i1) const {
|
||||
@@ -171,7 +171,7 @@ u64 ChannelBuffer::snap_index(f64 x) const {
|
||||
plot::Range ChannelBuffer::x_bounds() const {
|
||||
const u64 n = pyramid_.size();
|
||||
if (n == 0) return plot::Range{epoch_, epoch_};
|
||||
return plot::Range{x0(), epoch_ + f64(time_of(absolute(n - 1)))};
|
||||
return plot::Range{x0(), epoch_ + time_of(absolute(n - 1))};
|
||||
}
|
||||
|
||||
plot::Range ChannelBuffer::y_bounds() const {
|
||||
|
||||
+6
-4
@@ -34,8 +34,10 @@ using plot::u64;
|
||||
/// undecimated case and the common one.
|
||||
class ChannelBuffer final : public plot::data::Series {
|
||||
public:
|
||||
/// `values` is f32 and `times` is f64: see `BufferStore::time()` for why the clock
|
||||
/// alone needs the wider type.
|
||||
ChannelBuffer(std::shared_ptr<const plot::data::RingBuffer<f32>> values,
|
||||
std::shared_ptr<const plot::data::RingBuffer<f32>> times, u64 stride, f64 epoch,
|
||||
std::shared_ptr<const plot::data::RingBuffer<f64>> times, u64 stride, f64 epoch,
|
||||
f64 nominal_dt);
|
||||
|
||||
ChannelBuffer(const ChannelBuffer&) = delete;
|
||||
@@ -94,10 +96,10 @@ class ChannelBuffer final : public plot::data::Series {
|
||||
/// Absolute (channel) index of retained-window index `i`.
|
||||
u64 absolute(u64 i) const noexcept { return pyramid_.window_base() + i; }
|
||||
/// Timestamp of channel sample `absolute_index`, in seconds since `epoch_`.
|
||||
f32 time_of(u64 absolute_index) const noexcept;
|
||||
f64 time_of(u64 absolute_index) const noexcept;
|
||||
|
||||
std::shared_ptr<const plot::data::RingBuffer<f32>> values_;
|
||||
std::shared_ptr<const plot::data::RingBuffer<f32>> times_;
|
||||
std::shared_ptr<const plot::data::RingBuffer<f64>> times_;
|
||||
u64 stride_ = 1;
|
||||
f64 epoch_ = 0.0;
|
||||
f64 dt_ = 1.0;
|
||||
@@ -114,7 +116,7 @@ class ChannelBuffer final : public plot::data::Series {
|
||||
/// the "live plot flickers as if there were no data" defect. The cached value is also the
|
||||
/// *right* answer rather than merely a stable one: it is the stamp sample `window_base`
|
||||
/// actually carries, where the clamp answered with whatever slot happened to be oldest now.
|
||||
f32 window_x0_ = 0.0f;
|
||||
f64 window_x0_ = 0.0;
|
||||
plot::data::MinMaxPyramid pyramid_;
|
||||
};
|
||||
|
||||
|
||||
@@ -26,7 +26,13 @@ using plot::u64;
|
||||
/// live ring buffers switched by a pending flag, plus a display-epoch shift)
|
||||
/// could not stop.
|
||||
struct EventSnapshot {
|
||||
std::vector<f32> stamps; ///< edge-relative seconds, monotonic
|
||||
/// Seconds, monotonic -- edge-relative for a trigger event, the stream's own for a
|
||||
/// pause view. **f64, like the live ring it is cut from** (see `BufferStore::time()`):
|
||||
/// an event's stamps are small enough that f32 would nearly do, but a *pause* is
|
||||
/// deliberately not rebased, so its stamps carry the full stream time and quantise at
|
||||
/// the same 3.8 us per 32 s of uptime the live ring did. Keeping the two the same type
|
||||
/// also makes the cut in `publish_event()`/`publish_pause_view()` a straight copy.
|
||||
std::vector<f64> stamps;
|
||||
std::vector<std::vector<f32>> values; ///< one array per channel, same length
|
||||
double pre_s = 0.0; ///< seconds of pre-trigger data kept
|
||||
double post_s = 0.0; ///< seconds of post-trigger data kept
|
||||
|
||||
+6
-6
@@ -109,9 +109,9 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
||||
if (snap != nullptr) {
|
||||
// The snapshot is immutable and already index-aligned, so the visible window is two
|
||||
// binary searches over its stamps and the same index range for every channel.
|
||||
const std::vector<f32>& st = snap->stamps;
|
||||
const auto lo = std::lower_bound(st.begin(), st.end(), static_cast<f32>(view.min));
|
||||
const auto hi = std::upper_bound(st.begin(), st.end(), static_cast<f32>(view.max));
|
||||
const std::vector<f64>& st = snap->stamps;
|
||||
const auto lo = std::lower_bound(st.begin(), st.end(), view.min);
|
||||
const auto hi = std::upper_bound(st.begin(), st.end(), view.max);
|
||||
if (hi <= lo) return table;
|
||||
const std::size_t first = static_cast<std::size_t>(lo - st.begin());
|
||||
const std::size_t n = static_cast<std::size_t>(hi - lo);
|
||||
@@ -122,7 +122,7 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
||||
table.time.resize(n);
|
||||
table.stream_time.resize(n);
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
table.time[i] = static_cast<f64>(st[first + i]);
|
||||
table.time[i] = st[first + i];
|
||||
table.stream_time[i] = edge + table.time[i];
|
||||
}
|
||||
for (const int c : chans) {
|
||||
@@ -151,12 +151,12 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
||||
if (n == 0) return table;
|
||||
|
||||
BufferStore& store = model.live();
|
||||
std::vector<f32> stamps(static_cast<std::size_t>(n));
|
||||
std::vector<f64> stamps(static_cast<std::size_t>(n));
|
||||
store.time().copy_from(first, n, stamps.data());
|
||||
table.time.resize(static_cast<std::size_t>(n));
|
||||
table.stream_time.resize(static_cast<std::size_t>(n));
|
||||
for (std::size_t i = 0; i < stamps.size(); ++i) {
|
||||
table.stream_time[i] = static_cast<f64>(stamps[i]);
|
||||
table.stream_time[i] = stamps[i];
|
||||
table.time[i] = epoch + table.stream_time[i];
|
||||
}
|
||||
for (const int c : chans) {
|
||||
|
||||
+27
-29
@@ -42,12 +42,6 @@ constexpr std::uint64_t kMathCostSample = 1024;
|
||||
|
||||
} // namespace
|
||||
|
||||
u64 Partition::total_samples() const noexcept {
|
||||
u64 total = time_capacity;
|
||||
for (u64 c : value_capacity) total += c;
|
||||
return total;
|
||||
}
|
||||
|
||||
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes,
|
||||
double target_span_s) {
|
||||
Partition p;
|
||||
@@ -76,11 +70,14 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
|
||||
for (std::size_t i = 0; i < n; ++i) p.stride[i] = 1;
|
||||
|
||||
const u64 budget_bytes = static_cast<u64>(std::max(1.0, buffer_bytes));
|
||||
const u64 budget = budget_bytes / sizeof(f32);
|
||||
double weight = 1.0; // the time slice itself
|
||||
for (u64 s : p.stride) weight += 1.0 / static_cast<double>(s);
|
||||
// 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)); // the time slice itself
|
||||
for (u64 s : p.stride) weight += static_cast<double>(sizeof(f32)) / static_cast<double>(s);
|
||||
|
||||
const u64 slots = budget / static_cast<u64>(std::llround(weight));
|
||||
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");
|
||||
@@ -102,11 +99,13 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
|
||||
return std::max<u64>(pow2_floor(ticks / stride), 1);
|
||||
};
|
||||
const auto bytes_for = [&](u64 ticks) {
|
||||
u64 total = ticks;
|
||||
for (u64 s : p.stride) total += capacity_for(ticks, s);
|
||||
return total * sizeof(f32);
|
||||
u64 total = ticks * sizeof(f64);
|
||||
for (u64 s : p.stride) total += capacity_for(ticks, s) * sizeof(f32);
|
||||
return total;
|
||||
};
|
||||
while (time_capacity * 2 <= budget && bytes_for(time_capacity * 2) <= budget_bytes) {
|
||||
// `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;
|
||||
}
|
||||
p.time_capacity = time_capacity;
|
||||
@@ -138,7 +137,7 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
|
||||
|
||||
BufferStore::BufferStore(const Partition& part, f64 span_s)
|
||||
: part_(part),
|
||||
time_(std::make_shared<plot::data::RingBuffer<f32>>(part.time_capacity)) {
|
||||
time_(std::make_shared<plot::data::RingBuffer<f64>>(part.time_capacity)) {
|
||||
(void)span_s;
|
||||
const std::size_t n = part.value_capacity.size();
|
||||
values_.reserve(n);
|
||||
@@ -452,7 +451,7 @@ BufferStore& ScopeModel::live() { return *store_; }
|
||||
|
||||
void ScopeModel::live_window_locked(double lo_s, double hi_s, u64& first, u64& last) {
|
||||
BufferStore& src = live();
|
||||
plot::data::RingBuffer<f32>& tsrc = src.time();
|
||||
plot::data::RingBuffer<f64>& tsrc = src.time();
|
||||
|
||||
// The slice is one absolute index range, found in the time ring and then shared by
|
||||
// every value ring. Sharing it is what makes a cut's stamps and each of its channels
|
||||
@@ -460,16 +459,15 @@ void ScopeModel::live_window_locked(double lo_s, double hi_s, u64& first, u64& l
|
||||
// truncations that could disagree.
|
||||
const u64 tbase = tsrc.produced() - tsrc.size();
|
||||
const u64 tend = tsrc.produced();
|
||||
// The bounds are rounded to the ring's own `f32` before they are compared against
|
||||
// it. The window arrives in f64 and the stamps are stored as f32, so an f64
|
||||
// comparison decides a sample that sits exactly on a bound by which way its stamp
|
||||
// happened to round -- the same nominal window then keeps or drops its first sample
|
||||
// depending on how large the stream time is (`f32(38.001) < 38.001` but
|
||||
// `f32(18.001) > 18.001`). One sample is invisible at 10 kS/s and a third of the
|
||||
// capture at 1 S/s. Comparing in the stored type makes a bound that lands on a
|
||||
// sample include it, always.
|
||||
const f32 t_lo = static_cast<f32>(lo_s);
|
||||
const f32 t_hi = static_cast<f32>(hi_s);
|
||||
// The bounds are compared in the ring's own type, which is the `f64` they arrive in,
|
||||
// so a bound that lands exactly on a sample includes it. This used to need rounding
|
||||
// *down* to the stored f32 first: an f64 comparison against an f32 stamp decided such
|
||||
// a sample by which way its stamp happened to round, and the same nominal window kept
|
||||
// or dropped its first sample depending on the magnitude of the stream time
|
||||
// (`f32(38.001) < 38.001` but `f32(18.001) > 18.001`). The ring is f64 now and the
|
||||
// question does not arise.
|
||||
const f64 t_lo = lo_s;
|
||||
const f64 t_hi = hi_s;
|
||||
|
||||
// Stream time is non-decreasing, so the bounds are a binary search. Searching the
|
||||
// stamps is the whole point: the window is in seconds and this ring is the only
|
||||
@@ -496,7 +494,7 @@ void ScopeModel::live_window_locked(double lo_s, double hi_s, u64& first, u64& l
|
||||
last = a;
|
||||
|
||||
for (std::size_t c = 0; c < channels_.size(); ++c) {
|
||||
plot::data::RingBuffer<f32>& from = src.values(c);
|
||||
plot::data::RingBuffer<f32>& from = src.values(c); // values stay f32
|
||||
first = std::max(first, from.produced() - from.size());
|
||||
last = std::min(last, from.produced());
|
||||
}
|
||||
@@ -554,7 +552,7 @@ void ScopeModel::publish_pause_view(double lo_s, double hi_s) {
|
||||
void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
|
||||
auto snap = std::make_shared<EventSnapshot>();
|
||||
BufferStore& src = live();
|
||||
plot::data::RingBuffer<f32>& tsrc = src.time();
|
||||
plot::data::RingBuffer<f64>& tsrc = src.time();
|
||||
|
||||
u64 first = 0, last = 0;
|
||||
live_window_locked(edge - std::max(0.0, pre_s), edge + std::max(0.0, post_s), first, last);
|
||||
@@ -566,7 +564,7 @@ void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
|
||||
// Edge-relative from here on: the frozen event reads [-pre, +post] with the
|
||||
// edge at exactly 0, with no axis-level shift any other code path could
|
||||
// disagree with.
|
||||
for (auto& t : snap->stamps) t -= static_cast<f32>(edge);
|
||||
for (auto& t : snap->stamps) t -= edge;
|
||||
}
|
||||
|
||||
snap->values.resize(channels_.size());
|
||||
|
||||
+12
-7
@@ -93,8 +93,6 @@ struct Partition {
|
||||
double base_dt = 1.0; ///< seconds per base tick
|
||||
double span_s = 0.0; ///< stream seconds one buffer covers
|
||||
|
||||
u64 total_samples() const noexcept;
|
||||
u64 total_bytes() const noexcept { return total_samples() * sizeof(f32); }
|
||||
/// 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.
|
||||
@@ -125,8 +123,15 @@ class BufferStore {
|
||||
/// axis read seconds from the trigger event.
|
||||
void set_epoch(f64 epoch) noexcept;
|
||||
|
||||
plot::data::RingBuffer<f32>& time() noexcept { return *time_; }
|
||||
const plot::data::RingBuffer<f32>& time() const noexcept { return *time_; }
|
||||
/// The shared clock, in **f64**. The values are f32 and the clock is not: a stamp is
|
||||
/// `stream_time - epoch` and the epoch is fixed at the first sample ever seen, so the
|
||||
/// magnitude grows without bound over a run's life while the interval that has to be
|
||||
/// resolved stays the sample period. f32 has 24 bits of mantissa, so its ULP at 32 s is
|
||||
/// 3.8 us -- four consecutive samples of a 1 MS/s stream then share one stamp, and at
|
||||
/// 600 s uptime 61 of them do. A value's precision is a property of the signal and 24
|
||||
/// bits is plenty; a *timestamp's* is a property of how long the scope has been running.
|
||||
plot::data::RingBuffer<f64>& time() noexcept { return *time_; }
|
||||
const plot::data::RingBuffer<f64>& time() const noexcept { return *time_; }
|
||||
plot::data::RingBuffer<f32>& values(std::size_t channel) { return *values_[channel]; }
|
||||
ChannelBuffer& channel(std::size_t channel) { return *channels_[channel]; }
|
||||
/// Shared handle to a channel view: the caller keeps the storage alive even if
|
||||
@@ -141,7 +146,7 @@ class BufferStore {
|
||||
|
||||
private:
|
||||
Partition part_;
|
||||
std::shared_ptr<plot::data::RingBuffer<f32>> time_;
|
||||
std::shared_ptr<plot::data::RingBuffer<f64>> time_;
|
||||
std::vector<std::shared_ptr<plot::data::RingBuffer<f32>>> values_;
|
||||
std::vector<std::shared_ptr<ChannelBuffer>> channels_;
|
||||
};
|
||||
@@ -278,8 +283,8 @@ class ScopeModel {
|
||||
/// The live buffer's absolute index range covering `[lo_s, hi_s]` in stream seconds,
|
||||
/// clamped to what the time ring and *every* value ring still hold. One range for the
|
||||
/// time ring and all the channels, which is what keeps a cut index-aligned by
|
||||
/// construction; the bounds are compared in the ring's own `f32`, so a bound that
|
||||
/// lands on a sample includes it whatever the magnitude of the stream time.
|
||||
/// construction; the bounds are compared in the ring's own type, which is now the
|
||||
/// `f64` they arrive in, so a bound that lands on a sample includes it exactly.
|
||||
///
|
||||
/// Caller holds `mutex()` (the acquisition thread already does when it latches).
|
||||
void live_window_locked(double lo_s, double hi_s, u64& first, u64& last);
|
||||
|
||||
+21
-19
@@ -2030,18 +2030,18 @@ bool visible_samples(App& app, std::size_t channel, std::vector<double>& time,
|
||||
|
||||
if (std::shared_ptr<const EventSnapshot> snap = app.model->display_snapshot()) {
|
||||
if (snap == nullptr || channel >= snap->values.size()) return false;
|
||||
const std::vector<f32>& st = snap->stamps;
|
||||
const std::vector<f64>& st = snap->stamps;
|
||||
const std::vector<f32>& vals = snap->values[channel];
|
||||
if (vals.size() != st.size()) return false;
|
||||
const auto lo = std::lower_bound(st.begin(), st.end(), static_cast<f32>(view.min));
|
||||
const auto hi = std::upper_bound(st.begin(), st.end(), static_cast<f32>(view.max));
|
||||
const auto lo = std::lower_bound(st.begin(), st.end(), view.min);
|
||||
const auto hi = std::upper_bound(st.begin(), st.end(), view.max);
|
||||
const std::size_t count = static_cast<std::size_t>(hi - lo);
|
||||
if (count == 0) return false;
|
||||
const std::size_t off = static_cast<std::size_t>(lo - st.begin());
|
||||
time.resize(count);
|
||||
value.resize(count);
|
||||
for (std::size_t i = 0; i < count; ++i) {
|
||||
time[i] = static_cast<double>(st[off + i]);
|
||||
time[i] = st[off + i];
|
||||
value[i] = static_cast<double>(vals[off + i]);
|
||||
}
|
||||
return true;
|
||||
@@ -2058,21 +2058,23 @@ bool visible_samples(App& app, std::size_t channel, std::vector<double>& time,
|
||||
if (n == 0) return false;
|
||||
time.resize(static_cast<std::size_t>(n));
|
||||
value.resize(static_cast<std::size_t>(n));
|
||||
// In bounded chunks rather than through two whole-window temporaries: the rings hold f32 and
|
||||
// the engine reads f64, so the widening is unavoidable, but the *copies* are not -- a full
|
||||
// window is millions of samples, and the temporaries cost a full write and read of it on top
|
||||
// of the result. `kChunk` keeps the scratch in cache.
|
||||
// In bounded chunks rather than through two whole-window temporaries: the value ring holds
|
||||
// f32 and the engine reads f64, so that widening is unavoidable, but the *copies* are not --
|
||||
// a full window is millions of samples, and the temporaries cost a full write and read of it
|
||||
// on top of the result. `kChunk` keeps both scratches in cache. Two of them, because the
|
||||
// clock ring is f64 and the values f32 (see `BufferStore::time()`).
|
||||
constexpr u64 kChunk = 8192;
|
||||
std::vector<f32> scratch(kChunk);
|
||||
std::vector<f64> t_scratch(kChunk);
|
||||
std::vector<f32> v_scratch(kChunk);
|
||||
for (u64 at = 0; at < n; at += kChunk) {
|
||||
const u64 take = std::min<u64>(kChunk, n - at);
|
||||
store.time().copy_from(first + at, take, scratch.data());
|
||||
store.time().copy_from(first + at, take, t_scratch.data());
|
||||
for (u64 i = 0; i < take; ++i) {
|
||||
time[static_cast<std::size_t>(at + i)] = epoch + static_cast<double>(scratch[i]);
|
||||
time[static_cast<std::size_t>(at + i)] = epoch + t_scratch[i];
|
||||
}
|
||||
store.values(channel).copy_from(first + at, take, scratch.data());
|
||||
store.values(channel).copy_from(first + at, take, v_scratch.data());
|
||||
for (u64 i = 0; i < take; ++i) {
|
||||
value[static_cast<std::size_t>(at + i)] = static_cast<double>(scratch[i]);
|
||||
value[static_cast<std::size_t>(at + i)] = static_cast<double>(v_scratch[i]);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
@@ -2098,10 +2100,10 @@ class MeasureWindowReader {
|
||||
const plot::Range view = r0.plot->x_range(r0.axis_x);
|
||||
if ((snap_ = app.model->display_snapshot()) != nullptr) {
|
||||
if (channel >= snap_->values.size()) return;
|
||||
const std::vector<f32>& st = snap_->stamps;
|
||||
const std::vector<f64>& st = snap_->stamps;
|
||||
if (snap_->values[channel].size() != st.size()) return;
|
||||
const auto lo = std::lower_bound(st.begin(), st.end(), static_cast<f32>(view.min));
|
||||
const auto hi = std::upper_bound(st.begin(), st.end(), static_cast<f32>(view.max));
|
||||
const auto lo = std::lower_bound(st.begin(), st.end(), view.min);
|
||||
const auto hi = std::upper_bound(st.begin(), st.end(), view.max);
|
||||
if (hi <= lo) return;
|
||||
off_ = static_cast<std::size_t>(lo - st.begin());
|
||||
count_ = static_cast<std::size_t>(hi - lo);
|
||||
@@ -2174,10 +2176,10 @@ class MeasureWindowReader {
|
||||
|
||||
/// Window sample `i`'s stream time. One sample at a time on purpose: see the class comment.
|
||||
double time_of(std::size_t i) const {
|
||||
if (snap_vals_ != nullptr) return static_cast<double>(snap_->stamps[off_ + i]);
|
||||
f32 stamp = 0.0f;
|
||||
if (snap_vals_ != nullptr) return snap_->stamps[off_ + i];
|
||||
f64 stamp = 0.0;
|
||||
store_->time().copy_from(first_ + i, 1, &stamp);
|
||||
return epoch_ + static_cast<double>(stamp);
|
||||
return epoch_ + stamp;
|
||||
}
|
||||
|
||||
private:
|
||||
|
||||
Reference in new issue
Block a user