From 3771318675040e1ac3f1ad9c8ccb5ef21f261763 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 14:14:07 +0000 Subject: [PATCH] codec8: keep ties-away encode vector on wasm The codegen check for phase (b) of .claude/plans/quantized-codec8-v2.md found one arm that leaves the vector unit: on wasm32+simd128, f32::round (round half away) has no vector instruction, so the TFLite encode loop made four roundf calls per f32x4. x86 v3/v4 and aarch64 were already fully vector and generic == hand-written. TiesAway::round now uses trunc plus a half test on wasm32 only (cfg, no runtime dispatch); other targets keep f32::round, so their assembly is unchanged (scripts/codec8_asm_diff.sh: v3 283/295, v4 278/295, IDENTICAL). On wasm the loop is now f32x4.trunc/sub/abs/ge/bitselect, zero roundf calls. Equality of the trunc form with f32::round: 0 mismatches over all 2^32 bit patterns natively, and over 2^32 - 1 run in node's wasm engine (simd128 build). A strided unit test plus half-integer neighbourhoods keeps it pinned on every target. No new SIMD primitives: every other arm is already vectorised by LLVM. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- examples/codec8_asm_probe.rs | 15 ++++++++- src/hpc/codec8.rs | 59 +++++++++++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/examples/codec8_asm_probe.rs b/examples/codec8_asm_probe.rs index 0161030e..0d1946ea 100644 --- a/examples/codec8_asm_probe.rs +++ b/examples/codec8_asm_probe.rs @@ -69,7 +69,20 @@ pub fn d_tflite_act(x: &[f32], o: &mut [i8]) -> Result<(), Codec8Error> { return Err(Codec8Error::NanInput { index }); } for (o, &v) in o.iter_mut().zip(x) { - *o = ((v / Q::SCALE).round().clamp(-128.0, 127.0) as i32) as i8; + let x = v / Q::SCALE; + // The trunc form on wasm, as `TiesAway::round` does there. + #[cfg(target_arch = "wasm32")] + let r = { + let t = x.trunc(); + if (x - t).abs() >= 0.5 { + t + 1.0f32.copysign(x) + } else { + t + } + }; + #[cfg(not(target_arch = "wasm32"))] + let r = x.round(); + *o = (r.clamp(-128.0, 127.0) as i32) as i8; } Ok(()) } diff --git a/src/hpc/codec8.rs b/src/hpc/codec8.rs index 941a8fa0..de81613b 100644 --- a/src/hpc/codec8.rs +++ b/src/hpc/codec8.rs @@ -225,10 +225,33 @@ impl sealed::Sealed for TiesAway {} impl sealed::Sealed for TiesEven {} impl sealed::Sealed for Floor {} +/// Round half away from zero through `trunc`. Bit-identical to `f32::round` for every +/// `f32` (checked exhaustively over all 2^32 bit patterns, NaN payloads aside). +#[inline(always)] +fn round_half_away_trunc(x: f32) -> f32 { + let t = x.trunc(); + // `x - trunc(x)` is exact in f32, so this compares the true fraction with one half. + if (x - t).abs() >= 0.5 { + t + 1.0f32.copysign(x) + } else { + t + } +} + impl Rounding for TiesAway { + /// wasm has a vector truncate (`f32x4.trunc`) but no vector round-half-away, so there + /// `f32::round` becomes one `roundf` call per lane and the encode loop leaves the + /// vector unit. x86 and aarch64 lower `f32::round` to vector code already. #[inline(always)] fn round(x: f32) -> f32 { - x.round() + #[cfg(target_arch = "wasm32")] + { + round_half_away_trunc(x) + } + #[cfg(not(target_arch = "wasm32"))] + { + x.round() + } } #[inline(always)] fn div_round(n: u128, d: u128) -> u128 { @@ -683,6 +706,40 @@ mod tests { } // G2: TfliteAct with the exact fitted scale reproduces `quantize_f32_to_i8`. + #[test] + fn trunc_form_of_ties_away_matches_f32_round() { + // The whole f32 space is covered by an exhaustive run outside the test suite; here a + // stride over every exponent, plus the neighbourhood of each half-integer up to 2^24. + let same = |x: f32| { + let (a, r) = (round_half_away_trunc(x), x.round()); + assert!(a.to_bits() == r.to_bits() || (a.is_nan() && r.is_nan()), "{x:e}: {a:e} vs {r:e}"); + }; + for b in (0..=u32::MAX).step_by(65_521) { + same(f32::from_bits(b)); + } + for k in 0..(1u32 << 12) { + let h = k as f32 + 0.5; + for x in [h, -h, h.next_up(), h.next_down(), -h.next_up(), -h.next_down()] { + same(x); + } + } + for x in [ + 0.0, + -0.0, + 0.49999997, + -0.49999997, + 8388607.5, + 8388608.0, + f32::MAX, + f32::MIN, + f32::INFINITY, + f32::NEG_INFINITY, + f32::NAN, + ] { + same(x); + } + } + #[test] fn div_round_does_not_overflow_on_large_operands() { let big = 1u128 << 127;