Skip to content

perf: cache text fonts per size and initialize the font module on demand - #21

Merged
dmccoystephenson merged 1 commit into
mainfrom
feature/cache-and-init-text-font
Jul 26, 2026
Merged

perf: cache text fonts per size and initialize the font module on demand#21
dmccoystephenson merged 1 commit into
mainfrom
feature/cache-and-init-text-font

Conversation

@dmccoystephenson

@dmccoystephenson dmccoystephenson commented Jul 26, 2026

Copy link
Copy Markdown
Member

Summary

  • Cache pygame.font.Font instances per size on the Graphik instance instead of building a new one on every drawText. Measured locally (pygame 2.6.1, Python 3.10): constructing a Font costs 3.899 ms, the render() it exists to serve costs 0.005 ms — so ~99.9% of drawText was rebuilt work, paid again every frame by every label and by every drawButton. drawText now measures 0.035 ms/call over 200 calls. Closes perf: drawText rebuilds a pygame Font on every call #17.
  • Initialize the font module on demand in the text path. Graphik's constructor only sets up a display (it calls set_mode() itself), so a consumer could reasonably reach drawText with the font module still down and get a bare pygame.error: font not initialized surfacing from inside graphik. Closes drawText fails with a bare "font not initialized" error when only the display was initialized #18.
  • No public signature or drawn-output change; drawRectangle / drawImage are untouched.

Cache invalidation — the part worth reviewing

A Font that outlives a font.quit()/init() cycle points at freed SDL_ttf memory and segfaults on use (confirmed locally, exit 139), and pygame exposes no way to test a Font for validity — no generation counter, and nothing in pygame.font's module surface changes across a restart (I diffed every attribute). So the cache is dropped on two signals:

  1. Font module observed down (pygame.font.get_init() is false) — covers pygame.font.quit() followed by our own init().
  2. Display surface object changed — restarting pygame (pygame.quit()/pygame.init()) drops the display surface, so pygame.display.get_surface() returning a different object (or None) marks a new session. A plain resize returns the same surface object, so this does not throw the cache away on every set_mode().

Guard 2 is load-bearing: with it defeated, the restart case segfaults; with it in place the instance fails the same loud way it did before this change (pygame.error: display Surface quit, from blitting to its stale display surface). Both behaviours are covered by tests.

Known limitation, documented in the code: a consumer that calls pygame.font.quit() and then pygame.font.init() itself, leaving the display alone, is indistinguishable from an untouched module through pygame's public API — a Graphik built before such a restart must be rebuilt after it. This is the same constraint that applies to any pygame code holding a Font across a font-module restart.

Vendored consumers

No breaking change: no public method signature or rendered output changes, so the vendored copies in Roam, Apex, Ophidian, Patchwork and Tic-Tak-Toe can take this as a drop-in. The only new instance state is _fonts / _fontDisplay, both private and set in __init__.

Test plan

  • pytest — 19 passed (5 new tests)
  • Font built once across 5 same-size drawText calls; one font per distinct size
  • drawText works after only pygame.display.init() / set_mode(), with no consumer pygame.font.init()
  • Cache rebuilt after the font module shuts down
  • Display-session change drops the cache and yields a clean pygame.error rather than a segfault
  • python -m compileall src/main/python
  • Existing drawText / drawButton pixel-output tests unchanged and passing

Closes #17
Closes #18


This PR description was drafted during a Gardener session (Stephenson-Software/gardener).

drawText built a new pygame.font.Font on every call. Constructing a Font
parses and rasterizes the TrueType file (3.899 ms/call measured locally)
while the render() it exists to serve costs 0.005 ms, so nearly all of
drawText's cost was rebuilt work paid again every frame, by every label,
and by every drawButton.

Cache one Font per size on the instance instead. A Font that outlives a
font.quit()/init() cycle is freed memory and segfaults on use, so the
cache is dropped when the font module is observed down or when the
display surface object changes (which is what restarting pygame does).

drawText also called pygame.font.Font without ensuring the font module
was up, so a consumer who initialized only the display -- all Graphik's
own constructor needs -- hit a bare "font not initialized" error from
inside the library. The text path now initializes the module itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dmccoystephenson

Copy link
Copy Markdown
Member Author

Self-review

Verified the two cache-invalidation guards empirically rather than from pygame's docs, because the failure mode here is a segfault rather than an exception. Confirmed locally on pygame 2.6.1 / Python 3.10:

  • Using a Font after font.quit() + font.init() crashes the interpreter (exit 139).
  • Using one after font.quit() alone raises cleanly (Library not initialized), so guard 1 covers that path.
  • pygame.quit() + pygame.init() yields a new display surface object, while a plain resize set_mode() returns the same object — which is what makes guard 2 both correct and free of spurious cache drops.
  • With guard 2 removed, the restart case segfaults; with it in place the instance raises pygame.error: display Surface quit — exactly what it did before this PR.

No behaviour change to drawRectangle / drawImage, no public signature change, and the pygame-optional package import is untouched (test_package_imports_without_pygame still passes).

Three non-blocking observations:

  1. graphik.py:68 — unbounded cache by size. The cache is keyed on size with no eviction, so a caller that animates text size (a label tweening 8 → 72) accumulates one Font per distinct size for the life of the instance. Bounded by distinct sizes rather than call count, and the realistic consumer draws at a handful of fixed sizes, so an LRU cap would cost more complexity than it buys. Flagging it so the tradeoff is a decision, not an oversight.

  2. test_graphik.py:213 — global state mutated mid-suite. test_draw_text_drops_cached_font_when_the_display_session_changes tears all of pygame down. Safe as written: every later test goes through _make_graphik(), which re-inits display and font itself, and the test passes in isolation too. But it is the one place in this file where ordering matters — worth remembering if these tests are ever split across files or run under a randomizing plugin.

  3. test_graphik.py:185 — stale Fonts from earlier tests. Shutting the font module down invalidates Fonts cached by earlier tests' instances, which are collected later in the run. I checked rather than assumed: creating a Font, cycling font.quit()/font.init(), dropping the reference and forcing gc.collect() deallocates without crashing on pygame 2.6.1. Only use of a stale Font is fatal, not its deallocation.

@dmccoystephenson
dmccoystephenson merged commit 91ca658 into main Jul 26, 2026
1 check passed
@dmccoystephenson
dmccoystephenson deleted the feature/cache-and-init-text-font branch July 26, 2026 22:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

drawText fails with a bare "font not initialized" error when only the display was initialized perf: drawText rebuilds a pygame Font on every call

1 participant