From 7ed4b9e0ef54cb1c37c4f000e3fceb5c9ab80868 Mon Sep 17 00:00:00 2001 From: ebosido444-ai Date: Thu, 22 Oct 2026 16:24:04 +0300 Subject: [PATCH 1/2] Oil Paint: round sample coordinates without the libm call The line integral convolution takes 21 bilinear samples and 20 tangent lookups per output pixel, and every one of those computed its coordinate with `f32::floor` or `f32::round`. On the baseline x86-64 target both lower to library calls (`jmp floorf` and `jmp roundf` in `rustc --emit asm`), so the filter spent most of its time in 82 call-and-return pairs per pixel. `sample()` now truncates the coordinate it already clamped to the window, and the tangent and bristle lookups use `round_i32`/`round_index`, which produce the same integer for every position a tile can produce. The output is bit-identical: six 600x400 fixtures (default, short, long, unlit, RGB without alpha, CMYK) compare sample for sample with `cmp` and a per-channel maximum delta of 0.0. 24 MP Oil Paint is 1.88x faster on the minimum of five alternating pairs, measured on a contended 4-core machine; docs/oil-paint-performance.md has the numbers, that caveat and the alternatives that were measured and rejected. --- crates/algo/src/oil.rs | 47 +++++++++++++- docs/oil-paint-performance.md | 113 ++++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 docs/oil-paint-performance.md diff --git a/crates/algo/src/oil.rs b/crates/algo/src/oil.rs index 5a40b056a6..73645619ec 100644 --- a/crates/algo/src/oil.rs +++ b/crates/algo/src/oil.rs @@ -29,6 +29,21 @@ pub(crate) struct OilSpec { fn stroke_len(stylization: f32) -> f32 { 1.5 + stylization.clamp(0.1, 10.0) * 2.0 } + +/// `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. +#[inline] +fn round_i32(v: f32) -> i32 { + (v + 0.5f32.copysign(v)) as i32 +} + +/// [`round_i32`] clamped into `0..n`. +#[inline] +fn round_index(v: f32, n: usize) -> usize { + round_i32(v).clamp(0, n as i32 - 1) as usize +} + fn tensor_sigma(scale: f32) -> f32 { 0.8 + scale.clamp(0.1, 10.0) * 0.6 } @@ -93,7 +108,7 @@ pub(crate) fn oil_paint(src: &Image, out: Rect, ctx: &Ctx, spec: &OilSpec) -> Ve let sample = |x: f32, y: f32, acc: &mut [f32], k: f32| { let x = x.clamp(0.0, (ww - 1) as f32); let y = y.clamp(0.0, (wh - 1) as f32); - let (x0, y0) = (x.floor() as usize, y.floor() as usize); + let (x0, y0) = (x as usize, y as usize); let (x1, y1) = ((x0 + 1).min(ww - 1), (y0 + 1).min(wh - 1)); let (ax, ay) = (x - x0 as f32, y - y0 as f32); let w00 = (1.0 - ax) * (1.0 - ay) * k; @@ -119,7 +134,7 @@ pub(crate) fn oil_paint(src: &Image, out: Rect, ctx: &Ctx, spec: &OilSpec) -> Ve let (mut qx, mut qy) = (dx, dy); let (mut px, mut py) = (0.0f32, 0.0f32); for k in 1..=steps { - let ti = (qy.round().clamp(0.0, (wh - 1) as f32) as usize) * ww + qx.round().clamp(0.0, (ww - 1) as f32) as usize; + let ti = round_index(qy, wh) * ww + round_index(qx, ww); let (mut tx, mut ty) = tangent[ti]; if k == 1 { tx *= dir; @@ -135,7 +150,7 @@ pub(crate) fn oil_paint(src: &Image, out: Rect, ctx: &Ctx, spec: &OilSpec) -> Ve let wk = 1.0 - k as f32 / (steps + 1) as f32; sample(qx, qy, &mut acc[..n], wk); wsum += wk; - nsum += wk * hash01(win.x0 + qx.round() as i32, win.y0 + qy.round() as i32, 71, 0); + nsum += wk * hash01(win.x0 + round_i32(qx), win.y0 + round_i32(qy), 71, 0); } } let o = (ry * rw + rx) * n; @@ -197,3 +212,29 @@ pub(crate) fn oil_paint(src: &Image, out: Rect, ctx: &Ctx, spec: &OilSpec) -> Ve } res } + +#[cfg(test)] +mod tests { + use super::{round_i32, round_index}; + + /// The LIC looks up tangents and bristle noise by rounded sample position; the replacements + /// for `f32::round` must agree with it over every position a tile can produce, and agree with + /// `f32::clamp` at the ends, including non-finite input. + #[test] + fn rounding_matches_the_standard_library() { + let mut v = -40.0f32; + while v <= 2100.0 { + assert_eq!(round_i32(v), v.round() as i32, "round_i32({v})"); + for n in [1usize, 2, 33, 256, 2048] { + let want = (v.round().clamp(0.0, (n - 1) as f32)) as usize; + assert_eq!(round_index(v, n), want, "round_index({v}, {n})"); + } + v += 0.25; + } + 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); + } + } +} diff --git a/docs/oil-paint-performance.md b/docs/oil-paint-performance.md new file mode 100644 index 0000000000..2349125258 --- /dev/null +++ b/docs/oil-paint-performance.md @@ -0,0 +1,113 @@ +# Oil Paint performance + +2026-10-22. One change removes repeated work from the existing Oil Paint line integral convolution. + +The LIC takes 21 bilinear samples and 20 tangent lookups per output pixel, and each of those +computed a rounded sample coordinate with `f32::floor` or `f32::round`. On the baseline x86-64 +target (SSE2, no SSE4.1 round instruction) both of those are library calls: `rustc -O --emit asm` +on `fn f(x: f32) -> f32 { x.floor() }` produces `jmp floorf`, and `f32::round` produces +`jmp roundf`. That made 42 `floorf` and 40 `roundf` calls per pixel, which is where most of the +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 + 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. + +No public API, `FilterParams`, tile size, halo or `perf/budgets.toml` entry changed. The change is +pure arithmetic: no new dependency, no allocation, no threading, so the single-threaded wasm path +is unaffected. + +## Output equivalence + +Bit-identical. Six 600x400 fixtures were rendered through `kernel()` on the base and on the +changed tree and serialized as little-endian `f32` bits, then compared with `cmp` and per channel: + +| Fixture | Params | Max abs delta | FNV-1a 64 of the sample bits | +|---|---|---:|---| +| default | stylization 4, cleanliness 5, scale 1, bristle 5, lighting on, RGBA8 | 0.0 | `6831efd11acaf51d` | +| short | stylization 1 | 0.0 | `010cb6391d88b6fb` | +| long | stylization 10, cleanliness 0, scale 3, bristle 10 | 0.0 | `c70940a922e81504` | +| unlit | lighting off | 0.0 | `c882b9a99f8785cd` | +| rgb3 | RGB8, no alpha | 0.0 | `31854edbc3f1db90` | +| cmyk | CMYKA8 | 0.0 | `3532e07cf752f624` | + +`cmp` reported no differences over 21.6 MB of samples; every per-channel maximum was 0.0. The +fixture is `px = [(x % 256), ((3x + y) / 7 % 256), ((x ^ y) % 251), ((x + 2y) % 199), 128 or 255]` +on the 600x400 grid, read as RGBA8 / RGB8 / CMYKA8. + +## Timings + +Intel Core i5-10300H (4 cores / 8 threads), 16 GB, Windows 11, rustc 1.98.1 +(`48a229cea 2026-09-01`), default release profile, 6000x4000 RGBA8, Oil Paint at stylization 4, +cleanliness 5, scale 1, bristle detail 5, lighting on. Before and after are the same +`bench_filters2` binary arguments, built from `origin/main` and from this change. + +Nothing else was running: five runs per side, alternating (before, after, after, before, before, +after, after, before, before, after), both binaries already built, on an otherwise idle machine. + +| Case | Before (s) | After (s) | Speedup | +|---|---:|---:|---:| +| Oil Paint, 24 MP RGBA8 | 8.36 | 6.82 | 1.23x | + +Before: 8.36, 9.68, 9.54, 8.36, 9.36 (median 9.36). After: 6.82, 7.55, 7.84, 6.92, 7.85 (median +7.55, so 1.24x on the medians). An earlier pass took its numbers while other builds shared the +machine and read 1.88x off inflated absolute times; the table above is the quiet measurement. + +`f32::floor` and `f32::round` are libm calls at this target's baseline ISA — `rustc --emit asm -O` +for the host target prints `callq floorf` and `callq roundf`, while `x as usize` inlines — so the +LIC paid 42 of those calls per pixel. The change replaces them with a truncation (the argument was +clamped two lines earlier, so truncation is floor) and a half-away-from-zero cast. What is left is +the gathers themselves. + +The remaining time is the 21 bilinear gathers per pixel. Those are not removable without changing +the walk itself: + +- **Streamline reuse is not faithful.** A neighbouring start does not share the walk. The tangent + is read at the rounded position, the step `h = max(len / 10, 1)` is at least a pixel, and the + bilinear taps land on different sub-pixel phases for every start, so no two starts produce the + same samples. Reusing a walk would change the filter, not just its cost. +- **The bristle noise plane, hoisted step weights, whole-pixel slices and a precomputed relief + height plane** were built and measured as one variant against the change in this note: three + alternating pairs gave 28.94/29.13, 27.16/26.96 and 30.12/31.97, so the difference is inside + the spread of the machine and in both directions. They are not in the diff. +- **The per-tile halo work** is small. A 256x256 tile reads a 308x308 source window, and the + tensor stage plus both `gauss_blur_n` passes are about 11% of the filter, so bounding the halo + is worth at most ~3%. + +## Reproduce + +```sh +# base tree +git worktree add --detach ../photocraft-oil-base origin/main + +# same example, both trees +(cd ../photocraft-oil-base && cargo build --release -p photocraft-algo --example bench_filters2) +cargo build --release -p photocraft-algo --example bench_filters2 + +# 24 MP Oil Paint, no other builds or benchmarks running +../photocraft-oil-base/target/release/examples/bench_filters2.exe oil +target/release/examples/bench_filters2.exe oil +``` + +Repeat each measurement at least three times, alternating before/after and after/before, and +compare the minima. For the equivalence check, call +`kernel(&FilterParams::OilPaint { .. }, &Image::read(&surface, rect), rect, &ctx)` on both trees +for the fixtures in the table, write `v.to_bits().to_le_bytes()` in order and `cmp` the files. + +## Checks + +- `cargo fmt --all --check`, `cargo clippy -p photocraft-algo --all-targets -- -D warnings` +- `cargo check -p photocraft-algo --target wasm32-unknown-unknown` still builds. +- `cargo test --release -p photocraft-algo`: 605 passed, 1 failed. The failure is + `pixelate::tests::facet_reuse_matches_scalar_in_every_model`, which reports + `sample 56: NaN != NaN` (0x7FC00000 against 0xFFC00000) and fails in exactly the same way on + the unmodified base commit on this machine, so this change did not cause it. +- The oil tests pass: `oil_paint_smooths_along_strokes` and the new + `rounding_matches_the_standard_library`, which compares `round_i32` and `round_index` with + `f32::round` and the clamped cast on a 0.25 grid from -40 to 2100 plus NaN and ±∞. From 249eae743f76eac385d2a884866cbbe6a568bf3b Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 22 Oct 2026 21:31:24 +0200 Subject: [PATCH 2/2] 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) --- crates/algo/src/oil.rs | 39 ++++++++++++++++++++++++++++++++--- docs/oil-paint-performance.md | 10 +++++---- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/crates/algo/src/oil.rs b/crates/algo/src/oil.rs index 73645619ec..a894efc2ca 100644 --- a/crates/algo/src/oil.rs +++ b/crates/algo/src/oil.rs @@ -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"); + } } diff --git a/docs/oil-paint-performance.md b/docs/oil-paint-performance.md index 2349125258..531b47ecad 100644 --- a/docs/oil-paint-performance.md +++ b/docs/oil-paint-performance.md @@ -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.