Skip to content

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

Merged
scottshambaugh merged 9 commits into
matplotlib:mainfrom
eendebakpt:perf/lazy-axis-init
May 15, 2026
Merged

PERF: Defer tick materialization during Axes init/clear#31525
scottshambaugh merged 9 commits into
matplotlib:mainfrom
eendebakpt:perf/lazy-axis-init

Conversation

@eendebakpt

@eendebakpt eendebakpt commented Apr 18, 2026

Copy link
Copy Markdown
Contributor

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

@timhoffm timhoffm left a comment

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.

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 thread lib/matplotlib/axis.py Outdated
Comment thread lib/matplotlib/axis.py Outdated
Comment thread lib/matplotlib/axis.py Outdated
@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
Comment thread lib/matplotlib/axis.py
Comment thread lib/matplotlib/axis.py Outdated
Comment thread lib/matplotlib/axis.py Outdated
Comment thread lib/matplotlib/axis.py Outdated
Comment thread lib/matplotlib/axis.py Outdated
Comment thread lib/matplotlib/axes/_base.py Outdated
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
Comment thread lib/matplotlib/axis.py Outdated
Comment thread lib/matplotlib/axes/_base.py Outdated
Comment thread lib/matplotlib/axes/_base.py Outdated
Comment thread lib/matplotlib/axis.py Outdated

@timhoffm timhoffm left a comment

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.

Thanks, this is a substantial and concise improvement now!

@eendebakpt

Copy link
Copy Markdown
Contributor Author

Thanks, this is a substantial and concise improvement now!

Thanks for reviews! This was (and still is) tricky to get right. If you have more suggestions (in particular for additional tests) let me know

@scottshambaugh

scottshambaugh commented May 15, 2026

Copy link
Copy Markdown
Contributor

Can confirm the speedup, pretty impressive IMO! Circled bits are removed from this profiling using your clf script.
image

@scottshambaugh scottshambaugh merged commit 81a1e03 into matplotlib:main May 15, 2026
46 checks passed
@scottshambaugh scottshambaugh added this to the v3.12.0 milestone May 15, 2026
@mathause

Copy link
Copy Markdown
Contributor

I think this causes an issue for cartopy (SciTools/cartopy#2674)

@eendebakpt

Copy link
Copy Markdown
Contributor Author

@mathause Thanks for reporting. I have a branch with a potential fix (unverified yet). Could you check whether that works? main...eendebakpt:matplotlib:fix/clear-custom-spine-type

@rcomer

rcomer commented May 16, 2026

Copy link
Copy Markdown
Member

[Picking up my Cartopy hat]

Thanks for catching that @mathause and for the quick fix @eendebakpt. I confirm I reproduced the error in the Cartopy tests, and that the tests pass with the new branch.

timhoffm pushed a commit that referenced this pull request May 17, 2026
PR #31525 made Axes.__clear call Spine._ensure_transform_is_set on
every spine, which calls set_position(('outward', 0.0)) for a spine
that still carries the placeholder transform from Spine.__init__.
Custom spines such as cartopy's GeoSpine have a non-cartesian
spine_type, manage their own transform, and may reject set_position,
so this raised NotImplementedError on plain subplot creation.

Restrict the spine nudge to the four standard cartesian spine types,
the only ones set_position / get_spine_transform support.

See SciTools/cartopy#2674.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
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

6 participants