perf(data): absolute-space pyramid so acquire() is O(produced), not O(window)
Frame cost was O(capacity x levels) once the ring saturated: acquire() saw the window base move (it always moves once the ring wraps), cleared the pyramid and re-appended all 1,048,576 retained samples, and append() walked every live level with no early stop -- 13,647,936 bucket merges and 73.8 ms per frame at a 1M-sample window, scaling with the window, which is what capped the 1 MS/s demo at 32-58 ms/frame while it was still filling. Index the pyramid in absolute bucket space instead. Level l bucket k covers the samples [k * (kBucket << l), (k + 1) * (kBucket << l)), so a bucket's span never depends on where the window starts and the window can slide over the aggregate instead of forcing it to be rebuilt. Each level keeps its complete buckets in a deque plus the absolute base of its first entry; append() folds a sample into the single in-progress level-0 bucket and, only when that bucket completes, cascades upward, stopping at the first level that gains no new entry; a new drop_before() slides the window by discarding leading buckets and remembers where the window begins. acquire() is now: drop to the new base, then append exactly what was produced -- 256 merges and 0.034 ms per frame for 8192 new samples, independent of the window, and memory bounded by the window (32,767 entries after 50M samples through a 1M ring, the same as after 10M). A producer that laps the consumer by more than capacity() between two acquires restarts the window rather than splicing a gap. Interface: `level(l) -> std::span<const MinMax>` cannot survive a ring and is replaced by level_base/level_size/bucket in absolute bucket coordinates, plus window_base/window_end/drop_before. The *_buckets queries stay in window coordinates (index 0 = oldest retained sample), so Series keeps its window semantics for callers; its exactness split now aligns in absolute space (window_base() + the ceil/floor) because the bucket grid is absolute. The sums move from per-level prefix sums to per-bucket ones -- the only representation a sliding front can keep exact -- and StreamSeries::y_bounds() becomes reduce(0, size()), exact in O(log n + kBucket) per frame instead of a scan of the window. The pyramid owns the published window now, so there is no second copy of base/end to keep in step, and gather() emits only buckets that are both complete and wholly retained. Tests: test-only merge counters (mirroring PLOT_PYRAMID_COUNT_COVERS, absent from every non-test build) pin an acquire proportional to production and independent of capacity, an append cost amortised over 16 live levels, and bounded memory; brute-force checks pin absolute bucket aggregates across slides, sliding-window reduce/sum/sumsq over unaligned ranges, and the level-1 gather mapping. The demo now draws 946 samples/px at 1.03 ms/frame (967 fps) where the saturated window cost 54.96 ms/frame (18 fps).
This commit is contained in:
1 parent
4b649a2783
commit
0ac5d47fa6
13 files changed
+769
-244
No files matched your search
@@ -1,12 +1,36 @@
|
||||
#include "harness.hpp"
|
||||
#include <plot/data/detail/cover_count.hpp>
|
||||
#include <plot/data/detail/merge_count.hpp>
|
||||
#include <plot/data/minmax_pyramid.hpp>
|
||||
#include <algorithm>
|
||||
#include <random>
|
||||
|
||||
using plot::data::kBucket;
|
||||
using plot::data::MinMax;
|
||||
using plot::data::MinMaxPyramid;
|
||||
using plot::Range;
|
||||
|
||||
namespace {
|
||||
|
||||
/// Brute-force aggregate of the absolute span of level-`l` bucket `b`, from the
|
||||
/// same value function the pyramid was fed. This is the oracle the absolute
|
||||
/// bucket indexing is judged against: a bucket that has slid inside the window
|
||||
/// must still aggregate exactly its own absolute samples.
|
||||
template <typename ValueFn>
|
||||
MinMax brute_bucket(u32 l, u64 b, const ValueFn& value) {
|
||||
const u64 span = u64{kBucket} << l;
|
||||
const u64 first = b * span;
|
||||
MinMax m{value(first), value(first)};
|
||||
for (u64 i = first + 1; i < first + span; ++i) {
|
||||
const f32 v = value(i);
|
||||
m.min = std::min(m.min, v);
|
||||
m.max = std::max(m.max, v);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
PLOT_TEST(pyramid_bucket_extremes_preserved) {
|
||||
MinMaxPyramid p;
|
||||
p.reserve(1000);
|
||||
@@ -107,7 +131,8 @@ PLOT_TEST(pyramid_clear_and_rebuild) {
|
||||
// bucket and ask the bucket-grained query for its extrema.
|
||||
for (u32 i = 1; i < kBucket; ++i) p.append(1.0f);
|
||||
PLOT_CHECK(p.size() == kBucket);
|
||||
PLOT_CHECK(p.level(0).size() == 1);
|
||||
PLOT_CHECK(p.level_base(0) == 0);
|
||||
PLOT_CHECK(p.level_size(0) == 1);
|
||||
PLOT_CHECK_NEAR(p.reduce_buckets(0, kBucket).max, 7.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(p.reduce_buckets(0, kBucket).min, 1.0, 1e-6);
|
||||
}
|
||||
@@ -121,7 +146,7 @@ PLOT_TEST(pyramid_cover_cost_is_logarithmic_for_unaligned_starts) {
|
||||
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
|
||||
PLOT_CHECK(p.level_size(0) == 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);
|
||||
@@ -134,3 +159,131 @@ PLOT_TEST(pyramid_cover_cost_is_logarithmic_for_unaligned_starts) {
|
||||
PLOT_CHECK(unaligned < 100);
|
||||
PLOT_CHECK(unaligned <= u64{4} * aligned); // misalignment costs entries, not a scan
|
||||
}
|
||||
|
||||
PLOT_TEST(pyramid_drop_before_slides_the_window) {
|
||||
MinMaxPyramid p;
|
||||
for (u64 i = 0; i < 4096; ++i) p.append(f32(i % 13));
|
||||
PLOT_CHECK(p.window_base() == 0);
|
||||
PLOT_CHECK(p.window_end() == 4096);
|
||||
PLOT_CHECK(p.size() == 4096);
|
||||
|
||||
p.drop_before(1000);
|
||||
PLOT_CHECK(p.window_base() == 1000);
|
||||
PLOT_CHECK(p.size() == 3096);
|
||||
// The window's first *whole* bucket starts at 1024: the bucket the drop cut
|
||||
// into is not representable as a whole bucket and is never queried.
|
||||
PLOT_CHECK(p.level_base(0) == 1024 / kBucket);
|
||||
PLOT_CHECK(p.level_base(0) + p.level_size(0) == 4096 / kBucket);
|
||||
|
||||
f32 lo = 1.0f, hi = -1.0f;
|
||||
for (u64 i = 1024; i < 4096; ++i) {
|
||||
lo = std::min(lo, f32(i % 13));
|
||||
hi = std::max(hi, f32(i % 13));
|
||||
}
|
||||
const Range r = p.reduce_buckets(0, p.size());
|
||||
PLOT_CHECK_NEAR(r.min, lo, 1e-6);
|
||||
PLOT_CHECK_NEAR(r.max, hi, 1e-6);
|
||||
// Window [1000, 1024) holds no whole bucket at all: an empty answer, not one
|
||||
// that quietly reaches into the dropped samples.
|
||||
PLOT_CHECK(p.reduce_buckets(0, 24).span() == 0.0);
|
||||
PLOT_CHECK(p.sum_buckets(0, 24) == 0.0);
|
||||
PLOT_CHECK(p.sumsq_buckets(0, 24) == 0.0);
|
||||
|
||||
p.drop_before(1000); // at the base: no-op
|
||||
PLOT_CHECK(p.window_base() == 1000);
|
||||
p.drop_before(4096); // at the end: the window is empty
|
||||
PLOT_CHECK(p.size() == 0);
|
||||
p.append(5.0f); // and appending starts a fresh window
|
||||
PLOT_CHECK(p.size() == 1);
|
||||
PLOT_CHECK(p.window_base() == 4096);
|
||||
}
|
||||
|
||||
PLOT_TEST(pyramid_bucket_aggregates_stay_absolute_across_slides) {
|
||||
// The invariant the sliding window rests on: a bucket that has slid inside the
|
||||
// window is never recomputed, so it must already hold the aggregate of its own
|
||||
// absolute span -- at every level, after any number of drops.
|
||||
MinMaxPyramid p;
|
||||
const auto value = [](u64 i) { return f32((i * 37u) % 101u) - 50.0f; };
|
||||
constexpr u64 kTotal = 20'000;
|
||||
constexpr u64 kWindow = 4'000;
|
||||
for (u64 i = 0; i < kTotal; ++i) {
|
||||
p.append(value(i));
|
||||
if ((i + 1) % 997 == 0 && i + 1 > kWindow) p.drop_before(i + 1 - kWindow);
|
||||
}
|
||||
// The last drop is the last multiple of 997 at or below kTotal.
|
||||
PLOT_CHECK(p.window_base() == (kTotal / 997) * 997 - kWindow);
|
||||
for (u32 l = 0; l < p.level_count(); ++l) {
|
||||
PLOT_CHECK(p.level_size(l) > 0);
|
||||
const u64 span = u64{kBucket} << l;
|
||||
for (u64 b = p.level_base(l); b < p.level_base(l) + p.level_size(l); ++b) {
|
||||
const MinMax want = brute_bucket(l, b, value);
|
||||
const MinMax got = p.bucket(l, b);
|
||||
PLOT_CHECK_NEAR(got.min, want.min, 1e-6);
|
||||
PLOT_CHECK_NEAR(got.max, want.max, 1e-6);
|
||||
PLOT_CHECK(b * span >= p.window_base()); // wholly inside the window
|
||||
PLOT_CHECK((b + 1) * span <= p.window_end()); // and complete
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PLOT_TEST(pyramid_sliding_window_bucket_queries_match_bruteforce) {
|
||||
MinMaxPyramid p;
|
||||
const auto value = [](u64 i) { return f32(i % 89) * 0.25f - 5.0f; };
|
||||
constexpr u64 kTotal = 12'000;
|
||||
constexpr u64 kWindow = 3'000;
|
||||
for (u64 i = 0; i < kTotal; ++i) {
|
||||
p.append(value(i));
|
||||
if ((i + 1) % 613 != 0) continue;
|
||||
const u64 abs0 = i + 1 > kWindow ? i + 1 - kWindow : 0;
|
||||
p.drop_before(abs0);
|
||||
const u64 n = p.size();
|
||||
// Window coordinates: window index j is absolute abs0 + j.
|
||||
const u64 first = abs0 / kBucket + (abs0 % kBucket == 0 ? u64{0} : u64{1});
|
||||
const u64 last = (abs0 + n) / kBucket;
|
||||
if (last <= first) continue;
|
||||
f32 lo = 1e30f, hi = -1e30f;
|
||||
f64 sum = 0.0, sq = 0.0;
|
||||
for (u64 a = first * kBucket; a < last * kBucket; ++a) {
|
||||
const f32 v = value(a);
|
||||
lo = std::min(lo, v);
|
||||
hi = std::max(hi, v);
|
||||
sum += f64(v);
|
||||
sq += f64(v) * f64(v);
|
||||
}
|
||||
const u64 wb = first * kBucket - abs0;
|
||||
const u64 we = last * kBucket - abs0;
|
||||
const Range r = p.reduce_buckets(wb, we);
|
||||
PLOT_CHECK_NEAR(r.min, lo, 1e-6);
|
||||
PLOT_CHECK_NEAR(r.max, hi, 1e-6);
|
||||
PLOT_CHECK_NEAR(p.sum_buckets(wb, we), sum, 1e-3);
|
||||
PLOT_CHECK_NEAR(p.sumsq_buckets(wb, we), sq, 1e-1);
|
||||
}
|
||||
}
|
||||
|
||||
PLOT_TEST(pyramid_append_cost_is_amortised_constant) {
|
||||
// A sample must not walk the live levels: level l gains an entry once per
|
||||
// kBucket << l samples, so N appends perform ~N/32 merges however many levels
|
||||
// the pyramid holds. Walking every level per append costs ~N * levels.
|
||||
MinMaxPyramid p;
|
||||
p.reserve(1u << 20);
|
||||
constexpr u64 kAppends = 1u << 20;
|
||||
for (u64 i = 0; i < kAppends; ++i) p.append(0.5f);
|
||||
PLOT_CHECK(p.level_count() >= 10); // 2^20 samples: 16384 level-0 buckets
|
||||
|
||||
plot::data::detail::merge_count().store(0, std::memory_order_relaxed);
|
||||
for (u64 i = 0; i < kAppends; ++i) p.append(0.5f);
|
||||
const u64 merges = plot::data::detail::merge_count().load(std::memory_order_relaxed);
|
||||
// ~N/64 + N/128 + ... = ~N/32; the bound leaves 2x room, and is four orders of
|
||||
// magnitude below the N * live_levels an unbounded walk would perform.
|
||||
PLOT_CHECK(merges <= kAppends / 16);
|
||||
|
||||
// Sliding must not add per-sample work either: the appends below run against a
|
||||
// window whose base moves with every sample.
|
||||
plot::data::detail::merge_count().store(0, std::memory_order_relaxed);
|
||||
for (u64 i = 0; i < kAppends; ++i) {
|
||||
p.drop_before(i + 1);
|
||||
p.append(0.5f);
|
||||
}
|
||||
const u64 slid = plot::data::detail::merge_count().load(std::memory_order_relaxed);
|
||||
PLOT_CHECK(slid <= kAppends / 16);
|
||||
}
|
||||
Reference in new issue
Block a user