Skip to content

Memoize Color Translations - #8055

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

robertclaus wants to merge 2 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.

// 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);

*/
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] });
};

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

2 participants