Skip to content

Let draw_text reuse only labels not drawn this frame - #2925

Merged
pvcraven merged 1 commit into
developmentfrom
perf/draw-text-staleness
Oct 6, 2026
Merged

pvcraven merged 1 commit into
developmentfrom
perf/draw-text-staleness

Conversation

@pvcraven

@pvcraven pvcraven commented Oct 6, 2026

Copy link
Copy Markdown
Member

Summary

Fixes a draw_text slowdown I introduced in #2919, which I found while measuring for the text docs (#2924).

The problem

#2919 kept at most 8 cached labels per style. That way, a line whose text changes every frame (a timer) reuses its own old label, which costs about 300 µs to re-lay out, instead of creating a new Text every frame, which costs about 600 µs. But with 9 or more unchanged lines in the same style, there was never room for all of them. Line 9 took line 1's label, line 1 then took line 2's, and so on. Every line was laid out again every frame, going from about 30 µs to about 300 µs per line.

The fix

The cap is gone. Instead, each label records the GLOBAL_CLOCK tick it was last drawn in. The window's event loop ticks that clock once per frame, before drawing, and the Tetris example already uses GLOBAL_CLOCK.ticks as a frame number. On a cache miss, the style's least recently used label is reused only if it wasn't drawn this frame:

  • Drawn this frame: if the least recently used label was, they all were, so this is a new line, and it gets a new label.
  • Not drawn this frame: it belonged to a line whose text changed or that isn't drawn anymore, so it's reused. Lines are drawn in the same order every frame, so a timer reuses its own label from the previous frame.

The limit of 256 labels in total stays.

Without the event loop, for example in a custom loop or tests, the clock doesn't advance. Every label then looks in use, so changing text creates new labels until the 256 limit forgets the oldest. That's slower for changing text, but it never thrashes.

I'd planned to group labels by style so a miss wouldn't scan the cache. But scanning all 256 entries costs 2.7 µs at most, and only on a miss, which then spends 300–600 µs on layout. An index would also need to stay in sync with ctx.label_cache, which code and tests clear directly. So I left it out.

Measurements

µs per frame, with the clock ticking each frame as in the event loop, alternating old and new:

Same-style lines Before After
8 static 251 258
9 static 2,477 285
20 static 5,595 643
50 static 14,823 1,675
4 static + 1 changing 538 (8 labels) 443 (5 labels)
20 static + 1 changing 6,883 953

The changing-line case also uses fewer labels now. Before, the timer kept creating labels until it reached the cap.

Tests

In tests/unit/text/test_draw_text_cache.py:

  • Updated: test_same_style_lines_keep_their_labels ticks the clock each frame, like the event loop. It checks that the timer line reuses its own label and that the cache holds exactly 5 labels.
  • New: test_many_same_style_lines_keep_their_labels with 9 and 30 lines, which keep their labels across frames.
  • New: test_line_that_stops_frees_its_label (a new line takes the label of one that stopped being drawn) and test_new_line_in_same_frame_gets_new_label.
  • New: test_changing_text_without_frames_is_limited, the no-event-loop fallback.

On development, 4 fail. The pixel test from #2919 still passes.

Full suite on pyglet 3.0.dev11: 1571 passed, no warnings, plus the same 3 render tests that fail only on my machine. Ruff is clean, and mypy has no new errors.

Changelog: the cap came in with #2919, which isn't released yet, so I updated its Unreleased entry instead of adding a "fixed" entry for a bug that never shipped.

🤖 Generated with Claude Code

draw_text kept at most 8 labels per style, so a changing line would
reuse its own old label instead of making a new one every frame. With 9
or more unchanged lines in one style, though, the lines took each
other's labels and every line was laid out again every frame: 20 lines
cost 5.6 ms per frame instead of 0.6 ms.

Instead, each label records the GLOBAL_CLOCK tick it was last drawn in.
On a cache miss, the style's least recently used label is reused only
if it wasn't drawn this frame. Lines are drawn in the same order each
frame, so a changing line reuses its own previous label, and unchanged
lines keep theirs however many there are. The 256 label limit stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@pvcraven
pvcraven merged commit d760d75 into development Oct 6, 2026
7 checks passed
@pvcraven
pvcraven deleted the perf/draw-text-staleness branch October 6, 2026 18:57
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.

1 participant