feat: broaden CPU and platform coverage across PyPI, nodes, and crate… (#26)

* feat: broaden CPU and platform coverage across PyPI, nodes, and crates.io

Replace static SIMD with runtime CPU-feature dispatch and expand the release
wheel matrix so one set of artifacts runs on any target CPU and platform
without illegal-instruction crashes.

Rust core:
- Add multiversion runtime dispatch (crates/ferro_ta_core/src/simd.rs); drop
  compile-time `wide`. `simd` feature is now default-on and forwarded through
  the pyo3 crate, and stays compatible with #![forbid(unsafe_code)].

Packaging:
- abi3-py310: one cp310-abi3 wheel per platform (covers CPython 3.10+).
- CI matrix adds Linux aarch64 + musllinux (x86_64/aarch64) and Windows arm64.

Node/Docker + docs:
- api/Dockerfile: document baseline+dispatch (no target-cpu pin) and add a
  fail-fast import check; aarch64 containers now install cleanly.
- Rewrite docs/guides/simd.md; fix stale `wide` mention in ADR 0003.
- Add ADR 0006 (CPU coverage strategy).

Also bundles in-flight release prep already staged in the tree (DTW exception
types, SBOM/provenance security, supporting docs).

* fix(ci): clear cargo-deny and pip-audit failures; apply dependency bumps

cargo-deny (advisories):
- Ignore pyo3 RUSTSEC-2026-0176 / RUSTSEC-2026-0177 in deny.toml with a
  documented rationale: ferro-ta uses neither affected code path
  (PyList/PyTuple nth iterators; PyCFunction::new_closure). Upstream fix
  needs pyo3 >=0.29 (large API migration), tracked as a follow-up.

pip-audit:
- Bump dev lockfile idna 3.18, pytest 9.1.1, urllib3 2.7.0 to clear
  PYSEC-2026-215, CVE-2025-71176, PYSEC-2026-141/142.

Dependency bumps (supersede open dependabot PRs; they auto-close on merge):
- cargo: log 0.4.32, serde_json 1.0.150, rayon 1.12.0
- api/requirements.txt: uvicorn>=0.49.0, pydantic>=2.13.4, ferro-ta>=1.1.4
- CI actions: deploy-pages v5, upload-pages-artifact v5, action-gh-release v3

The open `wide` 1.5.0 bump (PR #24) is obsolete — the crate is removed in
this branch.

* chore: address CodeRabbit review; remove docs/adr section

CodeRabbit findings:
- CI sbom job: add `attestations: write` so attest-build-provenance can run
  (it had only contents:write + id-token:write).
- simd.rs: vectorize `wma_seed` with lane-local accumulators — it was scalar
  behind the multiversion wrapper, adding dispatch overhead for no SIMD gain.
- CHANGELOG: consolidate the duplicate `### Changed` heading.
- python/ferro_ta/__init__.py: also re-export the `FerroTaError` alias.
- docs/guides/dtw.md: soften "byte-for-byte" parity to within-tolerance.

Remove docs/adr/ at maintainer request and clean up the ADR links in the
SIMD and DTW guides. The ADR files remain in commit 9506a30 if ever needed.
This commit is contained in:
Pratik Bhadane
2026-06-29 18:21:22 +05:30
committed by GitHub
parent fd1bb137d6
commit 288b1546b2
23 changed files with 839 additions and 169 deletions
+9 -3
View File
@@ -16,7 +16,7 @@ name = "ferro_ta_core"
crate-type = ["lib"]
[dependencies]
wide = { version = "1.1.1", optional = true }
multiversion = { version = "0.8", optional = true }
serde = { version = "1.0", features = ["derive"], optional = true }
serde_json = { version = "1.0", optional = true }
@@ -28,6 +28,12 @@ name = "indicators"
harness = false
[features]
wide = ["dep:wide"]
simd = ["wide"]
# Runtime CPU-feature dispatch (multiversion). Default ON so `cargo add
# ferro_ta_core` and the published wheels get SIMD-accelerated reductions
# that adapt to the running CPU (baseline .. AVX-512 / NEON) WITHOUT pinning
# a target-cpu — one binary runs on any CPU of the target arch, with no
# illegal-instruction crashes on older chips. Disable with
# `--no-default-features` for a pure-scalar build.
default = ["simd"]
simd = ["dep:multiversion"]
serde = ["dep:serde", "dep:serde_json"]
+4
View File
@@ -1,3 +1,5 @@
#![forbid(unsafe_code)]
/*!
ferro_ta_core — Pure Rust indicator library.
@@ -49,6 +51,8 @@ pub mod price_transform;
pub mod regime;
pub mod resampling;
pub mod signals;
/// Runtime-dispatched SIMD reduction primitives (internal).
pub(crate) mod simd;
pub mod statistic;
pub mod streaming;
pub mod volatility;
+7 -61
View File
@@ -38,27 +38,10 @@ pub fn sma_into(src: &[f64], timeperiod: usize, dest: &mut [f64], dest_offset: u
return;
}
#[cfg(feature = "simd")]
let window_sum_init = {
use wide::f64x4;
let p_data = &src[..timeperiod];
let mut sum = f64x4::splat(0.0);
let mut chunks = p_data.chunks_exact(4);
for chunk in &mut chunks {
sum += f64x4::new([chunk[0], chunk[1], chunk[2], chunk[3]]);
}
let arr = sum.to_array();
let mut total = arr[0] + arr[1] + arr[2] + arr[3];
for &v in chunks.remainder() {
total += v;
}
total
};
#[cfg(not(feature = "simd"))]
let window_sum_init: f64 = src[..timeperiod].iter().sum();
let mut window_sum = window_sum_init;
// Seed the rolling window with a runtime-dispatched reduction. The O(n)
// streaming recurrence below is inherently sequential, so SIMD only ever
// applies to this initial window sum.
let mut window_sum = crate::simd::sum(&src[..timeperiod]);
let tp_f64 = timeperiod as f64;
dest[dest_offset + timeperiod - 1] = window_sum / tp_f64;
@@ -124,46 +107,9 @@ pub fn wma(close: &[f64], timeperiod: usize) -> Vec<f64> {
let denom: f64 = (timeperiod * (timeperiod + 1) / 2) as f64;
let p = timeperiod as f64;
// Seed: compute T and S for the first window.
#[cfg(feature = "simd")]
let (mut t, mut s) = {
use wide::f64x4;
let p_data = &close[..timeperiod];
let mut t_simd = f64x4::splat(0.0);
let mut s_simd = f64x4::splat(0.0);
let mut chunks = p_data.chunks_exact(4);
let mut idx = 1.0;
let step = f64x4::new([0.0, 1.0, 2.0, 3.0]);
for chunk in &mut chunks {
let vals = f64x4::new([chunk[0], chunk[1], chunk[2], chunk[3]]);
let mults = f64x4::splat(idx) + step;
t_simd += vals * mults;
s_simd += vals;
idx += 4.0;
}
let t_arr = t_simd.to_array();
let s_arr = s_simd.to_array();
let mut t = t_arr[0] + t_arr[1] + t_arr[2] + t_arr[3];
let mut s = s_arr[0] + s_arr[1] + s_arr[2] + s_arr[3];
for &v in chunks.remainder() {
t += v * idx;
s += v;
idx += 1.0;
}
(t, s)
};
#[cfg(not(feature = "simd"))]
let (mut t, mut s) = {
let t_val: f64 = close[..timeperiod]
.iter()
.enumerate()
.map(|(k, &v)| v * (k + 1) as f64)
.sum();
let s_val: f64 = close[..timeperiod].iter().sum();
(t_val, s_val)
};
// Seed: compute T and S for the first window via a runtime-dispatched
// reduction (the streaming recurrence below is sequential).
let (mut t, mut s) = crate::simd::wma_seed(&close[..timeperiod]);
result[timeperiod - 1] = t / denom;
+161
View File
@@ -0,0 +1,161 @@
//! Runtime-dispatched SIMD primitives.
//!
//! Each public reduction here is compiled into several CPU-feature-specific
//! variants (baseline, SSE, AVX2/FMA, AVX-512 on x86_64; NEON on aarch64; …)
//! by [`multiversion`]. The fastest variant the *current* CPU supports is
//! chosen at runtime via CPUID. This gives one binary that:
//!
//! * runs on **any** CPU of the target architecture — no illegal-instruction
//! (SIGILL) crashes on pre-AVX2 chips, unlike a static `-C target-cpu=…`;
//! * still uses wide vector units where the hardware has them.
//!
//! The hot loops accumulate into **independent lanes** before a final
//! horizontal combine. That is what lets the optimizer auto-vectorize them:
//! a plain sequential `iter().sum()` is a dependency chain LLVM may not
//! reorder (doing so would change floating-point rounding). As a consequence
//! these results differ from a strict left-to-right sum by a few ULPs — well
//! inside every indicator's documented tolerance.
/// Number of independent accumulator lanes. Eight `f64` lanes cover the
/// widest target we dispatch to (AVX-512 = 8×f64); narrower targets (AVX2,
/// NEON) simply use a subset.
#[cfg(feature = "simd")]
const LANES: usize = 8;
/// Sum of a slice of `f64`, runtime-dispatched.
#[cfg(feature = "simd")]
#[multiversion::multiversion(targets = "simd")]
pub(crate) fn sum(data: &[f64]) -> f64 {
let mut acc = [0.0f64; LANES];
let mut chunks = data.chunks_exact(LANES);
for chunk in &mut chunks {
for (a, &v) in acc.iter_mut().zip(chunk) {
*a += v;
}
}
let remainder: f64 = chunks.remainder().iter().sum();
remainder + acc.iter().sum::<f64>()
}
/// Pure-scalar fallback when the `simd` feature is disabled.
#[cfg(not(feature = "simd"))]
pub(crate) fn sum(data: &[f64]) -> f64 {
data.iter().sum()
}
/// Weighted-moving-average seed for the first window.
///
/// Returns `(t, s)` where `t = Σ data[k] * (k + 1)` (1-based linear weights)
/// and `s = Σ data[k]`. Used to seed the O(n) WMA recurrence.
#[cfg(feature = "simd")]
#[multiversion::multiversion(targets = "simd")]
pub(crate) fn wma_seed(data: &[f64]) -> (f64, f64) {
// Lane-local accumulation (same idea as `sum`) so each CPU-feature clone
// can vectorize: `t` weights each value by its 1-based global index.
let mut t_acc = [0.0f64; LANES];
let mut s_acc = [0.0f64; LANES];
let mut chunks = data.chunks_exact(LANES);
let mut base = 0.0f64; // global index of this chunk's first element
for chunk in &mut chunks {
for (lane, ((t, s), &v)) in t_acc
.iter_mut()
.zip(s_acc.iter_mut())
.zip(chunk)
.enumerate()
{
*t += v * (base + lane as f64 + 1.0);
*s += v;
}
base += LANES as f64;
}
let mut t = 0.0;
let mut s = 0.0;
for (i, &v) in chunks.remainder().iter().enumerate() {
t += v * (base + i as f64 + 1.0);
s += v;
}
(t + t_acc.iter().sum::<f64>(), s + s_acc.iter().sum::<f64>())
}
/// Pure-scalar fallback when the `simd` feature is disabled.
#[cfg(not(feature = "simd"))]
pub(crate) fn wma_seed(data: &[f64]) -> (f64, f64) {
let mut t = 0.0;
let mut s = 0.0;
for (k, &v) in data.iter().enumerate() {
t += v * (k + 1) as f64;
s += v;
}
(t, s)
}
#[cfg(test)]
mod tests {
use super::*;
/// Strict sequential reference — the ground truth we compare against.
fn naive_sum(data: &[f64]) -> f64 {
data.iter().sum()
}
fn naive_wma_seed(data: &[f64]) -> (f64, f64) {
let t = data
.iter()
.enumerate()
.map(|(k, &v)| v * (k + 1) as f64)
.sum();
let s = data.iter().sum();
(t, s)
}
/// Deterministic test vectors spanning the lane boundaries: empty, a
/// partial chunk (< LANES), an exact multiple, and an exact-multiple +
/// remainder. This exercises every branch of the chunked reduction.
fn cases() -> Vec<Vec<f64>> {
let big: Vec<f64> = (0..1000).map(|i| (i as f64) * 0.5 - 123.0).collect();
vec![
vec![],
vec![42.0],
vec![1.0, 2.0, 3.0], // < LANES
(1..=8).map(|i| i as f64).collect(), // exactly LANES
(1..=17).map(|i| i as f64).collect(), // LANES*2 + 1
big,
]
}
#[test]
fn sum_matches_sequential_within_tolerance() {
for data in cases() {
let got = sum(&data);
let want = naive_sum(&data);
assert!(
(got - want).abs() <= 1e-9 * want.abs().max(1.0),
"sum mismatch: got {got}, want {want}, len {}",
data.len()
);
}
}
#[test]
fn wma_seed_matches_sequential_within_tolerance() {
for data in cases() {
let (t, s) = wma_seed(&data);
let (wt, ws) = naive_wma_seed(&data);
assert!(
(t - wt).abs() <= 1e-9 * wt.abs().max(1.0),
"wma t mismatch: got {t}, want {wt}, len {}",
data.len()
);
assert!(
(s - ws).abs() <= 1e-9 * ws.abs().max(1.0),
"wma s mismatch: got {s}, want {ws}, len {}",
data.len()
);
}
}
#[test]
fn sum_empty_is_zero() {
assert_eq!(sum(&[]), 0.0);
}
}
+30
View File
@@ -449,6 +449,36 @@ mod tests {
assert!((d1 - d2).abs() < 1e-12);
}
#[test]
fn dtw_nan_in_input_propagates() {
// NaN in either input must propagate to the distance (IEEE 754 semantics).
let a = vec![1.0, 2.0, f64::NAN, 4.0];
let b = vec![1.0, 2.0, 3.0, 4.0];
assert!(dtw_distance(&a, &b, None).is_nan());
assert!(dtw_distance(&b, &a, None).is_nan());
}
#[test]
fn dtw_is_symmetric() {
let a = vec![1.0, 4.0, 2.0, 8.0, 3.0, 6.0, 5.0];
let b = vec![2.0, 3.0, 7.0, 4.0, 5.0, 1.0, 9.0];
let d_ab = dtw_distance(&a, &b, None);
let d_ba = dtw_distance(&b, &a, None);
assert!((d_ab - d_ba).abs() < 1e-12);
}
#[test]
fn dtw_path_length_bounded() {
// A valid warp path has length between max(n, m) and n + m - 1.
let a: Vec<f64> = (0..7).map(|x| x as f64).collect();
let b: Vec<f64> = (0..10).map(|x| (x as f64).sin()).collect();
let (_, path) = dtw_path(&a, &b, None);
let n = a.len();
let m = b.len();
assert!(path.len() >= n.max(m));
assert!(path.len() <= n + m - 1);
}
#[test]
fn dtw_window_constrained_ge_unconstrained() {
// window convention matches dtaidistance: Some(w) means |i-j| < w.