NC Picto · Docs

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.

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:

Reproduce

# base tree
git worktree add --detach ../picto-oil-base origin/main

# same example, both trees
(cd ../picto-oil-base && cargo build --release -p picto-algo --example bench_filters2)
cargo build --release -p picto-algo --example bench_filters2

# 24 MP Oil Paint, no other builds or benchmarks running
../picto-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