Memoize Color Translations - #8055
robertclaus wants to merge 3 commits into
Conversation
| // 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); |
There was a problem hiding this comment.
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
currentCachefirst, thenoldCache(and move the item tocurrentCacheif found). - Writes only go to
currentCache. - When
currentCachereaches the maximum capacity limit, you simply wipeoldCacheentirely
oldCache.clear();and swap the references:
oldCache = currentCache;
currentCache = new Map();This provides a near-instantaneous O(1) bulk eviction without any loops.
|
Thanks @robertclaus for the PR. |
|
Please also double check the results using random marker colors. 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) }); |
There was a problem hiding this comment.
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>
|
@archmoj I did some more optimization am now getting performance improvements even with unique colors.
|
|
Thanks @robertclaus for the update. Could you also benchmark this suggestion with no cash lookup as well? |
|
@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. |
|
Thanks @robertclaus for the new table. |
|
Using the third method below (Two-Map Swapping) give me
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`); |


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
mainand this branch, do the following:npm startto open the dashboard on port 3000.Note the difference in numbers.