Skip to content

tone equalizer: add OpenCL implementation - #22051

Open
da-phil wants to merge 5 commits into
darktable-org:masterfrom
da-phil:pl/add_opencl_impl_for_toneeualizer
Open

tone equalizer: add OpenCL implementation#22051
da-phil wants to merge 5 commits into
darktable-org:masterfrom
da-phil:pl/add_opencl_impl_for_toneeualizer

Conversation

@da-phil

@da-phil da-phil commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Motivation

I'm frequently using multiple instances of the tone equalizer module and I love it for taming HDR contrast, however, if you have multiple instances of it, exporting images can easily take up to 25s on my desktop PC or even up to 40s on my laptop.
The module has only a CPU execution path, so it pulled the pixelpipe back to the host on every zoom, pan and slider move, which also made it an overall slow editing experience.

Fixes #4805

Changes

Add data/kernels/toneequal.cl and a process_cl() mirroring the CPU path:

  • luminance mask extraction for all seven estimators,
  • the fast guided filter (quantization, separable box average, linear and geometric-mean blending),
  • the exposure independent guided filter, reusing the existing
    dt_gaussian_*_cl() buffer blurs,
  • the exposure correction and the luminance mask display.

The correction is evaluated as the sum of the eight octave gaussians rather than through d->correction_lut: the transcendentals are cheaper on a GPU than uploading a 320 kB table per invocation, and this is the reference formulation the lut approximates.

The GUI reads the luminance mask from host memory to build its histogram and to report the luminance under the cursor, so process_cl() mirrors the caching logic of toneeq_process() and copies the mask back whenever the upstream pipe hash changes.

Also add a tiling_callback() so the pixelpipe accounts for the intermediate buffers and falls back to the CPU rather than failing to allocate. The module stays untileable, the guided filter being global.

Results

Integration tests

Tone equalizer relevant integration tests (CPU vs GPU) look fine, see test-20260829-201614.log.

Benchmarks

Comparison of darktable export performance with the CPU-only tone equalizer
(current master) against an OpenCL implementation, measured on two AMD systems.

Test setup

Laptop Desktop
CPU Ryzen 7 8845HS (Zen 4, 8C/16T) Ryzen 7 5700X (Zen 3, 8C/16T)
GPU Radeon 780M iGPU (gfx1103, 6 CU) Radeon RX 9060 XT (gfx1200, 16 CU)
GPU memory 10 GB GTT (unified) 16 GB dedicated
RAM 32 GB DDR5-5600 SO-DIMM 32 GB DDR4-2400 (4×8 GB)
OS Ubuntu 24.04.4 LTS Ubuntu 24.04.4 LTS
ROCm 10.0.0 10.0.0
OpenCL driver 3581.0 (HSA1.1, LC) 3581.0 (HSA1.1, LC)
  • Test image: single Sony ARW raw, ~9069 × 6046 px at the pixelpipe stage.
  • History stack: 24 modules including three toneequal instances (two with blending), two diffuse instances, colorequal, agx.
  • Command: darktable -d opencl -d perf -d tiling, export to JPEG.
  • Builds: release builds on both machines. Timings are pixelpipe processing time, excluding raw load.

Full pipeline

Metric Laptop CPU Laptop OpenCL Speedup Desktop CPU Desktop OpenCL Speedup
Total pipeline 30.62 s 13.93 s 2.2× 22.82 s 3.49 s 6.5×
toneequal (3 instances) 17.42 s 3.08 s 5.7× 19.67 s 1.06 s 18.6×
GPU queue time 8.77 s 8.34 s 1.64 s 2.06 s

Per-instance tone equalizer timings

Instance Laptop CPU Laptop OpenCL Desktop CPU Desktop OpenCL
toneequal 0.859 s 0.153 s 0.979 s 0.025 s
toneequal.2 (blended) 8.075 s 1.540 s 9.041 s 0.598 s
toneequal.1 (blended) 8.486 s 1.384 s 9.646 s 0.436 s
Total 17.42 s 3.08 s 19.67 s 1.06 s
Share of pipeline 56.9 % 22.1 % 86.2 % 30.4 %

Disclaimer: this change was co-created with Claude.

@da-phil
da-phil marked this pull request as ready for review August 29, 2026 18:55
@da-phil
da-phil force-pushed the pl/add_opencl_impl_for_toneeualizer branch from f39f2c2 to 6dab75d Compare August 29, 2026 18:56
@da-phil

da-phil commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

There is still one open question to w.r.t. the luminance mask cache keying on the wrong hash.

Both below items affect the CPU and the OpenCL path equally, which is why they are kept out of that PR, as it is purely about OpenCL.

1. The cache key includes the module's own parameters

toneeq_process() and process_cl() both gate mask recomputation on

const dt_hash_t hash = dt_dev_pixelpipe_piece_hash(piece, roi_out, TRUE);

With include = TRUE that hashes nodes[0 .. position-1], and module->position is the module's own 1-based index (pixelpipe_hb.c:2317, _dev_pixelpipe_cache_basichash() in
pixelpipe_cache.c) - so the module's own params are part of the key.

The luminance mask does not depend on the nine band factors or on smoothing; those only feed d->factors. But moving any of them changes the key, so every frame of a band-slider drag:

  • recomputes the whole mask, on CPU and on GPU alike, and
  • on the preview pipe, copies a bit-identical mask device to host again.

Potential fix. Key the mask on

  • dt_dev_pixelpipe_piece_hash(piece, roi_out, FALSE) - upstream only, and
  • a hash of the mask-affecting params only: method, blending, feathering, iterations, quantization, details, exposure_boost, contrast_boost.

That set is already enumerated in gui_changed(), which calls invalidate_luminance_cache() for exactly those widgets - so the fix can reuse the existing list instead of inventing a new one.

2. The preview readback can then be made ~2 KB instead of a whole buffer

Once (1) lands, what remains is one device-to-host copy of the whole mask per actual mask change. The two GUI consumers need far less than that:

consumer what it actually reads
get_luminance_from_buffer() (exposure under the cursor) a 3x3 neighbourhood, 9 floats
compute_log_histogram_and_stats() a reduction to int[256] plus max and two deciles

Potential fix. Compute the log histogram on the device and read back only the bins (~2 KB); read the 3x3 region on demand when the cursor moves, rather than the whole buffer on every pipe run.

That removes the last bulk device-to-host transfer from the darkroom path, which is the substance of the long-standing objection to giving this module a GPU code path at all.

Notes

  • Not covered by any test suite. All of this sits behind self->dev->gui_attached, which is FALSE in darktable-cli, so no integration test reaches it. Both items need validating in a real darkroom session: pan, zoom, a band-slider drag, and a feathering / iterations drag.
  • Already done in this PR The full pipe no longer copies its mask back at all, because g->full_preview_buf is never read by the GUI; g->ui_preview_hash is invalidated instead, so a fallback to the CPU recomputes rather than reusing a buffer the GPU path never filled. Item 1 above concerns the remaining preview-pipe copy and the redundant recompute on both paths.

Would be great to hear your thoughts on this @jenshannoschwalm
But we can also move this discussion into a separate issue, as I don't think it should be part of this PR.

@jenshannoschwalm

Copy link
Copy Markdown
Collaborator

Would be great to hear your thoughts on this @jenshannoschwalm

About what exactly - or in general?

  1. Yes - you should calculate the hash in two steps as suggested to avoid recalculation/invalidation?
  2. About the luminance & histogram? Isn't there the late work on color equalizer that provides exactly the required data?

@MStraeten

MStraeten commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

maybe carve out the fast guided filter OpenCl code into a separate kernel so it can be used for further modules (see #21596)

@da-phil

da-phil commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

maybe carve out the fast guided filter OpenCl code into a separate kernel so it can be used for further modules (see #21596)

Didn't you just do it in your PR: https://github.com/darktable-org/darktable/pull/21596/changes#diff-6f734258091f34041ce75b0beff1edca67f1941d5bf5ce86dde7347ee9f77dc0
Sounds like this can be a separate PR to keep your PR's smaller in scope.

@da-phil
da-phil force-pushed the pl/add_opencl_impl_for_toneeualizer branch 2 times, most recently from 7d36509 to c2483c5 Compare September 2, 2026 07:05
@TurboGit
TurboGit requested a lite review from Copilot September 2, 2026 07:05
@TurboGit TurboGit added this to the 5.8 milestone Sep 2, 2026
@TurboGit TurboGit added feature: enhancement current features to improve scope: performance doing everything the same but faster release notes: pending OpenCL Related to darktable OpenCL code labels Sep 2, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The change introduces a substantial new GPU implementation path (multiple kernels + memory/tiling behavior) that is difficult to fully validate for correctness and device-compatibility via automated review alone.

Pull request overview

Adds an OpenCL (GPU) execution path to toneequal to avoid repeated host/device roundtrips and accelerate interactive editing + export when multiple instances are used.

Changes:

  • Implement process_cl() for tone equalizer, including luminance mask extraction, guided/EIGF filtering, and correction application (with host-side cache synchronization for GUI histogram/cursor sampling).
  • Add a dedicated OpenCL program (data/kernels/toneequal.cl) containing kernels for luminance mask generation, guided filter steps, EIGF steps, and mask display.
  • Register the new OpenCL program in data/kernels/programs.conf and add a tiling_callback() to model intermediate-buffer memory needs.
File summaries
File Description
src/iop/toneequal.c Adds kernel IDs, OpenCL processing path, and tiling memory accounting for GPU execution while mirroring existing CPU behavior/caching.
data/kernels/toneequal.cl New OpenCL kernels implementing the tone equalizer luminance mask + guided/EIGF filtering + apply/display steps.
data/kernels/programs.conf Registers toneequal.cl as OpenCL program id 43 so the module can create kernels at init time.
Review details
  • Files reviewed: 5/5 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@da-phil
da-phil force-pushed the pl/add_opencl_impl_for_toneeualizer branch from d48c6f8 to 442ecf3 Compare September 2, 2026 07:46
@da-phil

da-phil commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Would be great to hear your thoughts on this @jenshannoschwalm

About what exactly - or in general?

  1. Yes - you should calculate the hash in two steps as suggested to avoid recalculation/invalidation?

Changes were done in the following commits

Does this look good to you?

  1. About the luminance & histogram? Isn't there the late work on color equalizer that provides exactly the required data?

Need to dig into that first...

Comment thread src/iop/toneequal.c
@da-phil
da-phil force-pushed the pl/add_opencl_impl_for_toneeualizer branch from 442ecf3 to 4857d8c Compare September 5, 2026 21:45
@TurboGit

TurboGit commented Sep 9, 2026

Copy link
Copy Markdown
Member

@da-phil : What is the status of this, is that ready for review/integration?

@da-phil

da-phil commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

@da-phil : What is the status of this, is that ready for review/integration?

Yes, it's ready for review/integration. I've been using/testing this code for a while.
But merging can wait until I'm back from vacation in two weeks.

The tone equalizer was CPU only, so it pulled the pixelpipe back to the
host on every zoom, pan and slider move.

Add data/kernels/toneequal.cl and a process_cl() mirroring the CPU path:

  - luminance mask extraction for all seven estimators,
  - the fast guided filter (quantization, separable box average, linear
    and geometric-mean blending),
  - the exposure independent guided filter, reusing the existing
    dt_gaussian_*_cl() buffer blurs,
  - the exposure correction and the luminance mask display.

The correction is evaluated as the sum of the eight octave gaussians
rather than through d->correction_lut: the transcendentals are cheaper
on a GPU than uploading a 320 kB table per invocation, and this is the
reference formulation the lut approximates.

The GUI reads the luminance mask from host memory to build its histogram
and to report the luminance under the cursor, so process_cl() mirrors
the caching logic of toneeq_process() and copies the mask back whenever
the upstream pipe hash changes.

Also add a tiling_callback() so the pixelpipe accounts for the
intermediate buffers and falls back to the CPU rather than failing to
allocate. The module stays untileable, the guided filter being global.

Checked against the CPU path with the integration tests 0079, 0080 and
0129 to 0134, plus mask quantization and multi-iteration variants: the
8-bit outputs differ by at most 1 LSB on less than 0.1% of the pixels.
Giving tone equalizer an OpenCL path moves its luminance mask into
device memory. That mask is not a private intermediate: the CPU code
publishes it to the GUI, which reads it from another thread. The GUI
cannot read device memory, so a device to host copy has to appear
somewhere, and such a copy is a synchronisation point in an otherwise
asynchronous command queue. This seam, interleaving the pixelpipe and
GUI threads with a GPU queue now in between, is the long standing
objection to giving this module a GPU code path at all, so it is worth
keeping as narrow as it can be.

process_cl() mirrored toneeq_process() and kept both host caches, but
only the preview pipe one is ever read. Both GUI consumers take their
data from it: update_histogram() through g->pd.buf, and
_luminance_from_module_buffer() for the exposure under the cursor. The
full pipe copy landed in g->full_preview_buf, which no GUI code reads,
and on the OpenCL path the correction is applied on the device from
dev_luminance, so no later GPU run read it back either. It was a
blocking multi-megabyte transfer into a buffer nobody consumes, issued
whenever the roi or the upstream pipe state changed: every pan and zoom
step, and every motion event of a feathering or iterations drag, since
those invalidate the luminance cache.

Drop it, and let the full pipe mask stay on the device.

The copy could not simply be deleted. toneeq_process() skips
compute_luminance_mask() while g->ui_preview_hash still matches, so a
GPU run that advanced that hash without filling the host buffer would
let a later CPU run apply a stale mask. That is reachable by turning
OpenCL off mid session, by cldevice_..._nocl=toneequal, and by any late
fallback to the CPU path. The hash is therefore invalidated rather than
advanced, so such a transition recomputes the mask once. That costs
about one CPU process() run.

The preview pipe copy stays as it is. The GUI genuinely needs that
buffer in host memory, it goes through the shared dt_preview_data_t
service, that pipe is small, and the blocking read is issued outside
the module GUI lock so it cannot stall a GUI thread.

Pixel output is unchanged: both kernels read dev_luminance either way,
and gui_attached is FALSE on export, so this block is darkroom only.
dt_preview_data_set_hash() records dt_dev_pixelpipe_piece_hash(piece,
roi, TRUE), which covers the owning module's own params.  That is the
right key for a buffer that is a copy of the module output, but not for
one that only depends on a part of the module state.

Add dt_preview_data_set_hash_value() so a module can commit a freshness
key it computed itself, and document that dt_preview_data_is_fresh(),
which recomputes the cumulative piece hash, does not apply to buffers
stored that way.  No functional change: no caller uses it yet.
toneeq_process() and process_cl() both gated mask recomputation on

  dt_dev_pixelpipe_piece_hash(piece, roi_out, TRUE)

With include = TRUE that hashes nodes[0 .. position-1], and
module->position is the module's own 1-based index in the pipe, so the
module's own params were part of the key.

The luminance mask does not depend on the nine band factors nor on
`smoothing`; those only feed d->factors and the correction LUT.  But
moving any of them changed the key, so every frame of a band slider drag
recomputed the whole mask on the CPU and on the GPU alike, and on the
preview pipe copied a bit-identical mask device to host again.  The mask
dominates process(): a full CPU run measures ~60 ms/Mpix (-d perf, four
cores, mire1.cr2 at 3908x2602 -> 0.575-0.694 s).

Key it instead on the upstream pipe only (include = FALSE, which still
folds in the roi) plus the params compute_luminance_mask() and
_compute_luminance_mask_cl() actually read: method, details, radius (and
the blending it derives from), feathering, iterations, quantization,
scale, exposure_boost and contrast_boost.  That is the same set
gui_changed() calls invalidate_luminance_cache() for.
@da-phil
da-phil force-pushed the pl/add_opencl_impl_for_toneeualizer branch from 4857d8c to 5053a89 Compare September 9, 2026 17:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature: enhancement current features to improve OpenCL Related to darktable OpenCL code release notes: pending scope: performance doing everything the same but faster

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Tone Equalizer does not utilize GPU

5 participants