Skip to content

PERF: Text rendering speedups - #32120

Open
scottshambaugh wants to merge 7 commits into
matplotlib:mainfrom
scottshambaugh:text_render_perf
Open

PERF: Text rendering speedups#32120
scottshambaugh wants to merge 7 commits into
matplotlib:mainfrom
scottshambaugh:text_render_perf

Conversation

@scottshambaugh

@scottshambaugh scottshambaugh commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

PR summary

Inspired by #32064, I dug into text rendering to try and see what we could speed up. Through a combination of caching, lazy loading, and combining runs of glyphs into a single rasterization pass, total figure draw time on my simple demo script below is sped up by 2.4x. This is separate from (and stacks on top of) the improvements in #32064. Beyond the immediate rendering, text is also handled in figure/subplot layout, so there are speedups across the entire call tree.

This is a big diff and stacked PRs aren't available yet, so is probably easiest to review by commit. Each is self contained, and here is a table describing them:

# Commit Incremental Speedup Total Speedup Description
1 d002b6c8b5 1.13x 1.13x Avoid per-glyph numpy overhead and repeated calcs.
2 93436b4048 1.18x 1.33x Depending on the font, the hinting that snaps outlines to the pixel grid can cost more than rendering them. This caches the loaded outline to avoid that cost, keyed on everything except the translation, so a repeated glyph is only hinted once.
3 681b265ea1 1.14x 1.52x Cache shaped layouts (bidirectional ordering and shaping). The key with shaping_state() is more complex here, as the result depends on the state of this face and of every fallback face it may shape with.
4 0587aa44b9 1.26x 1.91x Kerning was reloading every glyph and re-running the hinter. But we already calculated the advance width, so take that from the glyph cache instead. Same thing for measuring text, which now uses the outlines from the cache. This drops per-glyph hinting passes from 3x to 1x.
5 33c40f64c9 1.16x (svg), 1.21x (pdf) - Lazily load glyphs when saving figures, which skips loads on the vector (svg and pdf) backends which don't need it for get_path().
6 51d14736da 1.15x 2.19x Position and rasterize a run of glyphs in one call, then blit it in one call. Agg only right now. Avoids per-glyph Python/C++ boundary crossings.
7 e133bf037c 1.11x 2.44x Cache the font height metrics used by Text._get_layout.

Before (4.03 sec draw):
image

After (1.86 sec draw):
image

Benchmark script:

import statistics
import time
import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(8, 6), dpi=100)

# Simple plot with several types of text
ax.plot([1, 2, 3], [1, 4, 2], label="series one")
ax.set_title("Rendering a title with plenty of glyphs")
ax.set_xlabel("x axis label")
ax.set_ylabel("y axis label")
ax.legend()

for i, text in enumerate(["one", "two", "three", "four"]):
    ax.text(1, 1 + i * 0.5, "annotation" + text)
ax.text(2, 3, "rotated annotation", rotation=45)

fig.canvas.draw()  # warm the cache

times = []
for _ in range(50):
    start = time.process_time()
    fig.canvas.draw()
    times.append(time.process_time() - start)

print(f"draw: median={statistics.median(times) * 1e3:.2f} ms  "
      f"min={min(times) * 1e3:.2f} ms")

AI Disclosure

Lots of help from claude on the first pass for this one. I spent several hours reviewing and polishing its draft, and am confident in each of the changes. What I am less confident on is potential corner cases of the text rendering pipeline that I'm unfamiliar with, so would appreciate @QuLogic to take a look through this when he has the time.

PR checklist

@iccir

iccir commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Out of curiosity, have you seen get_sfnt_table show up while profiling? It showed up when I was working on macOS resizing/drawing performance, but I forgot to write down which example caused it.

@scottshambaugh

Copy link
Copy Markdown
Contributor Author

@iccir yup! That showed up and is addressed (at least in this use case) by commit 7's caching.

@iccir

iccir commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

@iccir yup! That showed up and is addressed (at least in this use case) by commit 7's caching.

Fantastic! I thought that might be the case, but I wasn't 100% sure. Thanks for letting me know, now I can stop trying to find the example that caused it!

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