Repository navigation
fix(core)!: make casts exact at integer and float range limits - #36
Conversation
Fix seven correctness bugs found by auditing against the zarrs cast_value implementation: - float->int: reject +/-Inf in every out_of_range mode (scalar and SIMD kernels), as the spec requires unless scalar_map covers it - float->int: compare against an exact exclusive upper bound, so values at 2^31/2^63/2^64 are no longer saturated silently - float->int wrap: reduce modulo 2^N exactly via i64 bits or rem_euclid(2^64) instead of the rounding (val - lo) arithmetic - nearest-away: use round() instead of floor(|x| + 0.5) - int->float: round the integer's bits directly to the target precision, instead of comparing against an already-rounded f64 - float->float: treat a value whose rounded magnitude exceeds the finite range as out of range in every rounding mode (clamp -> +/-Inf) - float16: convert f64 directly to f16 (single rounding), and range-check int->f16 casts BREAKING CHANGE: IntToFloatConfig gains an `out_of_range` field; CastInt now requires num_traits::FromPrimitive; +/-Inf -> int with clamp now errors instead of clamping. Assisted-by: ClaudeCode:claude-opus-5-5
Merging this PR will improve performance by 15.9%1 warning
Performance Changes
Tip Curious why performance improved? Comment Comparing Footnotes
|
- reject src/dst length mismatches in every slice function with CastError::LengthMismatch; the SIMD kernels wrote past a short `dst` through raw pointers (undefined behaviour reachable from safe code) - fix nearest-even ties in the wasm32 clamp kernels' scalar tails, which rounded half away from zero - report float-target overflow as the new CastError::FloatOutOfRange (no misleading `wrap` hint); OutOfRange now carries the integer target's exact i128 bounds and the unrounded input - make the f64->i32 check kernels hand off to the scalar path on the first bad vector, so errors match the scalar path in variant and order - remove the now-unused CastFloat::rem_euclid - group the Python binding helpers' shared arguments into CastOptions (fixes clippy::too_many_arguments) - fix stale docs and Python docstrings; add tests for length mismatch, error bounds, SIMD-vs-scalar agreement, the generic kernels, and int->float16 overflow in the Python `into` variant BREAKING CHANGE: CastError gains FloatOutOfRange and LengthMismatch, and OutOfRange's lo/hi are now i128; CastFloat::rem_euclid is removed. Assisted-by: ClaudeCode:claude-opus-5-5
CodSpeed flagged i64->f32 (-29%) and f64->f32 (-10%) towards-zero after the exact-cast fixes. Recover it by: - hoisting the rounding-mode match out of the int->float and float->float slice loops, so each loop is specialised to one mode - skipping int->float directed rounding when the integer's significant bit span fits the target significand (the `as` cast is then exact) - skipping the int->float overflow test for targets an integer cannot overflow (constant-folded; only f16 keeps it) - moving the float->float exact overflow decision into a cold function behind a branch-free infinity test Assisted-by: ClaudeCode:claude-opus-5-5
CodSpeed still measured i64->f32 towards-zero 16% slower: the leading/trailing-zero span test costs several instructions on x86. Detect exactness the way the original code did, comparing the integer and the nearest-even result in f64, but only trust it while the f64 value is below 2^53 (always, for sources of at most 53 bits), where f64 holds the integer exactly. The exact-value loop body is now one instruction shorter than on main (aarch64 asm: 11 vs 12). Assisted-by: ClaudeCode:claude-opus-5-5
🤖 AI text below 🤖
This PR fixes seven
cast_valuecorrectness bugs where this crate disagreed with the spec and with the zarrs implementation, and two older bugs that an adversarial review turned up. Casts at integer and float range limits, directed rounding of wide integers, and float16 conversions are now exact, and the slice functions no longer write out of bounds.Correctness fixes (first commit)
clampwas clamped; the spec requires an error unlessscalar_mapcovers it. Fixed in the scalar path and every SIMD kernel (NaN check is now a NaN/Inf check viax - x).+Inf→u8 clamp:255→ errori32::MAX as f32is 2³¹), so values at 2³¹/2⁶³/2⁶⁴ were silently saturated. The check now useshi + 1, which is exactly 2^k in every float type.out_of_range):i32::MAX→ error; f64 2⁶⁴→u64 wrap:u64::MAX→0wrapcomputed(val - lo).rem_euclid(range) + loin the source float type, which rounds for large values. It now uses thei64bit pattern, orrem_euclid(2^64)beyond the i64 domain, both exact.nearest-awayusedfloor(|x|+0.5), which fails on 0.49999999999999994 and on odd integers above 2⁵² (2²³ for f32). It now usesround().0.49999999999999994→1→0val as f64, which is already rounded. The integer's bits are now rounded directly to the target precision.clampgives ±Inf, no mode gives an error; values that round onto MAX still return MAX.MAX→inffloat16: f64→f16 went through f32 and rounded twice; int→f16 had no overflow check.1.0→1.0009766; i32 70000→f16:inf→ errorReview follow-ups (second commit)
Four adversarial reviewers each checked one area against an independent exact reference model: every f16 bit pattern, all 2³² f32→f16 casts, and about 10M f64→f32 edge points. They found no mismatch in the new numerics. The second commit fixes what they did find:
src.len() == dst.len(), and the SIMD kernels writedstthrough raw pointers sized bysrc, so a shortdstwas undefined behaviour from safe code. All four slice functions now returnCastError::LengthMismatchbefore writing.CastError::FloatOutOfRange, without thewraphint that the spec forbids for floats.OutOfRangereports the integer target's exact bounds and the unrounded input, instead of messages likeValue 2147483648 out of range [-2147483648, 2147483648].out_of_rangeis unset hands off to the scalar path at the first bad vector.[3e9, inf]now reports the out-of-range3e9, as the scalar path does.CastFloat::rem_euclid, fixed stale docs and Python docstrings, and grouped the binding helpers' arguments intoCastOptions(it failedclippy::too_many_arguments).Performance (third commit)
CodSpeed flagged
i64→f32(−29%) andf64→f32(−10%) towards-zero after the exactness fixes. The third commit recovers this:In an interleaved A/B harness on aarch64, all affected cases are now within run-to-run noise of
main.Breaking changes
IntToFloatConfiggains anout_of_rangefield.CastIntnow requiresnum_traits::FromPrimitive.CastErrorgainsFloatOutOfRangeandLengthMismatch, andOutOfRange'slo/hiare nowi128.CastFloat::rem_euclidis removed.CastFloat::round_with_mode(NearestAway)output changes for f32/f64 (fix 4).clampnow errors instead of clamping.Testing
just test-rustpasses (50 tests by default, 65 withfloat16).just fmt-check,just lint, thefloat16clippy run and binding-crate clippy are all clean.just test-pythonpasses (156 tests).Release and bookkeeping notes
wrapis not rejected up front for float targets; f16next_up(+Inf)returns NaN (no cast reaches it);dstcontents after an error differ between SIMD and scalar paths;scalar_mapmatches-0.0against a0.0key (spec is ambiguous).needless_returninsimd/mod.rs, and two clippy errors inexamples/zarrs_integration.rs.🤖 Generated with Claude Code