Skip to content

Memoize Color Translations - #8055

Open
robertclaus wants to merge 3 commits into
mainfrom
memoize-color-translations
Open

robertclaus wants to merge 3 commits into
mainfrom
memoize-color-translations

Conversation

@robertclaus

@robertclaus robertclaus commented Sep 17, 2026

Copy link
Copy Markdown

Fixes #8054

This PR adds memoization to the color string parsing functions. This way we do not need to re-parse the color strings for every single mark. Benchmarking indicates that this makes marker-heavy scatter charts render almost twice as quickly.

Testing

On both main and this branch, do the following:

  1. Run npm start to open the dashboard on port 3000.
  2. Run the following:
const gd = document.getElementById('graph');
const n = 1e5, x = new Float64Array(n), y = new Float64Array(n);
for (let i = 0; i < n; i++) { x[i] = i; y[i] = Math.sin(i / 500); }
const runs = [];
for (let k = 0; k < 5; k++) {
    await Plotly.purge(gd);
    const t = performance.now();
    await Plotly.newPlot(gd, [{type: 'scatter', mode: 'markers', x, y}],
        {width: 900, height: 600}, {displayModeBar: false});
    runs.push(+(performance.now() - t).toFixed(1));
}
runs.sort((a, b) => a - b);
console.log('median', runs[2], runs);

Note the difference in numbers.

Comment thread src/components/color/index.js Outdated
// Stop growing rather than evicting: a graph only ever uses a
// handful of distinct colors, so a full cache means array-valued
// colors, which repeat too little to be worth tracking.
if (cache.size < MAX_MEMO_SIZE) cache.set(cstr, value);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When MAX_MEMO_SIZE reached one could evict first half of the cache.

You could split the cache into two separate Maps: currentCache and oldCache.

  • Lookups check currentCache first, then oldCache (and move the item to currentCache if found).
  • Writes only go to currentCache.
  • When currentCache reaches the maximum capacity limit, you simply wipe oldCache entirely
oldCache.clear();

and swap the references:

oldCache = currentCache; 
currentCache = new Map();

This provides a near-instantaneous O(1) bulk eviction without any loops.

@archmoj

archmoj commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Thanks @robertclaus for the PR.
It's a good optimization opportunity specially with constant color cases.

@archmoj

archmoj commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Please also double check the results using random marker colors.
I tested this and the PR version seems slower.

function generateRandomColors(count) {
  const colors = [];
  
  for (let i = 0; i < count; i++) {
    // Generate a random number up to 16777215 (FFFFFF in hex)
    // Convert it to base-16 and pad with leading zeros if it's shorter than 6 characters
    const randomHex = Math.floor(Math.random() * 16777215)
      .toString(16)
      .padStart(6, '0');
      
    colors.push(`#${randomHex}`);
  }
  
  return colors;
}

var gd = document.getElementById('graph');
var n = 1e5, x = new Float64Array(n), y = new Float64Array(n);
for (let i = 0; i < n; i++) { x[i] = i; y[i] = Math.sin(i / 500); }
var randomColors = generateRandomColors(n)
var runs = [];
for (let k = 0; k < 5; k++) {
    await Plotly.purge(gd);
    const t = performance.now();
    await Plotly.newPlot(gd, [{type: 'scatter', mode: 'markers', marker: {color: randomColors}, x, y}],
        {width: 900, height: 600}, {displayModeBar: false});
    runs.push(+(performance.now() - t).toFixed(1));
}
runs.sort((a, b) => a - b);
console.log('median', runs[2], runs);

Comment thread src/components/color/index.js Outdated
*/
const fill = (s, cstr) => {
s.style({ fill: rgb(cstr), 'fill-opacity': parse(cstr).alpha });
s.style({ fill: rgb(cstr), 'fill-opacity': alphaOf(cstr) });

@archmoj archmoj Sep 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Alternatively instead of the cash we may benchmark this option of by passing previous stroke and previous fill. Something like this:

var prevStrokeSTR;
var prevStrokeRGB;
var prevStrokeAlpha;

function getStroke(cstr) {
  if(prevStrokeSTR !== cstr) {
    prevStrokeSTR = cstr;
    prevStrokeRGB = rgb(cstr);
    prevStrokeAlpha = parse(cstr).alpha;
  }
  return [prevStrokeRGB, prevStrokeAlpha];
}

const stroke = (s, cstr) => {
    const v = getStroke(cstr);
    s.style({ stroke: v[0], 'stroke-opacity': v[1] });
};

var prevFillSTR;
var prevFillRGB;
var prevFillAlpha;

function getFill(cstr) {
  if(prevFillSTR !== cstr) {
    prevFillSTR = cstr;
    prevFillRGB = rgb(cstr);
    prevFillAlpha = parse(cstr).alpha;
  }
  return [prevFillRGB, prevFillAlpha];
}

const fill = (s, cstr) => {
    const v = getFill(cstr);
    s.style({ fill: v[0], 'fill-opacity': v[1] });
};

`stroke` and `fill` each asked the memo twice — once for the rgb string
and once for the alpha — so a cache miss paid for two guarded lookups and
two full `parse` calls. With a distinct color per point nothing ever hits,
and that overhead showed up as a small regression against main.

Cache the pair instead, derived from a single `parse`, and leave the
exported `rgb` unmemoized so its many cold callers stop paying for the
wrapper. Colors that repeat still cost one lookup, and colors that don't
now parse once per point instead of twice.

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

Copy link
Copy Markdown
Author

@archmoj I did some more optimization am now getting performance improvements even with unique colors.

Screenshot 2026-09-21 at 10 14 53 AM

@archmoj

archmoj commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Thanks @robertclaus for the update. Could you also benchmark this suggestion with no cash lookup as well?

@robertclaus

Copy link
Copy Markdown
Author

@archmoj sorry! I forgot to include those results. The single value cache approach degraded immediately with multiple color values. I also ran tests with different cache sizes and saw the same thing: once you have more color values than cache space your performance drops very quickly back to the baseline.

In this table Branch is the cache approach, Proposed is the single value.
Screenshot 2026-09-21 at 12 50 43 PM

@archmoj

archmoj commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Thanks @robertclaus for the new table.
It demonstrated the importance of caching multiple colors & styles.
But I still think we may be able to improve this by evicting (e.g. the first half of the cache when reaching the maximum capacity) specially for the apps than should run for quite a while e.g. when the color styles are updated from initial colors to new colors the cache should still help.

@archmoj

archmoj commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Using the third method below (Two-Map Swapping) give me 0.00 ms eviction time for dropping old items.

Method Execution Time
1. Naive Delete 200.70 ms
2. Rebuild New Map 192.00 ms
3. Two-Map Swapping 0.00 ms

Running benchmarks (1,000,000 items)...

const ELEMENT_COUNT = 1_000_000;

// Setup mock data
const keys = Array.from({ length: ELEMENT_COUNT }, (_, i) => `key_${i}`);
const values = Array.from({ length: ELEMENT_COUNT }, (_, i) => ({ data: i }));

/**
 * Approach 1: Naive Iteration + Delete
 * Loops through the map iterator and deletes the first half of the elements.
 */
function benchmarkNaiveDelete() {
    const map = new Map();
    for (let i = 0; i < ELEMENT_COUNT; i++) map.set(keys[i], values[i]);

    const start = performance.now();
    
    let evicted = 0;
    const target = ELEMENT_COUNT / 2;
    for (const key of map.keys()) {
        if (evicted >= target) break;
        map.delete(key);
        evicted++;
    }
    
    const end = performance.now();
    return end - start;
}

/**
 * Approach 2: Rebuild a New Map
 * Extracts the surviving entries and passes them into a new Map constructor.
 */
function benchmarkRebuildMap() {
    let map = new Map();
    for (let i = 0; i < ELEMENT_COUNT; i++) map.set(keys[i], values[i]);

    const start = performance.now();
    
    // Take the second half of entries and instantiate a new Map
    const survivingEntries = Array.from(map.entries()).slice(ELEMENT_COUNT / 2);
    map = new Map(survivingEntries);
    
    const end = performance.now();
    return end - start;
}

/**
 * Approach 3: Two-Map Swapping (Generation/Age-based Cache)
 * Instead of one giant map, reads query both "old" and "new". 
 * Eviction is just clearing "old" and swapping references.
 */
function benchmarkTwoMapSwapping() {
    let oldMap = new Map();
    let newMap = new Map();

    // Populate evenly across both to simulate standard two-map state
    const half = ELEMENT_COUNT / 2;
    for (let i = 0; i < half; i++) oldMap.set(keys[i], values[i]);
    for (let i = half; i < ELEMENT_COUNT; i++) newMap.set(keys[i], values[i]);

    const start = performance.now();
    
    // EVICTION ROUTINE:
    // 1. Wipe out the oldest half completely in O(1) time
    oldMap.clear(); 
    // 2. Rotate the references (newMap becomes oldMap, empty map becomes newMap)
    oldMap = newMap;
    newMap = new Map(); // Ready to receive fresh writes
    
    const end = performance.now();
    return end - start;
}

// Execute benchmarks
console.log("Running benchmarks (1,000,000 items)...");
const timeNaive = benchmarkNaiveDelete();
const timeRebuild = benchmarkRebuildMap();
const timeSwap = benchmarkTwoMapSwapping();

console.log(`1. Naive Delete:       ${timeNaive.toFixed(2)} ms`);
console.log(`2. Rebuild New Map:     ${timeRebuild.toFixed(2)} ms`);
console.log(`3. Two-Map Swapping:    ${timeSwap.toFixed(2)} ms`);

@camdecoster camdecoster self-assigned this Sep 21, 2026
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.

[BUG]: Color Translation is Costly

3 participants