Integration: make Oil Paint's round_i32 exact for every f32 (#8556)

round_i32 added 0.5 in f32, which rounds up at the binade crossing:
0.49999997 (0.5 - 2^-25) + 0.5 becomes 1.0, so round_i32 gave 1 (and -1 for
the negative) where f32::round gives 0. The sum is now taken in f64, where
it is exact for every f32 the i32 cast does not saturate; still no roundf call.

rounding_matches_the_standard_library now covers +-0.49999997, the ties and
8388607.5, and a new test compares every f32 bit pattern in [-4096, 4096]
against f32::round (0.6 s). Both fail on the previous head. The code comment
and docs/oil-paint-performance.md no longer claim the f32 sum is exact.

Co-authored-by: ebosido444-ai <316167330+ebosido444-ai@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Mann
2026-10-22 21:31:24 +02:00
co-authored by ebosido444-ai Claude Opus 5.5
parent c58c0412fb
commit 249eae743f
2 changed files with 42 additions and 7 deletions
+36 -3
View File
@@ -31,11 +31,14 @@ fn stroke_len(stylization: f32) -> f32 {
}
/// `f32::round` (half away from zero) as an integer, without the `roundf` call `f32::round`
/// makes on the baseline x86-64 target. Exact for `|v| < 2^22`, which covers every window
/// coordinate; larger magnitudes saturate like the cast the caller used to perform.
/// makes on the baseline x86-64 target. The sum is taken in `f64`, where `v ± 0.5` is exact for
/// every `f32` the cast does not saturate (an `f32` sum is not: `0.49999997 + 0.5` rounds up to
/// `1.0`), so the result equals `v.round() as i32` for every `f32`, NaN (0) and the saturating
/// ends included.
#[inline]
fn round_i32(v: f32) -> i32 {
(v + 0.5f32.copysign(v)) as i32
let v = f64::from(v);
(v + 0.5f64.copysign(v)) as i32
}
/// [`round_i32`] clamped into `0..n`.
@@ -231,10 +234,40 @@ mod tests {
}
v += 0.25;
}
// The binade crossings where an `f32` sum `v + 0.5` rounds up (0.5 - 2^-25), the ties, and
// the largest non-integer `f32`.
let below_half = f32::from_bits(0x3eff_ffff);
for v in [below_half, -below_half, 0.5, -0.5, 1.5, -1.5, 2.5, -2.5, 8_388_607.5, -8_388_607.5, 8_388_608.0, 3.0e9, -3.0e9] {
assert_eq!(round_i32(v), v.round() as i32, "round_i32({v:e})");
for n in [1usize, 2, 33] {
let want = (v.round().clamp(0.0, (n - 1) as f32)) as usize;
assert_eq!(round_index(v, n), want, "round_index({v:e}, {n})");
}
}
assert_eq!(round_i32(f32::NAN), f32::NAN.round() as i32);
for n in [1usize, 8] {
assert_eq!(round_index(f32::NEG_INFINITY, n), (f32::NEG_INFINITY.clamp(0.0, (n - 1) as f32)) as usize);
assert_eq!(round_index(f32::INFINITY, n), ((n - 1) as f32) as usize);
}
}
/// Every `f32` bit pattern in [-4096, 4096] (both signs, about 2.3e9 values, split across
/// threads) rounds exactly like `f32::round`.
#[test]
fn rounding_matches_the_standard_library_for_every_f32_up_to_4096() {
use rayon::prelude::*;
let last = 4096.0f32.to_bits();
let bad = (0..=last >> 20)
.into_par_iter()
.filter_map(|hi| {
let lo = hi << 20;
let hi_end = (lo | 0xf_ffff).min(last);
(lo..=hi_end).find_map(|bits| {
let v = f32::from_bits(bits);
[v, -v].into_iter().find(|v| round_i32(*v) != v.round() as i32)
})
})
.min_by(|a, b| a.abs().total_cmp(&b.abs()));
assert_eq!(bad, None, "round_i32 differs from f32::round");
}
}
+6 -4
View File
@@ -12,10 +12,12 @@ filter's time went.
- **`sample()`:** `x.floor() as usize` became `x as usize`. The two arguments are clamped to
`0 ..= w-1` on the lines above, so truncation is floor for every input the function can see
(NaN truncates to 0 either way, which is the same sample the old code read).
- **Tangent and bristle lookups:** `round` became `round_i32(v) = (v + 0.5 * sign(v)) as i32`.
`f32::round` is round-half-away-from-zero, which is truncation of `v ± 0.5`; that sum is exact
in `f32` for `|v| < 2^22` (the spacing there is at most 0.5), and every coordinate is inside a
tile window, so the integer is identical. `round_index` adds the `clamp(0, n-1)` the tangent
- **Tangent and bristle lookups:** `round` became `round_i32(v) = (v as f64 + 0.5 * sign(v)) as i32`.
`f32::round` is round-half-away-from-zero, which is truncation of `v ± 0.5` when that sum is
exact. In `f32` it is not always: `0.49999997 + 0.5` (`0.5 - 2^-25`) rounds up to `1.0`, so an
`f32` sum gives 1 where `f32::round` gives 0. In `f64` the sum is exact for every `f32` the
`i32` cast does not saturate, so the integer is identical for every input; a unit test checks
every `f32` bit pattern in [-4096, 4096] against `f32::round`. `round_index` adds the `clamp(0, n-1)` the tangent
lookup already applied; NaN casts to 0 and ±∞ casts to an end of the clamp range in both
versions, so the clamped results match for every input, including non-finite ones.