PERF: Text rendering speedups by scottshambaugh · Pull Request #32120 · matplotlib/matplotlib · GitHub
Skip to content

PERF: Text rendering speedups - #32120

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

scottshambaugh wants to merge 8 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.
8 - 2.44x Code review updates

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!

@scottshambaugh

scottshambaugh commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

Hey @iccir, I got your review comments via email but they don't show when I click through... not sure how well comments on a commit work. But to respond:

load_glyph, load_glyph_copy, and load_glyph_cached all have different return types. Would it make more sense to have linear_hori_advance (and the glyph copy) always be output ptrs? I'm not sure of our exact naming conventions.

I like that! Added a commit with those changes. Made a specific GlyphPtr to handle freeing automatically.

Is there an additional performance gain if we cache the transform matrix? My first thought was that extend/slant might be changing for each glyph, but this doesn't appear to be the case?
(Disregard as this code was moved to cpp in a later commit)
Same comment as before in the Python version - is it worth caching the 2x2 matrix if extend/slant isn't changing for each glyph. I'm guessing that it didn't show up in the profiler.

Yeah, I don't see this showing up in profiling results at all.

@iccir iccir left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I checked the new GlyphPtr commit as I had already checked the others when I last reviewed.

I think everything looks good; however, this is right at the edge of my Python/C++ knowledge. I won't be offended if you want a 3rd pair of eyes :)

@iccir

iccir commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Yeah, I don't see this showing up in profiling results at all.

I think that makes sense. FT_Set_Transform is simply setting some private instance variables. FT_Set_Char_Size calls down into FT_Request_Size and does all kinds of fun stuff!

Comment thread src/_backend_agg.h
}
}
auto const& clip = text_clip_rect(gc.cliprect);
auto data = buffer.data(0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think you need to add the composite operator before this loop here after the blend group PRs pixFmt.comp_op(gc.comp_op);

Comment thread src/ft2font.cpp
Comment on lines +473 to +475

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is there an LRU cache eviction we can use instead of clearing and restarting?

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.

3 participants