Skip to content

colorspaces: compute JzAzBz's Az and Bz without cancellation - #22093

Merged
TurboGit merged 2 commits into
darktable-org:masterfrom
masterpiga:flexi_upstream_fix_2
Aug 31, 2026
Merged

colorspaces: compute JzAzBz's Az and Bz without cancellation#22093
TurboGit merged 2 commits into
darktable-org:masterfrom
masterpiga:flexi_upstream_fix_2

Conversation

@masterpiga

@masterpiga masterpiga commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Background

This is another OpenCL/CPU code inconsistency found working on flexi masks. I built a tool that replays mask edits from user libraries four ways (two code paths × CPU/OpenCL). I used it to processed >70K edits from a dozen darktable users. This made the pipeline disagreeing with itself very visible.

The issue

dt_XYZ_2_JzAzBz() computes az = 3.524L' − 4.066708M' + 0.542708S', whose coefficients sum to zero. For a near-neutral colour L', M' and S' are nearly the same O(1) number, so Az and Bz are whatever survives an almost total cancellation — and they are the only input to hue, since hz = atan2(Bz, Az). The PQ exponent 134.034375 makes it worse: it multiplies any relative error in its argument by 134, so a single ulp anywhere upstream reaches that subtraction magnified.

The consequence is that no two conforming float32 implementations can agree here, and darktable has two: the host conversion and its OpenCL twin. The same edit renders differently depending on whether OpenCL is enabled. Measured on a standalone differential carrying verbatim copies of both, hue disagrees by up to 1.68e-03 overall, and by up to 0.045 below Cz 1e-5.

The fix

Rewrite az and bz over the differences L'−M', S'−M', L'−S' — the same zero-sum rows regrouped, so algebraically identical — and carry each difference through both power laws and the rational PQ step without ever subtracting two computed values:

  • the raw differences come from the matrix rows, not the computed LMS;
  • through each power law by x^k − y^k = y^k·expm1(k·log1p((x−y)/y)), accurate because log1p/expm1 are accurate near zero;
  • through the PQ step by y_a − y_b = (c2 − c1·c3)(t_a − t_b) / ((1 + c3·t_a)(1 + c3·t_b)).

Iz is a sum, not a difference, and is untouched. Inputs with a non-positive LMS component take the original path, where the components are not near-equal and cancellation is not a concern.

Evidence. On 200,000 samples, CPU vs OpenCL: Az/Bz max difference 6.27e-06 → 4.49e-07, hue 1.68e-03 → 3.13e-05 — 14× and 54×. Replaying two contributed libraries of real edits, counting those where the CPU and OpenCL renders of the same edit disagree by more than 1/255:

corpus before after
library A 274 32 −88%
library B 135 27 −80%

No errors, and no edit whose own CPU/OpenCL gap widened. The residual is not this conversion: part is the guided filter used for mask feathering, part is _blendif_compute_factor's hard <= against a slider limit, which swings 0 → 1 on an input difference of 5e-07. Both are separate issues.

Does it change existing renders? Yes, on both paths, because it changes a conversion shared with colorbalancergb, colorequal, diffuse and the JzCzhz picker. The change is small and confined to near-neutral colours: mean hue change 1.43e-05, max 0.0068, with 3 of 199,773 samples (0.002%) moving by more than 1/255 — smaller than the CPU/OpenCL disagreement it removes. For the pixels most affected there is no stable rendering to preserve, since today they already render one way with OpenCL and another way without. It is also closer to the true value on both paths, not merely more consistent.

More details

There is a more extended version of this writeup here.

Co-authored with Claude.

The A matrix rows for az and bz each sum to zero -- az is
3.524L' - 4.066708M' + 0.542708S' -- so for a near-neutral colour, where
L', M' and S' are nearly the same O(1) number, the result is whatever
survives an almost total cancellation. It is also the only input to hue,
since hz = atan2(Bz, Az). And the PQ exponent 134.034375 multiplies any
relative error in its argument by 134, so a single ulp anywhere upstream
arrives at that subtraction magnified.

Evaluated directly, Az and Bz keep barely any correct digits for neutral
colours, and no two conforming float32 implementations can agree on them.
That is not hypothetical: it is why darktable renders such pixels
differently with OpenCL enabled than without. Measured on a standalone
differential carrying verbatim copies of both implementations, hue
disagrees between the CPU and OpenCL paths by up to 1.68e-03 overall, and
by up to 0.045 below Cz 1e-5.

Rewrite az and bz over the differences L'-M', S'-M' and L'-S'. This is
the same zero-sum rows regrouped, so it is algebraically identical, and
each difference is carried through both power laws and the rational PQ
step without ever subtracting two computed values:

  - the raw differences come from the matrix rows, not the computed LMS;
  - through each power law by x^k - y^k = y^k * expm1(k * log1p((x-y)/y)),
    which is accurate because log1p and expm1 are accurate near zero;
  - through the PQ step by
    y_a - y_b = (c2 - c1 c3)(t_a - t_b) / ((1 + c3 t_a)(1 + c3 t_b)).

Iz is a sum rather than a difference and is untouched. Inputs with a
non-positive LMS component take the original path, where the components
are not near-equal and cancellation is not a concern.

Measured on 200,000 samples, CPU versus OpenCL: Az/Bz max difference
6.27e-06 -> 4.49e-07, hue 1.68e-03 -> 3.13e-05. A 14x improvement on
Az/Bz and 54x on hue. The result is also closer to the true value on both
paths, not merely more consistent.

This changes renders, on both paths, because it changes a conversion
shared with colorbalancergb, colorequal, diffuse and the JzCzhz picker.
The change is small and confined to near-neutral colours: mean hue change
1.43e-05, max 0.0068, with 3 of 199,773 samples (0.002%) moving by more
than 1/255 -- smaller than the CPU/OpenCL disagreement it removes. For
the pixels most affected there is no stable rendering to preserve, since
today they render one way with OpenCL and another way without.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@masterpiga

masterpiga commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author

@TurboGit this changes both the CPU and OpenCL rendering paths to make them both more correct and agree with each other. I labeled this as a bugfix because the two paths diverging when this could be avoided reads as a bug to me.

@masterpiga masterpiga added this to the 5.8 milestone Aug 31, 2026
@masterpiga masterpiga added bugfix pull request fixing a bug priority: medium core features are degraded in a way that is still mostly usable, software stutters difficulty: trivial some changes in a couple of functions scope: image processing correcting pixels OpenCL Related to darktable OpenCL code difficulty: average some changes across different parts of the code base and removed difficulty: trivial some changes in a couple of functions labels Aug 31, 2026
@jenshannoschwalm

jenshannoschwalm commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Just mentioning;

  1. float multiplications are generally faster than divs on most CPUs and GPUs like / 10000.f - the compiler might take care of this but that depends on flags.
  2. In the cl code you have a lot of const floats. Yes - it's more readable and we all like it - but from my current understanding it puts a pretty high register burden on the cl kernel. If a float is not reused later it might be faster without the const.

Otherwise nice. Could you observe/check changes with the integration test results?

@masterpiga

masterpiga commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks, @jenshannoschwalm. I am assessing the impact of the proposed changes (and their effect on the integration tests) empirically. I will report back when it's done.

@TurboGit

Copy link
Copy Markdown
Member

I'm running the integration tests right now, I'm at 0085.

@masterpiga

Copy link
Copy Markdown
Collaborator Author

Thanks @jenshannoschwalm — took both suggestions after measuring.

Divisions. Changed to * 1e-4f in host and kernel. I checked it costs
nothing first, since this path exists precisely to protect precision: on 400k
near-neutral samples against a double-precision reference, mean hue error moves
1.069e-07 → 1.070e-07, max unchanged at 3.151e-05. Free, so it's in.

The const floats. Your instinct about the kernel was right, though what I
found was redundancy rather than a readability trade-off: dtcl_pow(m, n) and
dtcl_pow(s, n) were recomputed for the three dt_* terms although t_m and
t_s already hold exactly those values, and dtcl_pow(y_m, p) was evaluated
three times. That's five pow calls per pixel removed, bit-identical output,
and fewer live values rather than more. The host had the same duplication and
gets the same treatment, so the two stay mirror images. I've left the remaining
names in place.

Cost. Worth stating plainly: the rewrite is not free. colorbalancergb
module time at 10 Mpix:

master rewrite as first pushed with these changes
OpenCL (median of 20) 0.021 s 0.025 s (+19%) 0.024 s (+12%)
CPU (min of 7) 0.506 s 0.576 s (+14%) 0.568 s (+12%)

Three log1p and three expm1 per pixel are the price of the precision. The
dedup recovers about a third of the GPU overhead — it was hurting the kernel
more than the CPU, as you'd expect.

Integration tests. All 179, both binaries, CPU and OpenCL, under the
driver's CORE_OPTIONS. A per-test max diff is useless here, because baseline
CPU/OpenCL divergence from unrelated modules dominates it (0043-dithering-fs
differs by a full 1.0, 0136-posterize by 0.33), so this counts pixels where a
build disagrees with itself:

test mean gap master → patched pixels > 1/255
0120-blending-rgb-scene-2 1.98e-05 → 1.26e-06 6821 → 382
0119-blending-rgb-scene-1 5.14e-06 → 1.53e-07 2880 → 76
0167-raster-mask 2.55e-06 → 3.36e-07 1135 → 142
0121-blending-rgb-scene-3 2.01e-06 → 8.49e-07 720 → 261
0083-colorbalancergb 9.49e-06 → 6.30e-06 3218 → 2055

162 of 179 tests are untouched; the rest move by a handful of pixels either
way, which is run-to-run noise — re-rendering the same binary twice,
0103-almost-all swings ±35 pixels and 0019-color-mapping ±4, against
observed deltas of +104 and +1.

Against master, 15 tests change output. Nine by exactly one 8-bit level. Two
move more: 0120-blending-rgb-scene-2 (4372 px over 1/255, max 15/255) and
0121-blending-rgb-scene-3 (493 px, max 6/255). Both are
_blendif_compute_factor's hard <= against the slider limit, which swings
0 → 1 on an input difference of 5e-07: the conversion moves a pixel by an ulp
and the blend then moves it by a whole mask step. Both tests have zero
CPU/OpenCL gap in either build, so that's a shared boundary shifting rather
than new divergence. Those references will need regenerating.

One caveat on methodology: src/tests/integration/run doesn't execute on
macOS (bash 3.2 has no local -n, date -d is GNU, two helpers have absolute
python shebangs), so these come from a harness of mine that replicates the same
darktable-cli invocation including CORE_OPTIONS — not the upstream driver.
A Linux re-run of the suite would be worth having (I believe that @TurboGit is running that already, thanks a lot!).

@TurboGit

TurboGit commented Aug 31, 2026

Copy link
Copy Markdown
Member

Here are the results (only one failure):

Test 0120-blending-rgb-scene-2
      Image mire1.cr2
      Timing cpu 3.90s
      Timing gpu 2.65s
      CPU & GPU version differ by 2213 pixels
      CPU & GPU smaller difference old 43000 now 2213
      CPU vs. GPU report :
      ----------------------------------
      Max dE                   : 10.21154
      Avg dE                   : 0.00037
      Std dE                   : 0.02589
      ----------------------------------
      Pixels below avg + 0 std : 99.92 %
      Pixels below avg + 1 std : 99.92 %
      Pixels below avg + 3 std : 99.92 %
      Pixels below avg + 6 std : 99.92 %
      Pixels below avg + 9 std : 99.94 %
      ----------------------------------
      Pixels above tolerance   : 0.00 %
 
      Expected CPU vs. current CPU report :
      ----------------------------------
      Max dE                   : 5.79732
      Avg dE                   : 0.00290
      Std dE                   : 0.03535
      ----------------------------------
      Pixels below avg + 0 std : 99.13 %
      Pixels below avg + 1 std : 99.13 %
      Pixels below avg + 3 std : 99.13 %
      Pixels below avg + 6 std : 99.29 %
      Pixels below avg + 9 std : 99.66 %
      ----------------------------------
      Pixels above tolerance   : 0.00 %
 
  FAILS: image visually changed
         see diff.png for visual difference
         (24186 pixels changed)

But as we can see the actual diff between CPU & GPU is now smaller. So going in the right direction.

@TurboGit

Copy link
Copy Markdown
Member

Found one regression:

Test 0153-hl-reconstruct-filmicrgb
      Image hlrecovery.arw
      Timing cpu 15.07s
      Timing gpu 15.35s
      CPU & GPU version differ by 458602 pixels
      CPU & GPU large difference > 160000
      CPU vs. GPU report :
      ----------------------------------
      Max dE                   : 2.72680
      Avg dE                   : 0.09282
      Std dE                   : 0.22476
      ----------------------------------
      Pixels below avg + 0 std : 83.64 %
      Pixels below avg + 1 std : 84.71 %
      Pixels below avg + 3 std : 96.87 %
      Pixels below avg + 6 std : 99.97 %
      Pixels below avg + 9 std : 100.00 %
      ----------------------------------
      Pixels above tolerance   : 0.00 %

CPU & GPU version differ by 458602 pixels
CPU & GPU large difference > 160000

The number of pixel diff on current master is exactly 94780 so the regression is quite important.

@jenshannoschwalm

Copy link
Copy Markdown
Collaborator

I think that is fixed by my latest opencl pr, i had that before doing that and and i have around 50k diff pixels now here.

@TurboGit

Copy link
Copy Markdown
Member

I think that is fixed by my latest opencl pr, i had that before doing that and and i have around 50k diff pixels now here.

Indeed, we have more than 150k pixel diff, then you patch fixed that to 48k and then this patch move it back to almost 460k.

@masterpiga

Copy link
Copy Markdown
Collaborator Author

@TurboGit Is that on both commits or just the 1st one?

@TurboGit

Copy link
Copy Markdown
Member

I have only tested on both commits:

Current master:

Test 0153-hl-reconstruct-filmicrgb
      Image hlrecovery.arw
      Timing cpu 16.00s
      Timing gpu 10.48s
      CPU & GPU version differ by 94780 pixels
      CPU & GPU smaller difference old 160000 now 94780
      CPU vs. GPU report :
      ----------------------------------
      Max dE                   : 2.03732
      Avg dE                   : 0.01995
      Std dE                   : 0.11439
      ----------------------------------
      Pixels below avg + 0 std : 96.62 %
      Pixels below avg + 1 std : 96.62 %
      Pixels below avg + 3 std : 96.82 %
      Pixels below avg + 6 std : 98.96 %
      Pixels below avg + 9 std : 99.81 %
      ----------------------------------
      Pixels above tolerance   : 0.00 %

This PR:

Test 0153-hl-reconstruct-filmicrgb
      Image hlrecovery.arw
      Timing cpu 14.79s
      Timing gpu 9.68s
      CPU & GPU version differ by 94780 pixels
      CPU & GPU smaller difference old 160000 now 94780
      CPU vs. GPU report :
      ----------------------------------
      Max dE                   : 2.03732
      Avg dE                   : 0.01995
      Std dE                   : 0.11439
      ----------------------------------
      Pixels below avg + 0 std : 96.62 %
      Pixels below avg + 1 std : 96.62 %
      Pixels below avg + 3 std : 96.82 %
      Pixels below avg + 6 std : 98.96 %
      Pixels below avg + 9 std : 99.81 %
      ----------------------------------
      Pixels above tolerance   : 0.00 %

As you can see I have messed something :) I suppose my previous test was with a build without @jenshannoschwalm work. I'm sorry for the noise.

@TurboGit

Copy link
Copy Markdown
Member

@masterpiga : We just need a release notes entry for merging. TIA.

@masterpiga

masterpiga commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author

I have only tested on both commits:

@TurboGit Just to clarify. I am referring to both commits in this PR. I pushed a new commit after your commented that you were running the integration tests. Maybe you restarted the test afterwards, I am just asking for confirmation. TIA!

@TurboGit

Copy link
Copy Markdown
Member

Sorry to be clear my last message is with the last version of this PR. So in any case I there is no regression now.

@masterpiga

Copy link
Copy Markdown
Collaborator Author

Wonderful, thanks for confirming 👍

@masterpiga
masterpiga force-pushed the flexi_upstream_fix_2 branch from bf72672 to bfa5f87 Compare August 31, 2026 18:16
@masterpiga

Copy link
Copy Markdown
Collaborator Author

@TurboGit Added entry to release notes. TIA!

@TurboGit

Copy link
Copy Markdown
Member

@masterpiga : I don't see the release notes.

The cancellation-free path added for Az and Bz did more work than it
needs to. Scaling to the paper's 1/10000 nits was six divisions; 1e-4f
is not the exact reciprocal, but the extra rounding is orders below what
the difference machinery resolves. On 400k near-neutral samples against
a double-precision reference the mean hue error moves from 1.069e-07 to
1.070e-07, with the maximum unchanged.

Both the host and the kernel also recomputed powers they already held:
the y^k factor of each difference is t_m or t_s, and y_m^p is needed
three times but was evaluated three times. Hoisting those removes five
pow() calls per pixel on each path. _dt_pow_diff() therefore takes y^k
from the caller rather than computing it itself.

The results are unchanged: every integration test renders identically on
CPU, and the one test that moves by a level on GPU moves by the same
level between two runs of the same binary.

Measured on colorbalancergb at 10mpix, this recovers about a third of
what the rewrite costs on the GPU. Against master, its module time goes
from +19% to +12% (median of 20 runs, 0.021s -> 0.025s -> 0.024s); on
the CPU from +14% to +12% (min of 7, 0.506s -> 0.576s -> 0.568s). The
remaining cost is inherent: three log1p and three expm1 per pixel are
the price of the precision.
@masterpiga
masterpiga force-pushed the flexi_upstream_fix_2 branch from bfa5f87 to 17c17a0 Compare August 31, 2026 19:47
@masterpiga

masterpiga commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author

Right, that's what happens when one amends without adding first :) Done, sorry

@TurboGit
TurboGit merged commit 2ef8d13 into darktable-org:master Aug 31, 2026
6 checks passed
masterpiga added a commit to masterpiga/darktable that referenced this pull request Aug 31, 2026
The JzAzBz fix went in as PR darktable-org#22093, in two commits: the cancellation-free
rewrite, then the review follow-up that made the nit scaling a multiply and
stopped both paths recomputing powers they already held.

Adds what the follow-up measured on the integration suite, including the
cost -- 12% of colorbalancergb's module time on both paths -- and the two
tests whose references need regenerating.
masterpiga added a commit to masterpiga/darktable that referenced this pull request Sep 1, 2026
The JzAzBz fix went in as PR darktable-org#22093, in two commits: the cancellation-free
rewrite, then the review follow-up that made the nit scaling a multiply and
stopped both paths recomputing powers they already held.

Adds what the follow-up measured on the integration suite, including the
cost -- 12% of colorbalancergb's module time on both paths -- and the two
tests whose references need regenerating.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bugfix pull request fixing a bug difficulty: average some changes across different parts of the code base OpenCL Related to darktable OpenCL code priority: medium core features are degraded in a way that is still mostly usable, software stutters scope: image processing correcting pixels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants