From ab240d6ecea04cc7afb0e3951bf25c43fd956eef Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Sun, 11 Oct 2026 10:00:45 +1100 Subject: [PATCH] Perf: hue/saturation tables skip the sRGB round trip and fmodf in their per-pixel path HsvTable::apply took the value through linear -> sRGB -> linear even when the table's value scale is 1 (every hue/saturation-only table, as fitted camera looks are), and wrapped hues with rem_euclid (an fmodf call) three times per pixel. The value is now kept as it is for a unit scale and the sRGB-encoded value is reused otherwise; hues are wrapped by a helper that equals rem_euclid bit for bit (tested) without the call for values within one period. 2048 px render of a 24 MP ARW, single thread: 16.4 -> 15.1 G instructions, 4.94 -> 4.76 G cycles. Sources: own design. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/raw/src/profile.rs | 56 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/crates/raw/src/profile.rs b/crates/raw/src/profile.rs index 582bf9446..566c2b1c3 100644 --- a/crates/raw/src/profile.rs +++ b/crates/raw/src/profile.rs @@ -76,7 +76,7 @@ impl HsvTable { /// Tri-linear lookup at hue `h` (degrees), saturation `s` and value `v` (table coordinates). pub fn lookup(&self, h: f32, s: f32, v: f32) -> [f32; 3] { - let hf = (h.rem_euclid(360.0) / 360.0 * self.hue_divisions as f32).clamp(0.0, self.hue_divisions as f32); + let hf = (wrap(h, 360.0) / 360.0 * self.hue_divisions as f32).clamp(0.0, self.hue_divisions as f32); let h0 = (hf as usize).min(self.hue_divisions - 1); let h1 = (h0 + 1) % self.hue_divisions; let th = (hf - h0 as f32).clamp(0.0, 1.0); @@ -113,7 +113,9 @@ impl HsvTable { // already above 1 (out-of-ProPhoto colours, highlight headroom) are only ever lowered. let s2 = limited(sat, ss); let v2 = if self.srgb_value && max <= 1.0 { - srgb_to_linear((linear_to_srgb(max) * vs).min(1.0)) + // (a value scale of 1 — every hue/saturation-only table — leaves the value as it is: + // no round trip through the sRGB curve, two `powf` per pixel) + if vs == 1.0 { max } else { srgb_to_linear((v_index * vs).min(1.0)) } } else if self.srgb_value { max * srgb_to_linear(vs.min(1.0)).min(1.0) } else { @@ -128,12 +130,27 @@ fn limited(x: f32, k: f32) -> f32 { if x <= 1.0 { (x * k).min(1.0) } else { x * k.min(1.0) } } +/// `x.rem_euclid(p)` (`p > 0`), bit for bit, without the `fmodf` call for the values it nearly always +/// gets here (within one period of `[0, p)`; both subtractions are exact there). +#[inline] +fn wrap(x: f32, p: f32) -> f32 { + if (0.0..p).contains(&x) { + x + } else if x > -p && x < 0.0 { + x + p + } else if (p..2.0 * p).contains(&x) { + x - p + } else { + x.rem_euclid(p) + } +} + fn hue_of(c: [f32; 3], max: f32, d: f32) -> f32 { if d <= 0.0 { return 0.0; } let h = if max == c[0] { - ((c[1] - c[2]) / d).rem_euclid(6.0) + wrap((c[1] - c[2]) / d, 6.0) } else if max == c[1] { (c[2] - c[0]) / d + 2.0 } else { @@ -145,7 +162,7 @@ fn hue_of(c: [f32; 3], max: f32, d: f32) -> f32 { /// HSV → RGB for any saturation ≥ 0 (above 1 yields negative components, the inverse of /// [`hue_of`] for out-of-gamut inputs). fn hsv_to_rgb(h: f32, s: f32, v: f32) -> [f32; 3] { - let hp = h.rem_euclid(360.0) / 60.0; + let hp = wrap(h, 360.0) / 60.0; let sector = (hp as u32).min(5); let f = hp - sector as f32; let (p, q, t) = (v * (1.0 - s), v * (1.0 - s * f), v * (1.0 - s * (1.0 - f))); @@ -282,6 +299,37 @@ mod tests { HsvTable { hue_divisions: h, sat_divisions: s, val_divisions: v, data: vec![[0.0, 1.0, 1.0]; h * s * v], srgb_value: false } } + #[test] + fn wrap_is_rem_euclid_bit_for_bit() { + for p in [6.0f32, 360.0] { + let mut x = -3.0 * p; + while x < 3.0 * p { + for v in [x, x.next_up(), x.next_down(), -0.0, 0.0, p, -p, 2.0 * p, p.next_down(), (-p).next_up()] { + assert_eq!(wrap(v, p).to_bits(), v.rem_euclid(p).to_bits(), "{v} mod {p}"); + } + x += p / 97.0; + } + for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 1e30, -1e30] { + assert_eq!(wrap(v, p).is_nan(), v.rem_euclid(p).is_nan()); + } + } + } + + #[test] + fn unit_value_scale_with_srgb_values_keeps_the_value() { + let mut t = identity(6, 2, 3); + t.srgb_value = true; + for (i, e) in t.data.iter_mut().enumerate() { + *e = [(i % 7) as f32 - 3.0, 1.1, 1.0]; + } + for c in [[0.2f32, 0.1, 0.05], [0.9, 0.5, 0.7], [0.01, 0.02, 0.015], [1.5, 0.4, 0.2]] { + let max = c[0].max(c[1]).max(c[2]); + let out = t.apply(c); + let got = out[0].max(out[1]).max(out[2]); + assert!((got - max).abs() <= 1e-6 * max.max(1.0), "{c:?} -> {out:?}"); + } + } + fn close(a: [f32; 3], b: [f32; 3], tol: f32) -> bool { a.iter().zip(&b).all(|(x, y)| (x - y).abs() <= tol) }