burn-ndarray: relu and clamp drop NaN once a tensor is long enough to vectorize
Describe the bug
On burn-ndarray, relu, clamp_min, clamp_max and clamp replace NaN with the bound
as soon as a tensor is long enough to take the SIMD path. Short tensors fall through to
the scalar comparison below it and keep NaN, so the result depends on tensor length.
This is the same class of bug as #5609 and #5659, but it reaches further: relu is
affected, not just clamp, and any tensor of a realistic size is on the broken path.
To Reproduce
One NaN at index 0, every other element 2.0, --features ndarray:
LEN 3 clamp_min(0)=NaN clamp_max(1)=NaN clamp(0,1)=NaN relu=NaN
LEN 8 clamp_min(0)=NaN clamp_max(1)=NaN clamp(0,1)=NaN relu=NaN
LEN 16 clamp_min(0)=NaN clamp_max(1)=NaN clamp(0,1)=NaN relu=NaN
LEN 64 clamp_min(0)=0.0 clamp_max(1)=1.0 clamp(0,1)=1.0 relu=0.0
LEN 1024 clamp_min(0)=0.0 clamp_max(1)=1.0 clamp(0,1)=1.0 relu=0.0The switch is wherever should_use_simd starts returning true, between 16 and 64 f32
elements here.
Analysis
dispatch_binary_scalar_simd! returns early when a SIMD implementation exists
(Ok(out) => return out), so the mapv_inplace comparison underneath it never runs for
f32 or f64. macerator lowers VecMax, VecMin and VecClamp to the hardware max, min
and clamp, which return the non-NaN operand. relu goes through
NdArrayMathOps::clamp_min, so it is hit as well.
crates/burn-ndarray/src/ops/base.rs:1357 (clamp_min), :1383 (clamp_max), :1409
(clamp), crates/burn-ndarray/src/ops/simd/binary_elemwise.rs:119 onwards.
Worth correcting: the backend table in #5658 lists ndarray as passing for relu,
clamp_min and clamp_max. That measurement used three-element tensors and only
exercised the scalar path. The assertions added there have the same limitation.
Expected behavior
NaN survives regardless of tensor length, matching PyTorch and the scalar path.
Candidate fixes
Put the bound first in the vector ops. The hardware instruction returns its second operand when either is NaN, so
splat.max(data)keeps the element wheredata.max(splat)does not. Verified against macerator directly on x86:data.max(splat) lane0 = 0.0 nan_kept=false splat.max(data) lane0 = NaN nan_kept=trueThe scalar tail still needs its own comparison, since
f32::maxignores NaN in either order. Free, but it depends on behaviour macerator does not promise:VOrd::vmaxis documented asOrd::max, which says nothing about NaN for floats. ARM FMAX and wasmf32x4.maxpropagate NaN in both orders, so the swap is neutral there, but only x86 has been checked here.Drop f32 and f64 from the SIMD dispatch list so floats take the comparison path. Depends on nothing beyond the comparison operators, but it costs real throughput.
Ask macerator for a NaN-propagating min/max, or a select for floats so a blend can be written here. Cleanest, since the semantics would then be documented rather than inferred, but it needs an upstream release and a version bump.
Option 1 is implemented in https://github.com/Liberxue/burn/tree/fix/ndarray-vectorized-clamp-nan; option 2 was measured and discarded. Happy to switch to whichever the maintainers prefer, or to open the macerator issue for option 3.
Measurements
4M f32, best of 7 runs of 20 iterations, Ubuntu 26.04, Xeon E5-2686 v4. Baseline drifted under 1% between the first and last group.
| op | current (NaN dropped) | option 1 (bound first) | option 2 (no SIMD for floats) |
|---|---|---|---|
relu |
1.60 ms | 1.78 ms | 3.44 ms (2.2x) |
clamp_min |
1.57 ms | 1.62 ms | 3.40 ms (2.2x) |
clamp |
1.57 ms | 1.69 ms | 4.39 ms (2.8x) |
Option 1 sits inside the run-to-run spread of the baseline.
Desktop
- Behaviour: macOS 15.7.9, x86_64, Core i7-8559U, rustc 1.96.0
- Measurements: Ubuntu 26.04 LTS, x86_64, Xeon E5-2686 v4, rustc 1.97.1
- burn: 52c101f45
Additional context
Any regression test for this has to use a tensor long enough to vectorize. Three elements pass on the broken code.
Tensor-tensor min/max in ops/simd/binary.rs look like they have the same split
between the vector path and the scalar tail. Not investigated, and left alone by the fix
above, which adds separate NaN-propagating methods rather than changing MinMax::min
and MinMax::max.
Source: tracel-ai/burn