Skip to content

[charts] Make ordinal scales O(1) to copy and re-range#22691

Merged
JCQuintas merged 3 commits into
mui:masterfrom
JCQuintas:perf/ordinal-scale-copy
Jun 5, 2026
Merged

[charts] Make ordinal scales O(1) to copy and re-range#22691
JCQuintas merged 3 commits into
mui:masterfrom
JCQuintas:perf/ordinal-scale-copy

Conversation

@JCQuintas

Copy link
Copy Markdown
Member

Summary

Charts with a band/point axis over a large category domain were slow to pan and zoom. Every pan/zoom frame the axis scale is copied to apply the zoomed range, and for an ordinal scale that copy was O(domain). With ~1M categories this was ~255ms/frame (~4fps). Charts on linear axes were unaffected, so this mostly hit bar charts (and any categorical line/scatter).

Two costs dominated:

  • scaleBand.copy() rebuilt the domain InternMap and materialized the full position array on every rescale(), and selectorChartXScales copies the scale every frame.
  • getNormalizedAxisScale rebuilt the band scale on every render because the filtered-domains record is recreated per zoom, re-interning the domain.

Changes

  • Rewrite scaleBand so band positions are computed arithmetically in scale() (no materialized range array), and copy() shares the immutable domain index instead of rebuilding it. copy() + re-range drops from ~255ms to ~0.004ms per frame for a 1M-category domain, while still returning a fresh scale identity (so memoized consumers such as the grid keep updating on zoom). scalePoint is built on scaleBand, so it benefits too.
  • Cache the normalized ordinal scale by domain reference so a stable domain is not re-interned on each render.

No public API change. Behavior is unchanged (existing scaleBand/scalePoint, zoom, grid, and bar tests pass); only the cost of building/copying ordinal scales changes.

@JCQuintas
JCQuintas requested a review from alexfauquette as a code owner June 5, 2026 09:26
@JCQuintas JCQuintas added scope: charts Changes related to the charts. performance labels Jun 5, 2026
@JCQuintas JCQuintas self-assigned this Jun 5, 2026
@JCQuintas JCQuintas added internal Behind-the-scenes enhancement. Formerly called “core”. type: enhancement It’s an improvement, but we can’t make up our mind whether it's a bug fix or a new feature. and removed internal Behind-the-scenes enhancement. Formerly called “core”. labels Jun 5, 2026
@code-infra-dashboard

code-infra-dashboard Bot commented Jun 5, 2026

Copy link
Copy Markdown

Deploy preview

https://deploy-preview-22691--material-ui-x.netlify.app/

Bundle size

Bundle Parsed size Gzip size
@mui/x-data-grid 0B(0.00%) 0B(0.00%)
@mui/x-data-grid-pro 0B(0.00%) 0B(0.00%)
@mui/x-data-grid-premium 0B(0.00%) 0B(0.00%)
@mui/x-charts 🔺+160B(+0.04%) 🔺+71B(+0.06%)
@mui/x-charts-pro 🔺+160B(+0.03%) 🔺+51B(+0.03%)
@mui/x-charts-premium 🔺+321B(+0.05%) 🔺+112B(+0.06%)
@mui/x-date-pickers 0B(0.00%) 0B(0.00%)
@mui/x-date-pickers-pro 0B(0.00%) 0B(0.00%)
@mui/x-tree-view 0B(0.00%) 0B(0.00%)
@mui/x-tree-view-pro 0B(0.00%) 0B(0.00%)
@mui/x-license 0B(0.00%) 0B(0.00%)

Details of bundle changes

Performance

Total duration: 2,218.95 ms +353.66 ms(+19.0%) | Renders: 67 (+0) | Paint: 3,192.27 ms +518.52 ms(+19.4%)

Test Duration Renders
LineChart stacked area with multiple series 206.85 ms 🔺+54.82 ms(+36.1%) 2 (+0)
CandlestickChart with big data amount (webgl renderer) 246.44 ms 🔺+52.84 ms(+27.3%) 5 (+0)
BarChart stacked with multiple series 142.12 ms 🔺+31.38 ms(+28.3%) 3 (+0)
ScatterChartPremium with big data amount and zoomed in (webgl renderer) 99.02 ms 🔺+22.53 ms(+29.4%) 5 (+0)
RangeBarChart with big data amount 89.34 ms 🔺+17.15 ms(+23.8%) 3 (+0)

…and 8 more (+13 within noise) — details


Check out the code infra dashboard for more information about this PR.

Band/point axes over large category domains were rebuilt on every pan/zoom
frame. Two costs dominated:

- `scaleBand.copy()` rebuilt the domain InternMap and materialized the full
  position array (`O(domain)`), and `selectorChartXScales` copies the scale
  every frame to apply the zoomed range. For a 1M-category axis this was
  ~255ms/frame (~4fps); linear-axis charts were unaffected, so only bar
  charts (and other categorical axes) were slow.
- `getNormalizedAxisScale` rebuilt the band scale on every render because the
  filtered-domains record is recreated per zoom, re-interning the domain.

Changes:

- Rewrite `scaleBand` so positions are computed arithmetically (no
  materialized range array) and `copy()` shares the immutable domain index.
  copy+re-range drops from ~255ms to ~0.004ms per frame for 1M categories,
  while still returning a fresh scale identity (memoized consumers such as
  the grid keep updating on zoom).
- Cache the normalized ordinal scale, keyed by domain reference and scale
  variant (type + padding). The variant key matters because one `data` array
  can back several axes (e.g. a `band` and a `point` axis sharing it), which
  must not share a scale instance.

General improvement for any chart with a band/point axis; no API change.
@JCQuintas
JCQuintas force-pushed the perf/ordinal-scale-copy branch from 0c076ca to 6695b47 Compare June 5, 2026 09:43
Comment on lines 67 to 83
const rescale = () => {
const n = domain.length;
const reverse = r1 < r0;
const start = reverse ? r1 : r0;
const stop = reverse ? r0 : r1;
step = (stop - start) / Math.max(1, n - paddingInner + paddingOuter * 2);
reverse = r1 < r0;
const lo = reverse ? r1 : r0;
const hi = reverse ? r0 : r1;
step = (hi - lo) / Math.max(1, n - paddingInner + paddingOuter * 2);
if (isRound) {
step = Math.floor(step);
}
const adjustedStart = start + (stop - start - step * (n - paddingInner)) * align;
let adjustedStart = lo + (hi - lo - step * (n - paddingInner)) * align;
bandwidth = step * (1 - paddingInner);
const finalStart = isRound ? Math.round(adjustedStart) : adjustedStart;
const finalBandwidth = isRound ? Math.round(bandwidth) : bandwidth;
bandwidth = finalBandwidth;
const values = sequence(n).map((i) => finalStart + step * i);
ordinalRange = reverse ? values.reverse() : values;
if (isRound) {
adjustedStart = Math.round(adjustedStart);
bandwidth = Math.round(bandwidth);
}
start = adjustedStart;
return scale;

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 rewriting is a bit suspect, but I did not catch any difference

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

AI did it to remove sequence(n).map and values.reverse() from this calculation.

It moves reverse into scale fn above, making it kind of "lazy" instead of eager in the sense that it doesn't recompute unnecessarily.

@JCQuintas
JCQuintas merged commit 27222bc into mui:master Jun 5, 2026
21 checks passed
@JCQuintas
JCQuintas deleted the perf/ordinal-scale-copy branch June 5, 2026 14:05
mbrookes pushed a commit to mbrookes/mui-x that referenced this pull request Jun 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

performance scope: charts Changes related to the charts. type: enhancement It’s an improvement, but we can’t make up our mind whether it's a bug fix or a new feature.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants