Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .claude/plans/d-onnx-native-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -117,6 +119,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.
Expand Down
33 changes: 27 additions & 6 deletions crates/lance-graph-onnx/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
//!
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -194,9 +215,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<T: Ieee, S: Data<Elem = T>, D: Dimension>(a: &ArrayBase<S, D>) -> Array<T, D> {
a.map(|&v| -v)
Expand Down
124 changes: 113 additions & 11 deletions crates/lance-graph-onnx/tests/conformance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<f32>::zeros(16);
let mut out = Array1::<f32>::zeros(16);
fn vml_vsneg_matches_onnx_neg_on_positive_zero() {
let x = Array1::<f32>::zeros(17);
let mut out = Array1::<f32>::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]
Expand Down Expand Up @@ -111,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<f32>, ndarray::ArrayViewMut1<f32>),
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::<f32>::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::<f32>::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::<f32>::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.
Expand Down
Loading