From c5d8ef21688b050ced3a995b313125c86911c7e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 06:53:57 +0000 Subject: [PATCH 1/2] lance-graph-onnx: replace the vsneg tripwire now that ndarray #368 fixed it vml_vsneg_is_not_onnx_neg_on_positive_zero pinned vsneg(+0) == +0 so it would fail once ndarray fixed the sign of zero. ndarray #368 did, and the test fails on main (Rust Tests run 40047762268). The replacement pins that vsneg and Neg both give -0.0 for +0.0 in the SIMD body and the tail. Neg stays inline: vsneg is f32-only and writes into a view. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- .claude/plans/d-onnx-native-v1.md | 1 + crates/lance-graph-onnx/src/lib.rs | 6 ++--- crates/lance-graph-onnx/tests/conformance.rs | 23 ++++++++++---------- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/.claude/plans/d-onnx-native-v1.md b/.claude/plans/d-onnx-native-v1.md index 18f582320..301b6f350 100644 --- a/.claude/plans/d-onnx-native-v1.md +++ b/.claude/plans/d-onnx-native-v1.md @@ -117,6 +117,7 @@ The old 50 ns target does not apply to aliases. An alias's cost is the native fu ## 6. Proposed (not implemented) - An ndarray owner fix for `vml::vsneg` (+0) and `vml::vsexp` (±inf, overflow halving): AdaWorldAPI/ndarray#368, open. After it lands, `Neg` could become an ALIAS. + - ⊘ 2026-10-24: #368 merged. The tripwire `vml_vsneg_is_not_onnx_neg_on_positive_zero` failed as designed and was replaced by `vml_vsneg_matches_onnx_neg_on_positive_zero` (lance-graph#1757). `Neg` stays INLINE for now: `vsneg` is f32-only and writes into a view, while `neg` is generic over f32/f64. - A native unmasked `i32 → i64` sum in `ndarray::simd`, so that `ReduceSum::sum_i32` becomes a direct call. - The NATIVE rows in §2, each with its open decision named. - `#1752` `onnx_compat` stays as the independent probe. This crate is the facade, and #1752's measurements are kept as the diagnostic baseline. diff --git a/crates/lance-graph-onnx/src/lib.rs b/crates/lance-graph-onnx/src/lib.rs index b9a313f87..bf99774e9 100644 --- a/crates/lance-graph-onnx/src/lib.rs +++ b/crates/lance-graph-onnx/src/lib.rs @@ -194,9 +194,9 @@ binary!( /// `Neg`. INLINE: `ndarray`'s element map with `-v`, the same body as its /// `Neg for &ArrayRef` impl. /// -/// Not an alias of `ndarray::hpc::vml::vsneg`: its 16-lane body computes -/// `0.0 - v`, so `Neg(+0.0)` gives `+0.0` instead of `-0.0` whenever the input -/// has at least 16 elements (its scalar tail computes `-v` and is correct). +/// Not an alias of `ndarray::hpc::vml::vsneg`, which is `f32`-only and writes +/// into an output view. Both give `-0.0` for `+0.0` (vsneg since ndarray +/// #368; pinned by the conformance test). #[inline] pub fn neg, D: Dimension>(a: &ArrayBase) -> Array { a.map(|&v| -v) diff --git a/crates/lance-graph-onnx/tests/conformance.rs b/crates/lance-graph-onnx/tests/conformance.rs index e4a004080..3fd9b6e57 100644 --- a/crates/lance-graph-onnx/tests/conformance.rs +++ b/crates/lance-graph-onnx/tests/conformance.rs @@ -71,19 +71,20 @@ fn unary_aliases_are_exact_on_special_values_and_every_tail_length() { } /// The aliases are the native items, not wrappers around them. -/// Why `Neg` is not an alias: the vml kernel's SIMD body loses the sign of -/// zero. If ndarray fixes it, this test fails and `Neg` can become an alias. +/// `vsneg` used to lose the sign of zero in its SIMD body, which is why `Neg` +/// is not an alias. ndarray #368 fixed it; this test now pins that `vsneg` +/// and `Neg` agree on +0 in the SIMD body and the tail. Making `Neg` an +/// alias is a separate change: `vsneg` is `f32`-only and writes into a view. #[test] -fn vml_vsneg_is_not_onnx_neg_on_positive_zero() { - let x = Array1::::zeros(16); - let mut out = Array1::::zeros(16); +fn vml_vsneg_matches_onnx_neg_on_positive_zero() { + let x = Array1::::zeros(17); + let mut out = Array1::::zeros(17); ndarray::hpc::vml::vsneg(x.view(), out.view_mut()); - assert_eq!( - out[0].to_bits(), - 0.0f32.to_bits(), - "vsneg(+0) is +0 on the SIMD path" - ); - assert_eq!(onnx::neg(&x)[0].to_bits(), (-0.0f32).to_bits()); + let y = onnx::neg(&x); + for i in [0, 16] { + assert_eq!(out[i].to_bits(), (-0.0f32).to_bits(), "vsneg(+0) at {i}"); + assert_eq!(y[i].to_bits(), (-0.0f32).to_bits(), "Neg(+0) at {i}"); + } } #[test] From 4b3685e86c18f57a70bba66688953e0dbcc134b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 09:14:33 +0000 Subject: [PATCH 2/2] lance-graph-onnx: Tanh and Exp as ULP-bounded aliases of vstanh / vsexp ndarray #368 and #369 bring vml::vsexp and vml::vstanh within 1 ULP of the correctly rounded result with exact special values. They are re-exported under onnx::bounded, not at the crate root, so the root stays exact-only and a caller cannot pick up a bounded operator by accident. The crate doc's exact-versus-approximate note now says that, and names Log and Sigmoid as still unexported. Conformance adds: alias identity for both, special values and every tail length 0..70 (bit-exact where the result is a zero, infinity or NaN, else <= 2 ULP of f64 rounded once), a log-spaced tanh sweep from 1e-44 to 1e4 with oddness and the [-1, 1] bound, and exp across [-104, 90] including subnormal results and overflow. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- .claude/plans/d-onnx-native-v1.md | 2 + crates/lance-graph-onnx/src/lib.rs | 27 ++++- crates/lance-graph-onnx/tests/conformance.rs | 101 +++++++++++++++++++ 3 files changed, 127 insertions(+), 3 deletions(-) diff --git a/.claude/plans/d-onnx-native-v1.md b/.claude/plans/d-onnx-native-v1.md index 301b6f350..5076ac7e7 100644 --- a/.claude/plans/d-onnx-native-v1.md +++ b/.claude/plans/d-onnx-native-v1.md @@ -60,6 +60,8 @@ Every ALIAS and INLINE row was checked against the native source. The checks cov | `ReduceMin`/`ReduceMax` of a masked population | `masked_min_i32`/`masked_max_i32` | the empty-population result is undecided (ONNX ReduceMin over an empty set is ±inf for floats and undefined for ints) | | comparisons on u64 | `{lt,le,gt,ge}_u64_to_mask` | ONNX has no unsigned-64 compare-with-scalar distinct from int64; needs a dtype decision | +> ⊘ 2026-10-24: ndarray #368 and #369 merged. `Tanh` and `Exp` are now ALIAS in the named `bounded` module (`onnx::bounded::{tanh_f32, exp_f32}`, the native `vml::vstanh` / `vml::vsexp`): ≤ 2 ULP gated, 1 ULP measured, special values exact. `Log` and `Sigmoid` stay out until their kernels have a measured bound. + ### REFUSE | ONNX | reason | diff --git a/crates/lance-graph-onnx/src/lib.rs b/crates/lance-graph-onnx/src/lib.rs index bf99774e9..865723fd3 100644 --- a/crates/lance-graph-onnx/src/lib.rs +++ b/crates/lance-graph-onnx/src/lib.rs @@ -31,9 +31,12 @@ //! element `i`, and bits past the last element are zero. That is the //! representation of every `ndarray::simd` mask kernel. Use [`mask_words`] //! to size one. -//! - **Exact versus approximate**: every operator here is exact (IEEE-754 for -//! floats, exact integers). Polynomial `Exp`/`Log`/`Tanh`/`Sigmoid` and -//! palette operations are approximate and are not exported. +//! - **Exact versus bounded**: every operator at the crate root is exact +//! (IEEE-754 for floats, exact integers). Transcendental operators whose +//! native kernel has a measured ULP bound live in [`bounded`], never at the +//! root, so a caller who needs exact results cannot pick one up by accident. +//! `Log`, `Sigmoid` and palette operations have no measured bound yet and +//! are not exported. //! //! # Example //! @@ -62,6 +65,24 @@ pub use ndarray::hpc::vml::vsabs as abs_f32; /// `Sqrt` (`float`). Alias of `ndarray::hpc::vml::vssqrt`. pub use ndarray::hpc::vml::vssqrt as sqrt_f32; +/// ALIAS operators whose native kernel is within a measured ULP bound of the +/// correctly rounded result, rather than exact. +/// +/// The bound is relative to `f64` evaluation rounded once to `f32`. Special +/// values are exact: `±0` keeps its sign, `NaN` stays `NaN`, and infinities +/// map to the ONNX limits. The bounds are gated in ndarray's own tests (≤ 2 +/// ULP) and re-checked here; the measured maximum is 1 ULP. +pub mod bounded { + /// `Exp` (`float`). Alias of `ndarray::hpc::vml::vsexp`. At most 1 ULP on + /// `[-104, 89]`, subnormal results included; `exp(+inf) = +inf`, + /// `exp(-inf) = 0`, and results past the `f32` range saturate to `+inf`. + pub use ndarray::hpc::vml::vsexp as exp_f32; + /// `Tanh` (`float`). Alias of `ndarray::hpc::vml::vstanh`. At most 1 ULP + /// over all 2³² `f32` inputs (exhaustive, AVX-512; sampled on AVX2, the + /// x86-64 baseline and NEON), odd, monotone and within `[-1, 1]`. + pub use ndarray::hpc::vml::vstanh as tanh_f32; +} + /// `Equal(X, T)`, scalar `T`. Alias of `ndarray::simd::eq_i32_to_mask`. pub use ndarray::simd::eq_i32_to_mask as equal_i32; /// `GreaterOrEqual(X, T)`, scalar `T`. Alias of `ndarray::simd::ge_i32_to_mask`. diff --git a/crates/lance-graph-onnx/tests/conformance.rs b/crates/lance-graph-onnx/tests/conformance.rs index 3fd9b6e57..2bc6b0162 100644 --- a/crates/lance-graph-onnx/tests/conformance.rs +++ b/crates/lance-graph-onnx/tests/conformance.rs @@ -112,6 +112,107 @@ fn aliases_are_the_native_symbols() { onnx::abs_f32 as Un, ndarray::hpc::vml::vsabs as Un )); + assert!(std::ptr::fn_addr_eq( + onnx::bounded::tanh_f32 as Un, + ndarray::hpc::vml::vstanh as Un + )); + assert!(std::ptr::fn_addr_eq( + onnx::bounded::exp_f32 as Un, + ndarray::hpc::vml::vsexp as Un + )); +} + +/// ULP distance on the total order of `f32`, with `-0` and `+0` one apart. +fn ulp_distance(a: f32, b: f32) -> u32 { + let key = |x: f32| { + let i = x.to_bits() as i32; + if i < 0 { + i32::MIN - i + } else { + i + } + }; + (key(a) as i64 - key(b) as i64).unsigned_abs() as u32 +} + +/// The ONNX reference for a bounded operator: `f64` evaluation rounded once. +/// Where that result is a zero, an infinity or NaN, or the input is a zero, +/// the operator must match it bit for bit; elsewhere within 2 ULP. +fn check_bounded(name: &str, x: f32, got: f32, want: f32) { + if x == 0.0 || want == 0.0 || want.is_infinite() || want.is_nan() { + assert!( + same(got, want), + "{name}({x:e}) = {got:e}, want exactly {want:e}" + ); + } else { + let u = ulp_distance(got, want); + assert!(u <= 2, "{name}({x:e}) = {got:e}, want {want:e} ({u} ULP)"); + } +} + +#[test] +fn bounded_aliases_hold_their_ulp_bound_on_special_values_and_every_tail_length() { + let cases: [( + &str, + fn(ndarray::ArrayView1, ndarray::ArrayViewMut1), + fn(f64) -> f64, + ); 2] = [ + ("Tanh", onnx::bounded::tanh_f32, f64::tanh), + ("Exp", onnx::bounded::exp_f32, f64::exp), + ]; + for n in 0..70 { + let x = Array1::from(samples(n)); + let mut out = Array1::::zeros(n); + for (name, op, reference) in cases { + op(x.view(), out.view_mut()); + for i in 0..n { + check_bounded(name, x[i], out[i], reference(x[i] as f64) as f32); + } + } + } +} + +#[test] +fn bounded_aliases_hold_their_ulp_bound_across_magnitudes() { + // Log-spaced magnitudes from subnormal to 1e4, both signs; the tanh small + // branch, its 0.625 switch and saturation, and exp's subnormal and + // overflow edges all fall inside. + let n = 1 << 16; + let x = Array1::from_iter((0..2 * n).map(|i| { + let t = (i % n) as f64 / n as f64; + let m = (10f64.powf(-44.0 + 48.0 * t)) as f32; + if i < n { + m + } else { + -m + } + })); + let mut out = Array1::::zeros(x.len()); + onnx::bounded::tanh_f32(x.view(), out.view_mut()); + for i in 0..x.len() { + check_bounded("Tanh", x[i], out[i], (x[i] as f64).tanh() as f32); + assert!( + out[i].abs() <= 1.0, + "Tanh({:e}) = {:e} outside [-1, 1]", + x[i], + out[i] + ); + if i >= n { + assert_eq!( + out[i].to_bits(), + (-out[i - n]).to_bits(), + "Tanh is odd at {:e}", + x[i] + ); + } + } + // Exp over its full f32 range, including subnormal results and overflow. + let e = Array1::from_iter((0..n).map(|i| -104.0 + 194.0 * (i as f32 + 0.5) / n as f32)); + let mut out = Array1::::zeros(e.len()); + onnx::bounded::exp_f32(e.view(), out.view_mut()); + for i in 0..e.len() { + check_bounded("Exp", e[i], out[i], (e[i] as f64).exp() as f32); + } } /// Reference ONNX broadcasting: right-aligned, a 1 stretches.