Skip to content

Commit 17f84c4

Browse files
jmoralezclaude
andauthored
perf: rolling min/max with a branch-free block scan (#150)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 2722d38 commit 17f84c4

5 files changed

Lines changed: 145 additions & 133 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,16 @@
4242
zero for a group that produced a lambda, NaN for an empty or all-NaN group,
4343
whose whole row the driver fills.
4444

45+
### Performance
46+
47+
- Rolling min and max, with their seasonal, update and expanding variants, use
48+
a block scan (van Herk / Gil-Werman) instead of a monotonic deque. The deque
49+
popped entries in a loop whose exit depended on the data, which mispredicted
50+
about once per element; the scan does three compare-selects per element with
51+
no such branch. About 3x faster on random data, 2.6 to 3.9 ns per element
52+
instead of 9.5 on a Neoverse N1, and the same on monotonic data. Results are
53+
unchanged.
54+
4555
### Build
4656

4757
- The Eigen submodule is gone; the handful of reductions it backed are plain

include/coreforecast/rolling.h

Lines changed: 99 additions & 132 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@
55
#include <algorithm>
66
#include <cassert>
77
#include <cmath>
8-
#include <functional>
9-
#include <memory>
8+
#include <limits>
109
#include <span>
1110
#include <type_traits>
1211
#include <variant>
@@ -214,150 +213,118 @@ inline void StdTransform(std::span<const T> data, std::span<T> out,
214213
StdTransformWithStats(data, out, std::span<T>{}, w);
215214
}
216215

217-
// ============================================================================
218-
// CompAccumulator - PARTIALLY OPTIMIZED for SkipNA=false
219-
// ============================================================================
220-
// Cannot fully remove isnan checks because NaN breaks comparison semantics
221-
// (NaN < x and NaN > x are both false), which corrupts the monotonic deque.
222-
// Optimization: Short-circuit all deque operations once has_nan_ is true.
223-
// ============================================================================
224-
template <typename T, typename Comp, bool SkipNA> class CompAccumulator {
225-
public:
226-
// std::pair's default constructor value-initializes, which would zero the
227-
// whole ring buffer on every allocation; this aggregate's one is trivial
228-
struct Entry {
229-
index_t index;
230-
T value;
231-
};
216+
// Rolling min and max are one van Herk / Gil-Werman block scan: each output
217+
// joins the suffix scan of the previous block with the prefix scan of the
218+
// current one, three compare-selects per element and no data-dependent
219+
// branch. The op must stay the plain `a < b ? b : a` (one maxsd on x86-64,
220+
// fcsel on aarch64): std::fmax is a libm call on x86-64 GCC and an isnan()
221+
// select becomes a branch. NaN handling therefore lives outside the op. With
222+
// skipna a NaN turns into the identity before the op and a rolling NaN count
223+
// makes a window without a valid value NaN, which the identity alone can't
224+
// since -inf is a legitimate value. Without skipna the caller passes NaN-free
225+
// data.
226+
template <typename T> struct MaxOp {
227+
static constexpr T id = -std::numeric_limits<T>::infinity();
228+
T operator()(T a, T b) const noexcept { return a < b ? b : a; }
229+
};
232230

233-
CompAccumulator(index_t window_size)
234-
: buffer_(std::make_unique_for_overwrite<Entry[]>(
235-
static_cast<size_t>(window_size))),
236-
window_size_(window_size) {}
237-
inline bool Empty() const noexcept { return tail_ == -1; }
238-
inline void PushBack(index_t i, T x) noexcept {
239-
if (tail_ == -1) {
240-
head_ = 0;
241-
tail_ = 0;
242-
} else if (tail_ == window_size_ - 1) {
243-
tail_ = 0;
244-
} else {
245-
++tail_;
246-
}
247-
buffer_[tail_] = {i, x};
231+
template <typename T> struct MinOp {
232+
static constexpr T id = std::numeric_limits<T>::infinity();
233+
T operator()(T a, T b) const noexcept { return b < a ? b : a; }
234+
};
235+
236+
template <typename T, typename Op, bool SkipNA>
237+
inline void BlockScan(std::span<const T> data, std::span<T> out, index_t w,
238+
index_t min_samples) {
239+
const Op op;
240+
const index_t n = std::ssize(data);
241+
auto clean = [](T x) -> T {
242+
if constexpr (SkipNA)
243+
return std::isnan(x) ? Op::id : x;
244+
else
245+
return x;
246+
};
247+
auto is_nan = [](T x) -> index_t {
248+
if constexpr (SkipNA)
249+
return std::isnan(x);
250+
else
251+
return 0;
252+
};
253+
index_t nans = 0;
254+
T run = Op::id;
255+
const index_t head = std::min(w, n);
256+
for (index_t i = 0; i < head; ++i) {
257+
run = op(run, clean(data[i]));
258+
nans += is_nan(data[i]);
259+
out[i] = (i + 1 < min_samples || nans == i + 1) ? kNaN<T> : run;
248260
}
249-
inline void PopBack() noexcept {
250-
if (head_ == tail_) {
251-
head_ = 0;
252-
tail_ = -1;
253-
} else if (tail_ == 0) {
254-
tail_ = window_size_ - 1;
255-
} else {
256-
--tail_;
261+
if (n <= w)
262+
return;
263+
// slot w holds the identity so the window that is exactly one block needs
264+
// no special case
265+
std::vector<T> prev(w + 1, Op::id), cur(w + 1, Op::id);
266+
auto suffix = [&](std::vector<T> &buf, index_t start) {
267+
buf[w - 1] = clean(data[start + w - 1]);
268+
for (index_t j = w - 2; j >= 0; --j)
269+
buf[j] = op(clean(data[start + j]), buf[j + 1]);
270+
};
271+
suffix(prev, 0);
272+
for (index_t b = w; b < n; b += w) {
273+
const index_t len = std::min(w, n - b);
274+
run = Op::id;
275+
for (index_t o = 0; o < len; ++o) {
276+
const index_t i = b + o;
277+
run = op(run, clean(data[i]));
278+
nans += is_nan(data[i]) - is_nan(data[i - w]);
279+
out[i] = nans == w ? kNaN<T> : op(prev[o + 1], run);
257280
}
258-
}
259-
inline void PopFront() noexcept {
260-
if (head_ == tail_) {
261-
head_ = 0;
262-
tail_ = -1;
263-
} else if (head_ == window_size_ - 1) {
264-
head_ = 0;
265-
} else {
266-
++head_;
281+
if (b + len < n) {
282+
suffix(cur, b);
283+
std::swap(prev, cur);
267284
}
268285
}
269-
inline const Entry &Front() const noexcept { return buffer_[head_]; }
270-
inline const Entry &Back() const noexcept { return buffer_[tail_]; }
271-
272-
void Insert(T x) noexcept {
273-
if constexpr (!SkipNA) {
274-
// Short-circuit: once NaN seen, skip all deque maintenance
275-
if (has_nan_) {
276-
++i_;
277-
return;
278-
}
279-
if (std::isnan(x)) {
280-
has_nan_ = true;
281-
++i_;
282-
return;
283-
}
284-
}
285-
286-
if constexpr (SkipNA) {
287-
if (std::isnan(x)) {
288-
// the front can expire on this step even though nothing is inserted;
289-
// skipping the check leaves an out-of-window entry to be returned
290-
if (!Empty() && Front().index <= i_) {
291-
PopFront();
292-
}
293-
++i_;
294-
return;
295-
}
296-
}
286+
}
297287

298-
// Valid value: maintain monotonic deque
299-
while (!Empty() && comp_(Back().value, x)) {
300-
PopBack();
301-
}
302-
if (!Empty() && Front().index <= i_) {
303-
PopFront();
304-
}
305-
PushBack(window_size_ + i_, x);
306-
++i_;
288+
template <typename T, typename Op>
289+
inline void CompTransform(std::span<const T> data, std::span<T> out,
290+
const Window &w) {
291+
assert(w.window_size > 0 && w.min_samples > 0);
292+
const index_t n = std::ssize(data);
293+
if (n < w.min_samples) {
294+
FillNaN(out);
295+
return;
307296
}
308-
309-
void Update(T x) noexcept { Insert(x); }
310-
311-
T Update(T x, index_t) noexcept {
312-
Insert(x);
313-
if constexpr (!SkipNA) {
314-
if (has_nan_)
315-
return kNaN<T>;
316-
}
317-
if (Empty())
318-
return kNaN<T>;
319-
return Front().value;
297+
if (w.skipna) {
298+
const index_t window_size = std::min(w.window_size, n);
299+
BlockScan<T, Op, true>(data, out, window_size,
300+
std::min(w.min_samples, window_size));
301+
return;
320302
}
321-
322-
T Update(T new_x, T) noexcept {
323-
Insert(new_x);
324-
if constexpr (!SkipNA) {
325-
if (has_nan_)
326-
return kNaN<T>;
327-
}
328-
if (Empty())
329-
return kNaN<T>;
330-
return Front().value;
303+
// a NaN poisons its own output and every later one, so the kernel only
304+
// sees the clean prefix; this branch fires once
305+
index_t k = 0;
306+
while (k < n && !std::isnan(data[k]))
307+
++k;
308+
if (k < w.min_samples) {
309+
FillNaN(out);
310+
return;
331311
}
332-
333-
private:
334-
std::unique_ptr<Entry[]> buffer_;
335-
index_t window_size_;
336-
index_t head_ = 0;
337-
index_t tail_ = -1;
338-
index_t i_ = 0;
339-
[[no_unique_address]] std::conditional_t<SkipNA, std::monostate, bool>
340-
has_nan_{};
341-
Comp comp_ = Comp();
342-
};
312+
const index_t window_size = std::min(w.window_size, k);
313+
BlockScan<T, Op, false>(data.first(k), out.first(k), window_size,
314+
std::min(w.min_samples, window_size));
315+
FillNaN(out.subspan(k));
316+
}
343317

344318
template <typename T>
345-
void MinTransform(std::span<const T> data, std::span<T> out, const Window &w) {
346-
if (w.skipna) {
347-
Transform<T, CompAccumulator<T, std::greater_equal<T>, true>>(data, out, w);
348-
} else {
349-
Transform<T, CompAccumulator<T, std::greater_equal<T>, false>>(data, out,
350-
w);
351-
}
319+
inline void MinTransform(std::span<const T> data, std::span<T> out,
320+
const Window &w) {
321+
CompTransform<T, MinOp<T>>(data, out, w);
352322
}
353323

354324
template <typename T>
355-
void MaxTransform(std::span<const T> data, std::span<T> out, const Window &w) {
356-
if (w.skipna) {
357-
Transform<T, CompAccumulator<T, std::less_equal<T>, true>>(data, out, w);
358-
} else {
359-
Transform<T, CompAccumulator<T, std::less_equal<T>, false>>(data, out, w);
360-
}
325+
inline void MaxTransform(std::span<const T> data, std::span<T> out,
326+
const Window &w) {
327+
CompTransform<T, MaxOp<T>>(data, out, w);
361328
}
362329

363330
// ============================================================================

src/coreforecast.cpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#include <algorithm>
22
#include <exception>
33
#include <limits>
4+
#include <memory>
45
#include <span>
56
#include <stdexcept>
67
#include <string>

tests/cpp/helpers.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ void CheckClose(const std::vector<T> &got, const std::vector<T> &want) {
9595
INFO("index ", i);
9696
if (std::isnan(want[i])) {
9797
CHECK(std::isnan(got[i]));
98+
} else if (std::isinf(want[i])) {
99+
CHECK(got[i] == want[i]);
98100
} else {
99101
CHECK(got[i] == doctest::Approx(want[i]).epsilon(eps));
100102
}

tests/cpp/test_rolling.cpp

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,37 @@ TEST_CASE_TEMPLATE("min and max without skipna are NaN from the first NaN on",
106106
CheckClose(out, want_max);
107107
}
108108

109+
// The min/max kernel scans the data in window-sized blocks, so these shapes
110+
// hit its seams: n below, equal to and not a multiple of w, w of 1, a NaN run
111+
// longer than the window and -inf, which is also the scan's identity.
112+
TEST_CASE_TEMPLATE("min and max match the reference across block boundaries", T,
113+
float, double) {
114+
for (bool skipna : {false, true}) {
115+
for (int n : {1, 2, 3, 7, 8, 9, 31}) {
116+
auto data = Random<T>(n);
117+
for (int i = 3; i < n; i += 5) {
118+
data[i] = -std::numeric_limits<T>::infinity();
119+
}
120+
if (skipna && n > 20) {
121+
data = WithNaN(data, {10, 11, 12, 13, 14, 15, 16, 17, 18, 19});
122+
}
123+
for (int w : {1, 2, 3, 7, 8, 30}) {
124+
for (int ms : {1, w}) {
125+
CAPTURE(skipna);
126+
CAPTURE(n);
127+
CAPTURE(w);
128+
CAPTURE(ms);
129+
std::vector<T> out(n);
130+
rolling::MinTransform(In(data), Out(out), Window{w, ms, skipna});
131+
CheckClose(out, RefRolling(data, w, ms, skipna, false, Min<T>));
132+
rolling::MaxTransform(In(data), Out(out), Window{w, ms, skipna});
133+
CheckClose(out, RefRolling(data, w, ms, skipna, false, Max<T>));
134+
}
135+
}
136+
}
137+
}
138+
}
139+
109140
TEST_CASE_TEMPLATE("update equals the last element of transform", T, float,
110141
double) {
111142
const int n = 17;
@@ -204,7 +235,8 @@ TEST_CASE_TEMPLATE("expanding transforms are cumulative statistics", T, float,
204235
CHECK(agg[0] == static_cast<T>(Valid(data, 0, n, skipna).size()));
205236
// the single-array entry points pass no agg; the result is the same
206237
std::vector<T> without_agg(n);
207-
expanding::MeanTransform(In(data), Out(without_agg), std::span<T>{}, skipna);
238+
expanding::MeanTransform(In(data), Out(without_agg), std::span<T>{},
239+
skipna);
208240
CheckClose(without_agg, out);
209241

210242
expanding::StdTransform(In(data), Out(out), Out(agg), skipna);

0 commit comments

Comments
 (0)