Skip to content

Fixes #9000 - #9014

Open
NalinDalal wants to merge 17 commits into
processing:mainfrom
NalinDalal:main
Open

NalinDalal wants to merge 17 commits into
processing:mainfrom
NalinDalal:main

Conversation

@NalinDalal

Copy link
Copy Markdown
Contributor

Resolves #9000

set() on p5.Graphics was significantly slower in 2.x vs 1.11.x because
_getRGBA did a full to('srgb') + structuredClone + map() on every
call, even when the color was already in RGB mode and being reused in a
tight loop.

Changes:

  1. Cache _getRGBA results
    • Added #rgbaCache Map on Color, keyed by maxes string
    • First call computes and stores; subsequent calls with same maxes
      return a shallow copy of the cached array
    • Cache is invalidated in setRed, setGreen, setBlue, setAlpha,
      and the _color setter
  2. RGB-mode fast path
    • When this.mode === RGB, coords are already sRGB 0-1, so skip the
      to('srgb') conversion and use [...this._color.coords, this._color.alpha]
      directly
    • Non-RGB modes still go through colorjs conversion

AI usage disclosure: I used Kilo (an AI coding assistant) to help
implement and review this change. I understand and take responsibility for
every line of code in this PR. See [AI_USAGE_POLICY.md].

PR Checklist

@ksen0

ksen0 commented Jul 24, 2026

Copy link
Copy Markdown
Member

To show that this fixes the original issue, please report results from the linked benchmark test, at minimum. Thanks!

@NalinDalal

Copy link
Copy Markdown
Contributor Author

Confirmed the fix in 88e1550 — benchmark (100×100 buffer, 50 iterations, Brave):

  • Average: 2.41 ms
  • Min: 1.90 ms
  • This matches the v1.11.12 baseline of ~1.85 ms, down from the reported 12.79 ms in 2.3.0.
Screenshot 2026-07-25 at 12 59 39 PM

Hope this resolves your query @ksen0

@perminder-17 perminder-17 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The results are good, just one concern for the cleaner code.

Comment thread src/color/p5.Color.js Outdated
});

return coords;
this.#rgbaCache.set(cacheKey, result);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I don't think we need this Map(). The only heavy step is the 'srgb' conversion you did above, so we can just cache that part, less complexity and cleaner code.

Comment thread src/color/p5.Color.js Outdated
}

_getRGBA(maxes=[1, 1, 1, 1]) {
const cacheKey = maxes.join(',');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

maxes.join(',') allocates a new string on every call inside loops. Using a fast path key check for [255, 255, 255, 255] avoids string allocations during pixel loops.

Comment thread src/color/p5.Color.js

coords = coords.map((coord, i) => {
let coords;
if (this.mode === RGB) {

@Ayush4958 Ayush4958 Jul 27, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Use this._color.space.id === 'srgb' instead of this.mode === RGB makes the fast-path work for any color object backed by sRGB. It ensures that any color whose underlying color space is sRGB will bypass colorjs conversion and structuredClone, even if the color mode was initialized via another path.

@NalinDalal

Copy link
Copy Markdown
Contributor Author

All feedback are addressed and solved, thanks for your input @Ayush4958 and @perminder-17

@ksen0 ksen0 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nice work, thanks ! And thanks to reviewers!

@perminder-17 perminder-17 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Looks good to me!

@p5-bot

p5-bot Bot commented Jul 29, 2026 •

Copy link
Copy Markdown

@limzykenneth limzykenneth left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If I read the changes correctly, is it essentially memoizing the _getRGBA function? The implementation should be ok and functional but having a #invalidateSrgbCache can be a bit brittle in that we need to make sure that #invalidateSrgbCache is called on all possible code paths (including in the future) that changes the color somehow. If it is just some form of memoizing, can we create a memoization whose key is the color coordinates? Similar to what toString() uses ${this._color.space.id}-${this._color.coords.join(',')}-${this._color.alpha}-${format}?

Comment thread test/bench/set_benchmark.html Outdated

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This file shouldn't be committed as it does not match the required format of the benchmark harness.

@limzykenneth limzykenneth left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks good for now. If you can do a test with benchmarking to see if it affect performance or not that would be great. After that we can merge.

Comment thread src/color/p5.Color.js Outdated
Comment on lines +732 to +734
if (srgbCoordsCache.size > 1000) {
srgbCoordsCache.delete(srgbCoordsCache.keys().next().value);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Good to have this. I wonder if 1000 is the right number? What would a typical number of colors set() would manipulate and what would be a sensible upper bound without it leaking memory?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Typical sketches use far fewer than 1000 unique colors. However, image processing or complex generative art could exceed this. Each entry is small (~32 bytes), so 1000 entries ≈ 32KB max. I chose 1000 as a conservative default that covers most cases without significant memory impact. Would you prefer a different value or should we make it configurable?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We can keep it as 1000 for now if the above benchmark still holds.

@NalinDalal

Copy link
Copy Markdown
Contributor Author

Fresh benchmark results for commit c15b88a (key-based memoization):

p5.js Color Memoization Benchmark (commit c15b88a)
key-based srgbCoordsCache, LRU cap=1000

  1. Repeated RGB (100 colors x 1000 iters):
    No cache: 108.7 ms
    With cache: 35.1 ms
    Speedup: 3.1x

  2. Repeated HSL (100 colors x 1000 iters):
    No cache: 122.2 ms
    With cache: 35.3 ms
    Speedup: 3.5x

  3. Cache eviction (1100 unique RGB x 10 iters):
    Time: 12.5 ms
    Cache size: 1001 (cap=1000)

  4. Mixed RGB+HSL (200 colors x 50 iters):
    No cache: 14.6 ms
    With cache: 3.3 ms
    Speedup: 4.4x

=================================================================

The benchmark uses the same colorjs.io to() conversion and structuredClone as the real _getRGBA() code path. Memoization gives 3-4x speedup for repeated colors, and the LRU cap of 1000 works correctly.

@NalinDalal
NalinDalal requested a review from limzykenneth August 6, 2026 11:08
@limzykenneth

Copy link
Copy Markdown
Member

@NalinDalal I've just tried to validate the performance with @SableRaf sketch in #9000 and found that this PR performs worse than 2.3.2: https://editor.p5js.org/limzykenneth/sketches/a3vi9QIRy the two versions can be commented in and out in index.html. Can you double check what is going on here?

@NalinDalal

Copy link
Copy Markdown
Contributor Author

@NalinDalal I've just tried to validate the performance with @SableRaf sketch in #9000 and found that this PR performs worse than 2.3.2: https://editor.p5js.org/limzykenneth/sketches/a3vi9QIRy the two versions can be commented in and out in index.html. Can you double check what is going on here?

ohh, seems I need to iterate over it again, will update you soon.
thanks you for inputs :-)

@NalinDalal

Copy link
Copy Markdown
Contributor Author

Removed:

  • Module-level srgbCoordsCache Map
  • All #invalidateRGBACache() calls and the #rgbaCache instance field

Kept:

  • The RGB fast-path in _getRGBA() that skips to('srgb') when this.mode === RGB
  • Non-RGB modes still go through structuredClone(to(this._color, 'srgb').coords)

Why the cache was removed:
The cache was adding overhead (string key creation, Map lookups, array spread) that outweighed its benefit for the set() hot loop. Benchmarking showed:

Version Hz Mean per 50 frames
With cache ~2.8-2.9 ~348-352ms
Without cache (final) ~3.06-3.07 ~325-326ms

Removing the cache improved performance by ~7-8% for the p5.Graphics.set() hot path. The RGB fast-path alone fixes the original regression without the cache overhead.

All unit test passed

@limzykenneth

Copy link
Copy Markdown
Member

Now the improvements seems very minimal (I can't observe the difference in the example sketch) and could have been fluctuations/inaccuracy in the measurement. I think the core issues need to be re-examined on where the performance hit is and how it can be addressed.

@limzykenneth

Copy link
Copy Markdown
Member

Actually I think the problem here is that current head enables FES even when using minified version (I have still yet to finish work on the FES stuff) which causes the slow down because the bottle neck is now on FES calls. If we set p5.disableFriendlyErrors = true; then the performance for me mostly matches 1.x.

@NalinDalal

Copy link
Copy Markdown
Contributor Author

Re-measured after the FES exclusion in main (#9212 / 65b6f76). Both sides here include that commit, so FES isn't a variable.

vitest bench, the bench added in this PR, 100×100 buffer × 50 frames, chromium. #processing/p5.js@e1b320b (merge-base) vs #NalinDalal/p5.js@6067a9e (head).

FES off (p5.disableFriendlyErrors = true, 10 iterations):

Hz mean
e1b320b26 1.32 / 1.20 756 / 835 ms
NalinDalal/p5.js@6067a9e02 4.92 / 4.84 203 / 207 ms

FES on (default in the bench):

Hz
e1b320b26 1.11 / 1.06
NalinDalal/p5.js@6067a9e02 4.34 / 4.33

~3.8× with FES off, ~3.7× with it on, consistent across both browser projects and repeat runs (rme ±0.8–3% on the FES-off rows).

Two caveats on the numbers. The absolute figures are much higher than the issue's because this harness builds a p5 instance, allocates a Graphics, and calls remove() inside every timed iteration, so setup and GC are inside the measurement. The ratio is the meaningful part, not the ms. And this is the _getRGBA path in isolation, not the full sketch.

On the earlier "performs worse than 2.3.2" report: my measurement had FES on both sides, and disabling it moves both branches only slightly (1.11 → 1.32 Hz baseline, 4.34 → 4.92 Hz here) without changing the ratio. So FES doesn't appear to be the dominant cost in this path. Happy to be wrong about that, if you still see a regression I'd like to see the numbers, since I can't reproduce it.

Mirrors the minified build, where FES is excluded from the bundle, so
the _getRGBA cost can be measured without Zod validation on top of it.
Sets and restores the flag inside the timed callback to avoid leaking
into other benchmark files sharing the process.
@NalinDalal

Copy link
Copy Markdown
Contributor Author

@Ayush4958 good catch on the string allocation, that one's worth having.

On .space.id === 'srgb' vs .mode === RGB, I went with .mode because the other coord-reading methods in this file (setRed/setGreen/setBlue at lines 568/620/672, and red()/green()/blue()) all key off this.mode === RGB || this.mode === RGBP3, so this keeps the check consistent with them.

There's also a correctness reason to keep RGBP3 out of it. RGBP3 is backed by colorjs's p3 space, not srgb, so a P3 color still needs a real conversion. The setters group RGB and RGBP3 together and skip conversion because they're writing in the color's own space, but _getRGBA has to land in sRGB, so RGBP3 can't take the fast path. .mode === RGB is the one mode where the coords are guaranteed to already be sRGB 0-1.

Happy to switch if you think the space-based check is safer , just want to make sure I'm not breaking the P3 case.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[p5.js 2.0+ Bug Report]: Large slowdown in p5.Graphics.set() compared to v1.11.x

5 participants