Skip to content

PERF: Let FreeType read font data from memory instead of through Python - #32064

Merged
tacaswell merged 5 commits into
matplotlib:mainfrom
scottshambaugh:font_mmap
Aug 7, 2026
Merged

PERF: Let FreeType read font data from memory instead of through Python#32064
tacaswell merged 5 commits into
matplotlib:mainfrom
scottshambaugh:font_mmap

Conversation

@scottshambaugh

@scottshambaugh scottshambaugh commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

PR summary

FT2Font currently re-reads data from the font file on every glyph load, costing 10 lseek and 4 read syscalls per glyph on every FT2Font.set_text call. This is still relatively fast on a local filesystem, but imposes huge IO strain on remote filesystems (WSL's /mnt/c bridge to windows in my particular case, but also network mounts).

This PR mmaps the font file and hands the buffer to FreeType, so glyph loads skip the Python layer and only touch the filesystem once. Python still handles opening the file for unicode path handling, with a test added for that.

Note that we build freetype with meson's auto_features=disabled, so its mmap=auto line was a noop and removed in this PR. It doesn't matter here since we're handing freetype the data instead of a path.

Measuring syscalls directly on the font file for 30 glyphs across 10 set_text calls, we drop from ~2900 lseeks and ~1200 reads to 1 lseek and 1 mmap. When using the local linux filesystem on my machine, set_text is ~15x faster and a savefig on an empty plot is ~1.5x faster. When I cross the WSL filesystem boundary, the speedups are ~1000x for set_text and ~10x for savefig.

import matplotlib.pyplot as plt
for _ in range(50):
    fig, ax = plt.subplots()
    fig.savefig("/dev/null", format="png")
    plt.close(fig)

Before (Across WSL filesystem boundary):

image

After:
image

AI Disclosure

Discovered myself, investigated and prototyped with claude code, manually reviewed / edited.

PR checklist

@tacaswell

Copy link
Copy Markdown
Member

How does this intersect with a (hot) disk cache? What is the memory hit (discussed this in person with @ksunden ).

@scottshambaugh

scottshambaugh commented Jul 19, 2026

Copy link
Copy Markdown
Contributor Author

My understanding of how this works is that the disk cache is on the kernel side, and doesn't help with the userspace calls to fetch that data (much less the python overhead). I believe the reason the WSL bridge/network drives are so much slower here is because they can't have a disk cache. mmap forces that cache to happen, and skips the kernel for direct memory reads, so there's no additional memory usage (for local filesystems) or syscall/python overhead (for either). If the file changes underneath while we're running then the values we've already loaded to ram will be stale, but I don't think that's behavior we need to protect for.

Measuring it, DejaVuSans is ~738kB on disk, and for the ascii character set mmap lazily loads only ~292kB of this into ram (mostly headers). But again, I think this memory usage is just the disk cache made explicit and not actually additional. It's a free win AFAICT.

Comment thread src/ft2font_wrapper.cpp
@tacaswell

Copy link
Copy Markdown
Member

@QuLogic How do we get the WASM tests to run?

@QuLogic

QuLogic commented Jul 23, 2026

Copy link
Copy Markdown
Member

@QuLogic How do we get the WASM tests to run?

One could normally just add the cibuildwheel label, but currently the WASM build doesn't run any tests because they are broken, so you will only get a compile check.

Comment thread extern/meson.build
Comment thread src/ft2font_wrapper.cpp Outdated
Comment thread src/ft2font_wrapper.cpp Outdated
Comment on lines +541 to +542
// Fall back to a copy of the whole file into memory.
data = self->py_file.attr("read")();

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'm a bit wary of this. While most single-language fonts can be relatively small, fonts that cover large swaths of Unicode, like CJK fonts, may be much bigger. On Google Fonts, the largest font is Chiron Sung HK which is over 50 MB. And if someone configures fallback fonts, they will now all get loaded in their entirety, causing a large increase in memory usage after this.

As a worst case, if we start supporting full family loading (i.e., all weights of a family), then Noto Sans CJK in all its weights takes up 125MiB:

$ du -hsc /usr/share/fonts/google-noto-sans-cjk-fonts/*
19M	/usr/share/fonts/google-noto-sans-cjk-fonts/NotoSansCJK-Black.ttc
20M	/usr/share/fonts/google-noto-sans-cjk-fonts/NotoSansCJK-Bold.ttc
18M	/usr/share/fonts/google-noto-sans-cjk-fonts/NotoSansCJK-DemiLight.ttc
18M	/usr/share/fonts/google-noto-sans-cjk-fonts/NotoSansCJK-Light.ttc
18M	/usr/share/fonts/google-noto-sans-cjk-fonts/NotoSansCJK-Medium.ttc
19M	/usr/share/fonts/google-noto-sans-cjk-fonts/NotoSansCJK-Regular.ttc
16M	/usr/share/fonts/google-noto-sans-cjk-fonts/NotoSansCJK-Thin.ttc
125M	total

@timhoffm timhoffm Jul 23, 2026

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.

Would it be an option to do the pre-read into memory only for "small" fonts? With a suitable definitions of small, possibly even configurable.
This could still yield a speed up for common cases but prevent excessive memory use.

@scottshambaugh scottshambaugh Jul 23, 2026

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.

I'd be okay with moving this fallback back to the original implementation, I think the remaining slow path of WASI reading fonts over a network is a super edge case and we're getting into a storage vs speed tradeoff regardless.

Pushed that as a new commit, can revert pending discussion.

Comment thread extern/meson.build
@tacaswell
tacaswell merged commit 0098e7f into matplotlib:main Aug 7, 2026
42 checks passed
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.

4 participants