fix(data): level-per-emission pyramid cover, relaxed-atomic ring slots

cover_buckets chose one level per pass, so an odd first whole bucket (half of all

measurement ranges) never reached a level above 0 and folded every remaining

bucket at level 0 -- 15624 entries for a 1e6-sample pyramid. Pick the largest

aligned, in-range, materialised level at each cursor instead; the new cost test

counts folded entries through a PLOT_PYRAMID_COUNT_COVERS-only hook (absent from

normal builds) and fails on the old body.

Ring slots are now relaxed std::atomic: overwrite-on-full means the producer

reuses the slot of a snapshot's oldest sample while the consumer reads it, which

was a data race, not just a stale value. copy_from loads explicitly instead of

std::copy; push stays lock-free. A new producer/consumer case is the one TSan

flags without this.

Also extends the wrapped-window test with x0/bounds/pyramid/sum/sumsq/gather

checks, and corrects the StreamSeries threading note to the sample-count bound.
This commit is contained in:
Martino Ferrari committed 2026-09-27 21:42:53 +02:00
1 parent 138fa478c3
commit d9909f2115
8 files changed
+190 -38

No files matched your search

+29 -10
View File
@@ -1,3 +1,4 @@
#include <plot/data/detail/cover_count.hpp>
#include <plot/data/minmax_pyramid.hpp>
#include <algorithm>
@@ -26,22 +27,40 @@ constexpr u64 ceil_to_bucket(u64 v) {
/// A level-l entry is emitted only when the whole level-l bucket exists (all
/// kBucket << l samples are present, so its aggregate is final). Callers
/// guarantee b1 <= ceil(size() / kBucket), so the only possible remainder is the
/// single trailing partial level-0 bucket, which is emitted at level 0. The
/// decomposition has O(log) entries, so every query below is O(log n).
/// single trailing partial level-0 bucket, which is emitted at level 0.
///
/// The level is chosen per *emission*, not once per pass: at each cursor the
/// largest level is taken whose bucket is aligned with the cursor, lies wholly
/// inside [b0, b1) and has already been materialised. A cursor that starts
/// misaligned (an odd first whole bucket, i.e. half of all measurement ranges)
/// therefore burns at most one level-0 bucket and then resumes the coarse
/// levels, so the decomposition is at most two entries per level and every query
/// below is O(log n) for any start. Choosing the level once per pass instead left
/// the level-0 loop to consume the whole remainder one bucket at a time, because
/// no coarser level is ever reachable from an odd cursor.
template <typename Fn>
void cover_buckets(const MinMaxPyramid& p, u64 b0, u64 b1, Fn&& fn) {
const u64 complete = p.size() / kBucket;
const u64 limit = std::min(b1, complete);
const u32 top = p.level_count() - 1; // level_count() is at least 1
u64 b = b0;
for (u32 li = p.level_count(); li-- > 0 && b < b1;) {
const u64 lb = u64{1} << li; // level-0 buckets per level-li bucket
const u64 avail = p.level(li).size();
while (b < b1 && (b % lb) == 0 && b + lb <= b1 && b + lb <= complete &&
(b / lb) < avail) {
fn(li, b / lb);
b += lb;
while (b < b1) {
u32 li = top;
for (;;) {
const u64 lb = u64{1} << li;
if ((b % lb) == 0 && b + lb <= limit &&
(b / lb) < static_cast<u64>(p.level(li).size())) {
break;
}
if (li == 0) break;
--li;
}
#ifdef PLOT_PYRAMID_COUNT_COVERS
detail::cover_entry_count().fetch_add(1, std::memory_order_relaxed);
#endif
fn(li, b >> li);
b += u64{1} << li;
}
if (b < b1) fn(0u, b); // trailing partial level-0 bucket
}
/// Folds the decomposition of [b0, b1) into a min/max Range. Returns a