Skip to content

Don't invalidate the pixelpipe cache on every commit when a raster mask is used#21519

Open
masterpiga wants to merge 2 commits into
masterfrom
masterpiga-raster-mask-patch-1
Open

Don't invalidate the pixelpipe cache on every commit when a raster mask is used#21519
masterpiga wants to merge 2 commits into
masterfrom
masterpiga-raster-mask-patch-1

Conversation

@masterpiga

@masterpiga masterpiga commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

I stumbled onto this while profiling some mask changes. There, a raster-masked pipe re-executed from the raster source on every mask edit (~2.8 s), instead of recomputing only the edited last module (AgX, where the raster mask was being used, ~0.17 s). The wall-clock cost will scale with image size and how early the raster source sits.

dt_iop_commit_blend_params() computes whether a raster-mask user registration is actually new:

const gboolean new = g_hash_table_insert(candidate->raster_mask.source.users, module,
                                         GINT_TO_POINTER(blendop_params->raster_mask_id));

…uses new only in a debug print, and then returns candidate unconditionally.

The caller treats any non-NULL return as "a source just gained a raster user" and invalidates the pipe cache downstream of that source:

if(blendop_params->mask_mode & DEVELOP_MASK_RASTER && new_raster)
  dt_dev_pixelpipe_cache_invalidate_later(pipe, new_raster->iop_order, "blend new raster: ");

Since the return is non-NULL on every commit — not just the first — any edit anywhere invalidates every cacheline at or after the raster source's iop_order.

Why this matters

The invalidation exists for a narrow, real case: a source that gains a new user doesn't change its own params, so its cacheline hash is unchanged and it could be served from cache without ever writing the raster mask the new user depends on. That's correct. But it should fire once, when the user is genuinely added.

As written, it fires on every history commit. If the raster source sits early in the pipe (a common setup — mask painted on an early exposure/tone module, consumed by a later one), every slider move discards the cached output of the source and everything after it, forcing a full re-render of the pipe tail. The user is editing one late module, but pays for re-computing modules they never touched.

This is invisible on small images and short pipes, and increasingly punishing as either grows: a full-resolution intermediate buffer on a 30 MP image is several hundred MB, so the cache cannot re-establish itself between edits.

The fix

-        return candidate;
+        // Only report a *genuinely new* registration. The caller uses a non-NULL
+        // return to invalidate the pipe cache downstream of the source; returning
+        // `candidate` unconditionally wipes that cache on every history commit.
+        return new ? candidate : NULL;

This is what the function already advertises — its doc comment states it "either returns NULL or the source module."

Correctness

The return value is captured in exactly one place (new_raster, imageop.c:2186) and consumed in exactly one place (the invalidation, imageop.c:2241-2242). The remaining call sites (imageop.c:82, 431, 2330) discard it. Genuine registrations still invalidate: g_hash_table_insert() returns TRUE the first time a module is added as a user, which is precisely the case the invalidation guards.

Note that g_hash_table_insert() reports key novelty, not value change, so a retarget to a different raster_mask_id on an existing source would not invalidate. This cannot occur today: a source exports exactly one raster slot (BLEND_RASTER_ID == 0), so raster_mask_id is constant for a given source.

Reproducing

With a raster mask whose source is early in the pipe and whose consumer is late, run with -d pipe and move any slider:

pipecache invalidate [full HQ]  blend new raster: 7 cachelines after ioporder=2700, blend cache

This repeats on every commit. With the fix it appears once, when the raster user is first registered.

@TurboGit pending @jenshannoschwalm approval, I am flagging this for 5.6.1 as (unless I am barking at the wrong tree, which is always possible when it comes to cache invalidation) it is a genuine bugfix.

Co-authored with Claude.

@masterpiga masterpiga added this to the 5.6.1 milestone Jul 9, 2026
@masterpiga masterpiga added bugfix pull request fixing a bug difficulty: trivial some changes in a couple of functions scope: image processing correcting pixels scope: performance doing everything the same but faster release notes: pending labels Jul 9, 2026
@jenshannoschwalm

Copy link
Copy Markdown
Collaborator
  1. There are many-many invalidations for cachelines and also for rastermasks that should not be necessary now.
  2. BUT that rastermask stuff was very errorprone some versions ago - i didn't touch that again sinvce then - and there needs to be a huge lot of testing and very careful reviewing. So i would prefer that for 5.8 although i am very aware of the performance implications for now.

@masterpiga

Copy link
Copy Markdown
Collaborator Author

Thanks, Hanno, updating the milestone accordingly.

@masterpiga masterpiga modified the milestones: 5.6.1, 5.8 Jul 9, 2026
@jenshannoschwalm

Copy link
Copy Markdown
Collaborator

I think this is correct after source reading & checking. I remember working on that code and even doing all the comments why "new" is important :-)

Three thoughts;

  1. Would it be easier to understand if we do the invalidation right "here" in dt_iop_commit_blend_params()
  2. I might be good to modify the logging code, we might always want to know about the "new" situation and we would ignore reports if not-new?
  3. I would prefer a "readable text" in the commit text explaing the why.

…sk is used

dt_iop_commit_blend_params() registers a module as a user of a raster-mask
source, and is supposed to report only genuinely new registrations so the
caller can invalidate that source's cache once. Instead it returned the
source module unconditionally on every call, so any history commit anywhere
in the pipe was treated as "a source just gained a raster user," discarding
every cacheline from the source onward — even though almost all of those
calls were re-registering an existing user, not adding a new one.

The invalidation itself is legitimate for the one case it exists for: a
source that gains a new user doesn't change its own params, so its
cacheline hash stays the same and it could otherwise be served from cache
without ever writing the mask the new user needs. But firing on every
commit instead of once means that a raster source sitting early in the
pipe — a common setup, e.g. a mask painted on an early exposure/tone module
and consumed by a later one — forces a full re-render of the pipe tail on
every unrelated slider move downstream. This is invisible on small images
and short pipes, and increasingly costly as either grows, since a
full-resolution intermediate buffer can be hundreds of MB and won't
re-establish itself between edits.

The fix makes the "new" check (already computed via g_hash_table_insert's
return value, but previously used only for a debug print) gate the return
value, so the function does what its own doc comment already promised:
return NULL except for a genuinely new source registration.

Found while profiling a downstream fork, where a raster-masked pipe
re-executed from the source on every mask edit (~2.8s) instead of only
recomputing the touched module (~0.17s). The invalidation mechanism and
the -d pipe evidence are identical on master, so stock darktable pays the
same unnecessary cost, scaled to image size and how early the raster
source sits in the pipe.
…ed registration flag

The previous fix (cec8ba9) decided
whether to invalidate a raster-mask source's cacheline by checking
whether the consumer's registration in the source module's shared users
table was brand new. That flag lives on the module and is shared across
every pipe (full preview, thumbnail preview, second window), and it is
consumed the first time anything touches that table.

In practice, the consumer usually gets added to that shared table by
GUI code, at the moment the user picks a raster mask from the blend
panel combo box, before any pixel pipe actually commits the new
blend parameters. By the time each pipe's own commit runs afterward,
the registration is no longer new, so the flag is already false for
every pipe. None of them invalidate the source's cacheline. If the
source's own parameters have not changed, its cacheline is still
warm, so the source is never reprocessed, and it never writes the
mask the consumer needs. The consumer silently blends with no mask
at all until something unrelated forces a full reprocessing later.
The same problem shows up when raster mask assignments are replayed
from history, such as during undo, redo, or loading an image with
existing history: the registration is consumed while replaying
history, before the real per-pipe commit that would need to react to
it.

The fix stops relying on that shared, one-shot flag entirely. When a
pipe commits a module that consumes a raster mask, it now looks at
that specific pipe's own copy of the source module and checks
whether a mask has already been stored there for the requested mask
id. If not, only that pipe's cacheline for the source is
invalidated, so the source reprocesses and writes the mask on this
pipe. If the mask is already present, nothing is invalidated, which
keeps the original goal of not forcing a full reprocess on every
unrelated edit.

Because the check is now local to each pipe instead of a single
flag shared across all of them, every pipe correctly detects and
recovers from the case where it is missing a mask, regardless of
whether the registration happened through the GUI, through history
replay, or through a normal pipe commit, and regardless of which
pipe reaches the code first.

Verified interactively with pipe and mask debug logging: assigning a
raster mask to a module for the first time now triggers a cacheline
invalidation on both the full and preview pipes at the moment of
assignment, the source module reprocesses and writes the mask
immediately, and the consuming module picks it up in the same run
without requiring any further unrelated edit.
@masterpiga masterpiga force-pushed the masterpiga-raster-mask-patch-1 branch from da111dd to a297709 Compare July 13, 2026 14:21
@masterpiga

Copy link
Copy Markdown
Collaborator Author

Thanks, Hanno. Done.

I also added a follow up commit suggested by @kofa73's army of agents.

@jenshannoschwalm

Copy link
Copy Markdown
Collaborator

The more i think about this, the lesser i think it's as trivial as the tag implies :-)

First: i don't think the "simple interactive test" is good enough by far. Multiple masks, en-disabling modules before, between and after raster creators/consumers, toggling HQ mode, mask visualizing passthru, ...

The big question to me is, why do we have to invalidate cachelines at all?
If we have to do it, this would mean the piece hash and thus the cacheline hash is wrong. And it's very likely wrong because we (yet) don't include the status of "module provided a raster mask".

So we should try to get that sorted out. After that, all cacheline invalidations would only be helpful to reduce stress on the caching for a better hit rate.

@masterpiga masterpiga added difficulty: average some changes across different parts of the code base and removed difficulty: trivial some changes in a couple of functions labels Jul 14, 2026
@masterpiga

Copy link
Copy Markdown
Collaborator Author

Changed difficulty tag to "average" as this is no longer a one-liner.

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 release notes: pending scope: image processing correcting pixels scope: performance doing everything the same but faster

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants