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