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

+5
View File
@@ -22,6 +22,11 @@ target_link_libraries(plot_core PRIVATE ${CMAKE_DL_LIBS})
plot_apply_warnings(plot_core) plot_apply_warnings(plot_core)
if(PLOT_BUILD_TESTS) if(PLOT_BUILD_TESTS)
# Test-only: counts the pyramid's covering-decomposition entries so a test can
# pin its O(log n) cost, which no query answer reveals. Off in every non-test
# build (see include/plot/data/detail/cover_count.hpp).
target_compile_definitions(plot_core PRIVATE PLOT_PYRAMID_COUNT_COVERS)
enable_testing() enable_testing()
add_subdirectory(tests) add_subdirectory(tests)
endif() endif()
+29
View File
@@ -0,0 +1,29 @@
#pragma once
#ifdef PLOT_PYRAMID_COUNT_COVERS
#include <atomic>
#include <plot/core/types.hpp>
namespace plot::data::detail {
/// Test-only instrumentation. Counts the pyramid entries the covering
/// decomposition has emitted since the counter was last reset.
///
/// The decomposition's *cost* is invisible in every answer -- a decomposition
/// that walks the range one level-0 bucket at a time returns exactly the same
/// min/max/sum as the O(log n) one -- so a test that wants to pin "at most two
/// entries per level" has to count entries. The counter exists only when
/// PLOT_PYRAMID_COUNT_COVERS is defined (the test build defines it), so every
/// normal build carries no counter and no increment on the query path.
///
/// Not part of the query surface and not meant for production use.
inline std::atomic<u64>& cover_entry_count() {
static std::atomic<u64> count{0};
return count;
}
} // namespace plot::data::detail
#endif // PLOT_PYRAMID_COUNT_COVERS
+27 -17
View File
@@ -1,8 +1,6 @@
#pragma once #pragma once
#include <algorithm>
#include <atomic> #include <atomic>
#include <bit> #include <bit>
#include <cstddef>
#include <vector> #include <vector>
#include <plot/core/types.hpp> #include <plot/core/types.hpp>
@@ -16,6 +14,15 @@ namespace plot::data {
/// sample count with a release store; a consumer that observes that count with an /// sample count with a release store; a consumer that observes that count with an
/// acquire load is guaranteed to see the slot contents written before it. /// acquire load is guaranteed to see the slot contents written before it.
/// ///
/// Slots are std::atomic and every slot access is relaxed. Because the ring
/// *overwrites*, the producer's next push reuses the slot of the snapshot's
/// oldest retained element while the consumer may still be reading it, so a plain
/// load/store there would be a data race (UB), not merely a stale value. A
/// relaxed atomic load/store is a plain mov/ldr/str on the supported targets, so
/// the sample path costs the same. Relaxed is enough because the ordering lives in
/// the count handshake above; the count fixes *which* indices a snapshot reads,
/// not that the slots still hold those samples (see StreamSeries).
///
/// A push into a full ring silently discards the oldest element: for a live /// A push into a full ring silently discards the oldest element: for a live
/// plotting stream the newest sample is the interesting one and the retained /// plotting stream the newest sample is the interesting one and the retained
/// window slides forward. /// window slides forward.
@@ -29,6 +36,8 @@ namespace plot::data {
/// such a snapshot and then reads only absolute indices. /// such a snapshot and then reads only absolute indices.
template <typename T> template <typename T>
class RingBuffer { class RingBuffer {
static_assert(std::atomic<T>::is_always_lock_free,
"RingBuffer slots are atomic and must be lock-free: the sample path may not lock");
public: public:
explicit RingBuffer(u64 capacity) explicit RingBuffer(u64 capacity)
: cap_(std::bit_ceil(capacity < 1 ? u64{1} : capacity)), : cap_(std::bit_ceil(capacity < 1 ? u64{1} : capacity)),
@@ -38,7 +47,7 @@ class RingBuffer {
/// Producer thread only. /// Producer thread only.
void push(T v) { void push(T v) {
const u64 i = produced_.load(std::memory_order_relaxed); const u64 i = produced_.load(std::memory_order_relaxed);
buf_[static_cast<usize>(i & mask_)] = v; buf_[static_cast<usize>(i & mask_)].store(v, std::memory_order_relaxed);
produced_.store(i + 1, std::memory_order_release); produced_.store(i + 1, std::memory_order_release);
} }
@@ -66,24 +75,25 @@ class RingBuffer {
} }
/// Single-threaded: retained-window index (0 = oldest retained). `i` must be /// Single-threaded: retained-window index (0 = oldest retained). `i` must be
/// < size(). /// < size(). Returns by value: the slot is an atomic, so a relaxed load is the
const T& operator[](u64 i) const noexcept { /// only way to observe it.
return buf_[static_cast<usize>((base() + i) & mask_)]; T operator[](u64 i) const noexcept {
return buf_[static_cast<usize>((base() + i) & mask_)].load(std::memory_order_relaxed);
} }
/// Consumer: copies the `n` elements at absolute indices [absolute, absolute+n) /// Consumer: copies the `n` elements at absolute indices [absolute, absolute+n)
/// into `out`, splitting the copy at the ring's wrap point. The caller must hold /// into `out`, masking the index to wrap. The caller must hold a snapshot
/// a snapshot that retains those indices; this never consults the live count, so /// that retains those indices; this never consults the live count, so
/// a pushing producer cannot shift the read. /// a pushing producer cannot shift the read -- but it fixes only *which*
/// indices are read, not that the slots still hold those samples: a producer
/// that overtakes capacity() has already rewritten the oldest ones (see the
/// class note).
void copy_from(u64 absolute, u64 n, T* out) const { void copy_from(u64 absolute, u64 n, T* out) const {
if (n == 0) return; if (n == 0) return;
const u64 s = absolute & mask_; // Explicit relaxed loads: std::copy cannot read atomic slots, and the mask
const u64 run = std::min(n, cap_ - s); // makes the wrap split unnecessary.
const auto first = buf_.begin() + static_cast<std::ptrdiff_t>(s); for (u64 i = 0; i < n; ++i) {
std::copy(first, first + static_cast<std::ptrdiff_t>(run), out); out[i] = buf_[static_cast<usize>((absolute + i) & mask_)].load(std::memory_order_relaxed);
if (run < n) {
std::copy(buf_.begin(), buf_.begin() + static_cast<std::ptrdiff_t>(n - run),
out + static_cast<std::ptrdiff_t>(run));
} }
} }
@@ -94,7 +104,7 @@ class RingBuffer {
private: private:
u64 cap_; u64 cap_;
u64 mask_; u64 mask_;
std::vector<T> buf_; std::vector<std::atomic<T>> buf_;
std::atomic<u64> produced_{0}; std::atomic<u64> produced_{0};
}; };
+17 -9
View File
@@ -30,15 +30,23 @@ namespace plot::data {
/// ///
/// THREADING NOTE. The ring overwrites the oldest samples -- that is what keeps a /// THREADING NOTE. The ring overwrites the oldest samples -- that is what keeps a
/// live stream showing the newest data, and what the drop test pins down -- so a /// live stream showing the newest data, and what the drop test pins down -- so a
/// producer racing past capacity() can write the very slot the consumer is reading /// producer can overwrite the slots a consumer is reading even while it holds a
/// at the window's oldest edge. That read is not ordered against the write, so a /// snapshot. Those slots are relaxed atomics (see RingBuffer), so this is not a
/// race detector flags it: the value is still a single f32 (no tearing on /// data race and no value tears; what it is not is a frozen window.
/// supported targets) and the worst case is one stale or too-new sample at the ///
/// oldest edge, corrected by the next acquire() once it sees the base moved. The /// The bound is the sample count, not the geometry. A snapshot count fixes *which
/// pyramid itself never mixes windows, because acquire() derives the whole window /// absolute indices* a read walks, not that the slots still hold those samples:
/// from one count. Ingest that is race-free even at the edge needs the producer to /// every push after the snapshot overwrites exactly one slot, the one belonging to
/// stop before unread data (drop-on-full) or a claim protocol, i.e. a different /// the sample capacity() positions behind it. So if the producer pushes k samples
/// contract from the one this class specifies. /// while a read is in flight, the first k positions of that read window can hold
/// one of those k newer samples (or a still-newer sample, once the producer has
/// lapped again) instead of the sample the snapshot names. Nothing else moves: the
/// count, the absolute index -> x mapping and the newest end of the window are
/// derived from the one published count, and the next acquire() rebuilds from the
/// base it observes, so the pyramid never mixes two windows. Ingest with no reuse
/// underneath a read at all needs the producer to stop before unread data
/// (drop-on-full) or a claim protocol, i.e. a different contract from the one this
/// class specifies.
class StreamSeries final : public Series { class StreamSeries final : public Series {
public: public:
explicit StreamSeries(u64 capacity, f64 x0, f64 dx); explicit StreamSeries(u64 capacity, f64 x0, f64 dx);
+29 -10
View File
@@ -1,3 +1,4 @@
#include <plot/data/detail/cover_count.hpp>
#include <plot/data/minmax_pyramid.hpp> #include <plot/data/minmax_pyramid.hpp>
#include <algorithm> #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 /// 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 /// kBucket << l samples are present, so its aggregate is final). Callers
/// guarantee b1 <= ceil(size() / kBucket), so the only possible remainder is the /// 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 /// single trailing partial level-0 bucket, which is emitted at level 0.
/// decomposition has O(log) entries, so every query below is O(log n). ///
/// 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> template <typename Fn>
void cover_buckets(const MinMaxPyramid& p, u64 b0, u64 b1, Fn&& fn) { void cover_buckets(const MinMaxPyramid& p, u64 b0, u64 b1, Fn&& fn) {
const u64 complete = p.size() / kBucket; 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; u64 b = b0;
for (u32 li = p.level_count(); li-- > 0 && b < b1;) { while (b < b1) {
const u64 lb = u64{1} << li; // level-0 buckets per level-li bucket u32 li = top;
const u64 avail = p.level(li).size(); for (;;) {
while (b < b1 && (b % lb) == 0 && b + lb <= b1 && b + lb <= complete && const u64 lb = u64{1} << li;
(b / lb) < avail) { if ((b % lb) == 0 && b + lb <= limit &&
fn(li, b / lb); (b / lb) < static_cast<u64>(p.level(li).size())) {
b += lb; 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 /// Folds the decomposition of [b0, b1) into a min/max Range. Returns a
+6 -1
View File
@@ -22,6 +22,8 @@ else()
"or mesa-libEGL-devel) or configure with -DPLOT_BUILD_TESTS=OFF.") "or mesa-libEGL-devel) or configure with -DPLOT_BUILD_TESTS=OFF.")
endif() endif()
find_package(Threads REQUIRED) # std::thread in the push/read race test
add_executable(plot_tests add_executable(plot_tests
main.cpp main.cpp
test_transform.cpp test_transform.cpp
@@ -31,7 +33,10 @@ add_executable(plot_tests
test_series.cpp test_series.cpp
egl_headless.cpp egl_headless.cpp
) )
target_link_libraries(plot_tests PRIVATE plot::core ${plot_egl_library}) target_link_libraries(plot_tests PRIVATE plot::core ${plot_egl_library} Threads::Threads)
# Same switch the library is built with: the decomposition-cost test reads the
# counter behind it (see include/plot/data/detail/cover_count.hpp).
target_compile_definitions(plot_tests PRIVATE PLOT_PYRAMID_COUNT_COVERS)
plot_apply_warnings(plot_tests) plot_apply_warnings(plot_tests)
add_test(NAME plot_tests COMMAND plot_tests) add_test(NAME plot_tests COMMAND plot_tests)
+24
View File
@@ -1,4 +1,5 @@
#include "harness.hpp" #include "harness.hpp"
#include <plot/data/detail/cover_count.hpp>
#include <plot/data/minmax_pyramid.hpp> #include <plot/data/minmax_pyramid.hpp>
#include <random> #include <random>
@@ -104,3 +105,26 @@ PLOT_TEST(pyramid_clear_and_rebuild) {
PLOT_CHECK(p.size() == 1); PLOT_CHECK(p.size() == 1);
PLOT_CHECK_NEAR(p.reduce(0, 1).max, 7.0, 1e-6); PLOT_CHECK_NEAR(p.reduce(0, 1).max, 7.0, 1e-6);
} }
PLOT_TEST(pyramid_cover_cost_is_logarithmic_for_unaligned_starts) {
// Answers cannot show this: a decomposition that walks the range one level-0
// bucket at a time returns exactly the same min/max as the O(log n) one. So
// count the folded aggregates. For this query the bad pass emitted 15624
// entries (the level-0 loop consumed the whole remainder); picking a level per
// emission leaves 19, at most two per level, and the aligned start stays at 7.
MinMaxPyramid p;
p.reserve(1'000'000);
for (u64 i = 0; i < 1'000'000; ++i) p.append(0.0f);
PLOT_CHECK(p.level(0).size() == 1'000'000 / kBucket); // 15625, no partial bucket
auto folds_for = [&](u64 begin, u64 end) {
plot::data::detail::cover_entry_count().store(0, std::memory_order_relaxed);
(void)p.reduce_buckets(begin, end);
return plot::data::detail::cover_entry_count().load(std::memory_order_relaxed);
};
const u64 aligned = folds_for(0, 1'000'000); // first whole bucket 0
const u64 unaligned = folds_for(1, 1'000'000); // first whole bucket 1: odd
PLOT_CHECK(aligned <= 64);
PLOT_CHECK(unaligned < 100);
PLOT_CHECK(unaligned <= u64{4} * aligned); // misalignment costs entries, not a scan
}
+53 -1
View File
@@ -4,6 +4,7 @@
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
#include <random> #include <random>
#include <thread>
#include <vector> #include <vector>
PLOT_TEST(array_series_bounds_and_gather) { 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_TEST(stream_series_exact_queries_survive_ring_wrap) {
plot::data::StreamSeries s(1u << 10, 0.0, 1.0); 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(); s.acquire();
const u64 n = s.size(); 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 const plot::Range r = s.reduce(0, n); // retained window, wrapped
PLOT_CHECK_NEAR(r.max, 96.0, 1e-4); PLOT_CHECK_NEAR(r.max, 96.0, 1e-4);
PLOT_CHECK_NEAR(r.min, 0.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);
} }