Thanks to visit codestin.com
Credit goes to github.com

Skip to content

PERF: Defer tick materialization during Axes init/clear#31525

Open
eendebakpt wants to merge 7 commits intomatplotlib:mainfrom
eendebakpt:perf/lazy-axis-init
Open

PERF: Defer tick materialization during Axes init/clear#31525
eendebakpt wants to merge 7 commits intomatplotlib:mainfrom
eendebakpt:perf/lazy-axis-init

Conversation

@eendebakpt
Copy link
Copy Markdown
Contributor

@eendebakpt eendebakpt commented Apr 18, 2026

PR summary

The performance if matplotlibs ticks is a bottleneck in various plots. See for example the discussions and references in #5665, #31012, #29594.

In this PR we prevent materialization of the _LazyTickList when there are no ticks created yet. With the tick-materialization cascade gone from Axes.__clear, the spine transforms the cascade used to install as a side effect are installed explicitly at the end of __clear.

Benchmark results (updated):

nit_grid 8x8:                  [main] 177 ms ± 2 ms   -> [branch] 112 ms ± 24 ms:  1.58x faster
clear_grid 8x8:                 [main] 181 ms ± 2 ms   -> [branch] 139 ms ± 22 ms:  1.31x faster
reuse_axes 8x8:                 [main] 154 ms ± 1 ms   -> [branch] 53.8 ms ± 0.4 ms: 2.86x faster
fig100 clear+plot1000+legend:   [main] 9.45 ms ± 2.4 ms -> [branch] 3.28 ms ± 0.02 ms: 2.88x faster

Geometric mean: 2.03x faster
Benchmark script
# /// script
# requires-python = ">=3.10"
# dependencies = ['matplotlib', 'numpy', 'pyperf']
# ///
"""pyperf micro-benchmarks for matplotlib axis/tick init+clear cost.

"""
import pyperf

setup = """
import matplotlib
matplotlib.use("Agg")
# matplotlib.use("QtAgg")  # snap/glibc mismatch; use offscreen:
# import os; os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")

import numpy as np
import matplotlib.pyplot as plt

GRID = 8
rng = np.random.default_rng(0)
x1000 = np.arange(1000)
y1000 = rng.standard_normal(1000)

# Pre-create reusable figure for clear_grid / reuse_axes cases.
_fig_clear = plt.figure()
_fig_grid, _axs_grid = plt.subplots(GRID, GRID)
_axs_flat = _axs_grid.ravel()

# Warmup — first call pays font-cache / backend-init costs.
plt.close(plt.subplots(GRID, GRID)[0])
"""

runner = pyperf.Runner()

# Fresh figure each iter — full Axes.__init__ cost for an 8x8 grid.
runner.timeit(
    name="init_grid 8x8",
    stmt="fig, axs = plt.subplots(GRID, GRID); plt.close(fig)",
    setup=setup,
)

# Reuse one Figure, clear + re-populate with an 8x8 grid.
runner.timeit(
    name="clear_grid 8x8",
    stmt="_fig_clear.clear(); _fig_clear.subplots(GRID, GRID)",
    setup=setup,
)

# Iterate ax.clear() across an existing 8x8 grid.
runner.timeit(
    name="reuse_axes 8x8",
    stmt="[ax.clear() for ax in _axs_flat]",
    setup=setup,
)

# Figure num=100: clear + plot 1000-point line + legend. Reuses the same
# numbered figure across iterations so only clear+plot+legend is measured.
runner.timeit(
    name="fig100 clear+plot1000+legend",
    stmt=(
        "fig = plt.figure(num=100);"
        " fig.clear();"
        " ax = fig.add_subplot();"
        " ax.plot(x1000, y1000, label='y');"
        " ax.legend()"
    ),
    setup=setup,
)

Closes #23771.

AI Disclosure

Claude was used in identifying performance bottlenecks related to tick creation. Initially the goal was to create tick collections (as described in one of the references), but this approach seems to be a small change with large impact.

PR checklist

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@eendebakpt eendebakpt force-pushed the perf/lazy-axis-init branch from 5708dc8 to 3ac8224 Compare April 18, 2026 12:21
@eendebakpt eendebakpt marked this pull request as ready for review April 18, 2026 16:06
Copy link
Copy Markdown
Member

@timhoffm timhoffm left a comment

Choose a reason for hiding this comment

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

Thanks for the PR. The speedup is impressive, and the added complexity (rc caching) is bearable.

Strategically, I would like to move away from single-tick handling, but in the mean time this is a reasonable improvement.

Comment thread lib/matplotlib/axis.py
Comment on lines +965 to +971
had_major = bool(self.__dict__.pop('majorTicks', None))
had_minor = bool(self.__dict__.pop('minorTicks', None))
if had_major or had_minor:
try:
self.set_clip_path(self.axes.patch)
except AttributeError:
pass
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.

Is this a logical change or just rewriting? AFAICS we can impove the comment to something like

Delete the majorTicks / minorTicks instances so that the _LazyTickList descriptor is reactivated.

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.

This is a logical change as set_clip_path is no longer always executed. I updated the docstring

Comment thread lib/matplotlib/axis.py Outdated
Comment thread lib/matplotlib/axis.py Outdated
Comment thread lib/matplotlib/axis.py Outdated
try:
tick = instance._get_tick(major=self._major)
finally:
rc._update_raw(orig)
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.

Can rc_context be used here?

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.

I suggest to still define a private _rc_context_raw() context manager to simplify the code. The rcParams handling is very much distracting from the non-trivial tick handling code.

@eendebakpt
Copy link
Copy Markdown
Contributor Author

Strategically, I would like to move away from single-tick handling, but in the mean time this is a reasonable improvement.

Having tick collections is indeed the way to go. This change is orthogonal as it avoids some tick operations altogether. (but maybe if ticks are really fast that would not matter)

Comment thread lib/matplotlib/axis.py Outdated
# Initialize here for testing; later add API
self._major_tick_kw = dict()
self._minor_tick_kw = dict()
# Snapshot of rcParams at the time of the last ``Axis.clear`` (or
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.

Optional, but comments are never rendered, so ReST formatting is a bit of overkill here. Alternatives:

  • No quotes but parentheses

    [...] time of the last Axis.clear() (or

  • Single ticks as used in CPython error messages

    [...] time of the last 'Axis.clear'

Comment thread lib/matplotlib/axis.py
@@ -550,24 +550,48 @@ def __init__(self, major):
def __get__(self, instance, owner):
if instance is None:
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.

Since the logic complexity grows, we should afford a description.

Suggested change
if instance is None:
"""Materialize the desciptor to a list containing one properly configured tick."""
if instance is None:

Comment thread lib/matplotlib/axis.py Outdated
# may call reset_ticks(). Therefore, the final tick list is
# created and assigned afterwards.
attr = 'majorTicks' if self._major else 'minorTicks'
setattr(instance, attr, [])
Copy link
Copy Markdown
Member

@timhoffm timhoffm Apr 21, 2026

Choose a reason for hiding this comment

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

Can we keep one named instance around in the function?

  • create an empty list
  • assign it to the instance
  • create and configure the tick
  • add the tick to the list
  • return the list
        tick_list = []
        setattr(instance, attr, tick_list)  # must be defined before instance._get_tick()
        
        # create and configure the tick

        tick_list.append(tick)
        return tick_list

IMHO this is logically clearer than an empty placeholder list that get's replaced later and going back and forward through the instance using multiple getattr/setattr` (which are needed to have one execution path for majorTicks/minorTicks, which in turn is needed because the logical complexity of tick configuring rises and we don't want to duplicate that in two branches for major/minor

Comment thread lib/matplotlib/axis.py Outdated
Copy Axis clip state onto a lazily-created Tick.

`Axis.set_clip_path` runs during ``Axes.__clear`` before any Tick
exists under the lazy-init refactor, so the clip stored on the
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.

"lazy-init refactor" is a development activity/artifact, not an architectual concept. Also, I'm not exactly sure what "lazy-init refactor" is. Is it the introduction of _LazyTickList or this PR? Please rephrase.

Comment thread lib/matplotlib/axis.py Outdated
return [self.label, self.offsetText,
*self.get_major_ticks(), *self.get_minor_ticks()]

def _propagate_axis_state_to_tick(self, tick):
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 could be moved to a method tick._configure_for_axis(axis). Conceptually the tick should know how it needs to be configured; e.g. the Axis should not have to know that the tick itself and its gridline need to be clipped.

Comment thread lib/matplotlib/axis.py Outdated
try:
tick = instance._get_tick(major=self._major)
finally:
rc._update_raw(orig)
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.

I suggest to still define a private _rc_context_raw() context manager to simplify the code. The rcParams handling is very much distracting from the non-trivial tick handling code.

Comment thread lib/matplotlib/axes/_base.py Outdated
for spine in self.spines.values():
if spine._position is None and spine._transform is self.transData:
spine._position = ('outward', 0.0)
spine.set_transform(spine.get_spine_transform())
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 we're accessing private information like this, is there a reason we don't just call _ensure_position_is_set like in the comment?

eendebakpt and others added 3 commits April 23, 2026 21:36
@eendebakpt eendebakpt force-pushed the perf/lazy-axis-init branch from a3f1be1 to d0ed04c Compare April 23, 2026 19:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: matplotlib.pyplot.clf is very slow

3 participants