From d55483d3965d73f4dedca6e5f3d3530bc5f39e13 Mon Sep 17 00:00:00 2001 From: MathisWellmann Date: Mon, 29 Jun 2026 15:54:25 +0100 Subject: [PATCH 1/2] 10.0: `MadScaler` now drops `watermill` dependency for sorted window and has its own for 21-46% improvement in throughput for 8192 and 16384 window length. --- Cargo.lock | 57 ++++--------------------------- Cargo.toml | 5 +-- benches/mad_scaler.rs | 2 +- src/sliding_windows/mad_scaler.rs | 42 +++++++++++++++++++---- 4 files changed, 44 insertions(+), 62 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5430dc1..8e634f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -125,7 +125,7 @@ checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", "cpufeatures", - "rand_core 0.10.1", + "rand_core", ] [[package]] @@ -545,7 +545,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", - "rand_core 0.10.1", + "rand_core", "wasip2", "wasip3", ] @@ -850,17 +850,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" -[[package]] -name = "ordered-float" -version = "3.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1e1c390732d15f1d48471625cd92d154e66db2c56645e29a9cd26f4699f72dc" -dependencies = [ - "num-traits", - "rand 0.8.6", - "serde", -] - [[package]] name = "page_size" version = "0.6.0" @@ -1023,16 +1012,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" -dependencies = [ - "rand_core 0.6.4", - "serde", -] - [[package]] name = "rand" version = "0.10.1" @@ -1041,16 +1020,7 @@ checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20", "getrandom 0.4.2", - "rand_core 0.10.1", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "serde", + "rand_core", ] [[package]] @@ -1066,7 +1036,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8" dependencies = [ "num-traits", - "rand 0.10.1", + "rand", ] [[package]] @@ -1237,18 +1207,17 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "sliding_features" -version = "9.2.0" +version = "10.0.0" dependencies = [ "ballpark", "criterion", "getset", "num", "plotters", - "rand 0.10.1", + "rand", "romu", "round", "time_series_generator", - "watermill", ] [[package]] @@ -1289,7 +1258,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0882209b46f9f4a88a984a9445325665877167f509363feed83a0b2e0cc51bf" dependencies = [ "num-traits", - "rand 0.10.1", + "rand", "rand_distr", ] @@ -1434,18 +1403,6 @@ dependencies = [ "semver", ] -[[package]] -name = "watermill" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "674ae43b249a45b79db22c99564f348da46f16ef243c605db082d0cbe088669a" -dependencies = [ - "num", - "ordered-float", - "serde", - "serde_json", -] - [[package]] name = "web-sys" version = "0.3.99" diff --git a/Cargo.toml b/Cargo.toml index ec0456f..f66932d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,6 @@ -cargo-features = ["edition2024"] - [package] name = "sliding_features" -version = "9.2.0" +version = "10.0.0" authors = ["MathisWellmann "] edition = "2024" license-file = "LICENSE" @@ -22,7 +20,6 @@ exclude = ["img/"] [dependencies] getset = "0.1" num = "0.4" -watermill = "0.1.2" [dev-dependencies] ballpark = "1" diff --git a/benches/mad_scaler.rs b/benches/mad_scaler.rs index ad7502c..0245e34 100644 --- a/benches/mad_scaler.rs +++ b/benches/mad_scaler.rs @@ -22,7 +22,7 @@ use sliding_features::{ }; const N: usize = 100_000; -const WINDOW_LENS: &[usize] = &[128, 256, 512, 1024, 2048, 4096, 8192]; +const WINDOW_LENS: &[usize] = &[128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768]; fn standard_normal(rng: &mut SmallRng) -> f64 { // Box-Muller transform. Clamp u1 away from zero so ln(u1) is finite. diff --git a/src/sliding_windows/mad_scaler.rs b/src/sliding_windows/mad_scaler.rs index eb249e9..c811e90 100644 --- a/src/sliding_windows/mad_scaler.rs +++ b/src/sliding_windows/mad_scaler.rs @@ -11,6 +11,7 @@ //! and works well for skewed distributions. use std::{ + collections::VecDeque, num::NonZeroUsize, ops::{ AddAssign, @@ -22,7 +23,6 @@ use num::{ Float, FromPrimitive, }; -use watermill::sorted_window::SortedWindow; use crate::View; @@ -38,8 +38,10 @@ pub struct MadScaler { view: V, /// Sliding window length (for warm-up tracking). window_len: NonZeroUsize, - /// Sliding window storing the sorted values. - sorted: SortedWindow, + /// Sorted values in the window (contiguous `Vec` — faster than `VecDeque`). + sorted: Vec, + /// FIFO insertion order for O(1) eviction decisions. + order: VecDeque, /// Cached median of the window (recomputed when the sliding median changes). cached_median: T, /// Cached MAD (recomputed when the sliding median changes). @@ -79,18 +81,43 @@ where /// window length. #[inline] pub fn new(view: V, window_len: NonZeroUsize) -> Self { + let w = window_len.get(); Self { view, window_len, - sorted: SortedWindow::new(window_len.get()), + sorted: Vec::with_capacity(w), + order: VecDeque::with_capacity(w), cached_median: F::zero(), cached_mad: F::zero(), out: None, count: 0, - mad_buf: vec![F::zero(); window_len.get()], + mad_buf: vec![F::zero(); w], } } + /// Slide the window: evict the oldest value (if full), insert `val` in sorted position. + #[inline] + fn slide_window(&mut self, val: F) { + if self.sorted.len() >= self.window_len.get() { + let oldest = self.order.pop_front().unwrap(); + // partition_point requires monotonic predicate; sorted[len..] >= oldest after this. + let pos = self + .sorted + .partition_point(|p| p.partial_cmp(&oldest).expect("NaN") == std::cmp::Ordering::Less); + debug_assert_eq!( + self.sorted[pos].partial_cmp(&oldest), + Some(std::cmp::Ordering::Equal), + "oldest value missing from sorted window" + ); + self.sorted.remove(pos); + } + self.order.push_back(val); + let pos = self + .sorted + .partition_point(|p| p.partial_cmp(&val).expect("NaN") == std::cmp::Ordering::Less); + self.sorted.insert(pos, val); + } + /// The sliding window length. #[inline(always)] pub fn window_len(&self) -> NonZeroUsize { @@ -107,7 +134,8 @@ where fn recompute_cache(&mut self) { let n = self.sorted.len(); debug_assert!(n > 0, "recompute_cache called on empty window"); - debug_assert_eq!(n, self.mad_buf.len()); + // mad_buf may be larger than current window during warm-up ramp; use n. + debug_assert!(n <= self.mad_buf.len()); // --- median (from already-sorted window) --- let median = if n % 2 == 0 { @@ -218,7 +246,7 @@ where // Slide window: push current value into the sorted window. // This makes it part of the *next* normalization's reference. - self.sorted.push_back(val); + self.slide_window(val); self.count = (self.count + 1).min(self.window_len.get()); } From b3a4d0787d976687dfb0abcffb74877c597229e7 Mon Sep 17 00:00:00 2001 From: MathisWellmann Date: Mon, 29 Jun 2026 16:33:36 +0100 Subject: [PATCH 2/2] mad_scaler: use "branchless" merge for a 2-9% speedup --- src/sliding_windows/mad_scaler.rs | 50 ++++++++++++------------------- 1 file changed, 19 insertions(+), 31 deletions(-) diff --git a/src/sliding_windows/mad_scaler.rs b/src/sliding_windows/mad_scaler.rs index c811e90..d2bb074 100644 --- a/src/sliding_windows/mad_scaler.rs +++ b/src/sliding_windows/mad_scaler.rs @@ -147,9 +147,14 @@ where }; self.cached_median = median; - // --- median absolute deviation via O(w) merge --- + // --- median absolute deviation via O(w) branchless merge --- // Walk the sorted window outward from the median, merging the // monotonically-increasing left and right absolute deviations. + // + // ponytail: branchless — both sides computed unconditionally, + // exhausted side returns infinity sentinel so the other always wins. + // The hot-path `.get()` always returns Some (predictable branch). + // Selection and pointer advance compile to cmov. let mad_buf = &mut self.mad_buf; let mid = n / 2; let mut out_idx = 0usize; @@ -164,37 +169,20 @@ where let mut left: isize = mid.checked_sub(1).map_or(-1, |v| v as isize); let mut right: usize = if n % 2 == 0 { mid } else { mid + 1 }; - loop { - let l_valid = left >= 0; - let r_valid = right < n; - match (l_valid, r_valid) { - (true, true) => { - let l_abs = (self.sorted[left as usize] - median).abs(); - let r_abs = (self.sorted[right] - median).abs(); - if l_abs <= r_abs { - mad_buf[out_idx] = l_abs; - out_idx += 1; - left -= 1; - } else { - mad_buf[out_idx] = r_abs; - out_idx += 1; - right += 1; - } - } - (true, false) => { - mad_buf[out_idx] = (self.sorted[left as usize] - median).abs(); - out_idx += 1; - left -= 1; - } - (false, true) => { - mad_buf[out_idx] = (self.sorted[right] - median).abs(); - out_idx += 1; - right += 1; - } - (false, false) => break, - } + while out_idx < n { + let l_abs = self + .sorted + .get(left as usize) + .map_or(F::infinity(), |v| (*v - median).abs()); + let r_abs = self + .sorted + .get(right) + .map_or(F::infinity(), |v| (*v - median).abs()); + let pick_left = l_abs <= r_abs; + mad_buf[out_idx] = if pick_left { l_abs } else { r_abs }; + if pick_left { left -= 1 } else { right += 1 } + out_idx += 1; } - debug_assert_eq!(out_idx, n, "merge must fill entire buffer"); self.cached_mad = median_from_sorted(mad_buf); }