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

+53 -1
View File
@@ -4,6 +4,7 @@
#include <algorithm>
#include <cmath>
#include <random>
#include <thread>
#include <vector>
PLOT_TEST(array_series_bounds_and_gather) {
@@ -69,10 +70,61 @@ PLOT_TEST(array_series_exact_range_queries_match_bruteforce) {
PLOT_TEST(stream_series_exact_queries_survive_ring_wrap) {
plot::data::StreamSeries s(1u << 10, 0.0, 1.0);
for (int i = 0; i < 3000; ++i) s.push(f32(i % 97)); // wraps the ring twice
constexpr u64 kPushes = 3000;
constexpr u64 kFirstRetained = kPushes - (1u << 10); // 1976
for (u64 i = 0; i < kPushes; ++i) s.push(f32(i % 97)); // wraps the ring twice
s.acquire();
const u64 n = s.size();
PLOT_CHECK(n == 1u << 10);
PLOT_CHECK_NEAR(s.x0(), 1976.0, 1e-9);
PLOT_CHECK(s.x_bounds().min == s.x0());
PLOT_CHECK(s.pyramid().size() == n);
const plot::Range r = s.reduce(0, n); // retained window, wrapped
PLOT_CHECK_NEAR(r.max, 96.0, 1e-4);
PLOT_CHECK_NEAR(r.min, 0.0, 1e-4);
double sum = 0.0, sumsq = 0.0;
for (u64 i = kFirstRetained; i < kPushes; ++i) {
const double v = static_cast<double>(i % 97);
sum += v;
sumsq += v * v;
}
PLOT_CHECK_NEAR(s.sum(0, n), sum, 1e-2);
PLOT_CHECK_NEAR(s.sumsq(0, n), sumsq, 1e-1);
std::vector<f32> out;
s.gather(0, 0, n, out); // level 0: raw samples
PLOT_CHECK(out.size() == 2 * n);
PLOT_CHECK_NEAR(out[0], s.x0(), 1e-6);
PLOT_CHECK_NEAR(out[1], f32(kFirstRetained % 97), 1e-6);
PLOT_CHECK_NEAR(out[2 * n - 2], s.x0() + f64(n - 1), 1e-6);
}
PLOT_TEST(stream_series_push_and_read_are_race_free) {
// The ring overwrites, so the producer reuses the slot of the snapshot's oldest
// retained sample while the consumer may still be reading it. Relaxed atomic
// slots make that defined; nothing else in the suite has two threads, so this
// is the case ThreadSanitizer actually flags when that is not the case.
plot::data::StreamSeries s(1u << 8, 0.0, 1.0);
constexpr u64 kPushes = 50'000;
std::thread producer([&s] {
for (u64 i = 0; i < kPushes; ++i) s.push(f32(i % 971));
});
u64 reads = 0;
while (s.produced() < kPushes) {
s.acquire();
const u64 n = s.size();
if (n == 0) continue;
(void)s.reduce(0, n);
(void)s.sum(0, n);
(void)s.sumsq(0, n);
std::vector<f32> out;
s.gather(0, 0, n, out); // reads the oldest edge
PLOT_CHECK(out.size() == 2 * n);
++reads;
}
producer.join();
PLOT_CHECK(s.produced() == kPushes);
PLOT_CHECK(reads > 0);
}