Skip to content

PERF: Cache ticks and ticklabel bboxes within each draw cycle#31012

Open
scottshambaugh wants to merge 8 commits intomatplotlib:mainfrom
scottshambaugh:ticks_cache
Open

PERF: Cache ticks and ticklabel bboxes within each draw cycle#31012
scottshambaugh wants to merge 8 commits intomatplotlib:mainfrom
scottshambaugh:ticks_cache

Conversation

@scottshambaugh
Copy link
Contributor

@scottshambaugh scottshambaugh commented Jan 22, 2026

PR summary

Towards #5665

Currently we are calling _update_ticks() 3 times per axis every draw call, and _get_ticklabel_bboxes 2 times per axis. These calls take up about 35% of total draw time for an empty plot.

We can eliminate 25% of total draw time by caching the results of these calculations every draw cycle. This introduces some state, but I think it's decently well guarded and the performance boost is definitely worth it.

Before & After
The circled red areas show the before & after runtime of axis._update_label_position within Axis.draw(). It runs after the cache values have been set by other functions, so we nearly completely eliminate its runtime.

Before:
image

After:
image

Profiling script:

import time
import matplotlib.pyplot as plt

fig, ax = plt.subplots()

print("Timing...")
start_time = time.perf_counter()
for i in range(100):
    fig.canvas.draw()
end_time = time.perf_counter()

plt.close()
print(f"Time taken: {end_time - start_time:.4f} seconds")

PR checklist

@scottshambaugh scottshambaugh marked this pull request as ready for review January 22, 2026 02:32
@scottshambaugh scottshambaugh mentioned this pull request Jan 22, 2026
1 task
@scottshambaugh scottshambaugh changed the title PERF: Cache ticks and tick label bboxes each draw cycle PERF: Cache ticks and ticklabel bboxes within each draw cycle Jan 22, 2026
@timhoffm
Copy link
Member

Is this safe? Are you sure they are always identical? For example constrained layout draws twice and the Axes size and limits may have changed in the second draw.

Or asking the other way round: if the updates are redundant, why are we doing them in the first place?

@scottshambaugh
Copy link
Contributor Author

scottshambaugh commented Jan 22, 2026

get_tightbbox() which is called during constrained_layout is flagged here to ignore the cache - beyond that I can't find anywhere that we do an update on these values in the middle of a draw cycle (and the tests aren't complaining, though I know that's not fully exculpatory).

As to why we're doing them multiple times in the first place - in 3D we need to access these values in the tick drawing and the grid drawing paths, which might not both be called so both needed to potentially refresh the calcs.

In 2D, I don't see a reason - my hunch it was done without realizing the performance hit of recalculating that state each time.

# axis.draw()
self._clear_ticks_cache()

ticks_to_draw = self._update_ticks(_use_cache=True)
tlb1, tlb2 = self._get_ticklabel_bboxes(ticks_to_draw, renderer, _use_cache=True)

for tick in ticks_to_draw:
    tick.draw(renderer)

self._update_label_position(renderer, _use_cache=True)
self.label.draw(renderer)

self._update_offset_text_position(tlb1, tlb2)
self.offsetText.set_text(self.major.formatter.get_offset())
self.offsetText.draw(renderer)

renderer.close_group(__name__)
self._clear_ticks_cache()

@scottshambaugh
Copy link
Contributor Author

scottshambaugh commented Jan 22, 2026

Actually, looking at this again, we can also cache within the get_tightbbox function. I see a similar speedup when profiling with fig.tight_layout() in the inner loop now, and the overall impact is more dramatic since we're doing everything twice.

Before:
image

After:
image

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.

2 participants