Cache word widths and glyph bitmaps to cut page render time ~3.5x
Rendering a page re-measured and re-rasterised the same words constantly: at
1404x1872 a page issued ~2800 textlength calls and ~2500 draw.text calls for
fewer than 1000 distinct (font, string) pairs. Profiling a page turn showed
FreeType glyph rendering at 57% of total time and width measurement at 38% of
layout.
Both are now cached. Measured on Crime and Punishment at 1404x1872, one page:
layout 35ms -> 19ms
render 84ms -> 41ms
total 120ms -> 60ms
Eviction ranks by use count rather than recency. Word frequency in prose is
Zipfian and stationary, so the words worth keeping are the ones used most, and
unlike recency this lets a document's own frequencies be seeded up front --
see prewarm_caches(). Two details keep the policy from costing more than it
saves, since get() runs once per word drawn:
- counting is O(1) with no reordering, because structures that reorder on
every hit measured 3-5ms/page slower than the hit rate they bought;
- eviction samples 8 entries and drops the least used of those, rather than
maintaining a global order.
Aging (halving all counts periodically) is on by default. Without it a font
size change drove the hit rate to 0% on a real access trace: every key was new
and the previous size's entries held counts nothing could beat.
Both caches are bounded, since the glyph bitmaps reach ~19MB over a long
session and the target is a 512MB Pi Zero 2. Defaults are 4MB of bitmaps and
8192 widths; configure_text_caches() tunes them. Cache size barely affects
speed (2MB is within 13% of unbounded) because a miss costs only one ~44us
rasterisation, so the bound can be set for memory, not throughput.
EreaderLayoutManager.prewarm_caches() counts the book's word frequencies and
preloads the most common ones, seeding each with its document frequency. This
moves that rasterisation to open time and cut misses by 27%, for ~15% faster
page turns at a one-off ~300ms cost. It is opt-in; nothing calls it yet.
Rendering is no longer bit-identical. PIL positions text at sub-pixel offsets,
so the cache buckets that phase, defaulting to 2 buckets per axis. Total ink
per page is unchanged and the mean pixel difference is 3.6/255 -- a fifth of
one step of a 16-level e-ink panel. subpixel_steps=4 halves that if wanted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -219,6 +219,67 @@ class EreaderLayoutManager:
|
||||
self.chapter_changed_callback: Optional[Callable[[
|
||||
Optional[ChapterInfo]], None]] = None
|
||||
|
||||
def prewarm_caches(self, max_words: int = 2000,
|
||||
budget_bytes: Optional[int] = None) -> Tuple[int, int]:
|
||||
"""
|
||||
Preload the text caches with this document's most frequent words.
|
||||
|
||||
Counts how often each word occurs in the book and rasterises the most
|
||||
common ones ahead of time, so that the work lands at open time rather than
|
||||
on the first page turns. Entries are seeded with their document frequency,
|
||||
which is what keeps them resident under usage-ranked eviction.
|
||||
|
||||
Safe to call again after a font change; the fonts differ, so the new
|
||||
entries simply take their place in the eviction order alongside the old.
|
||||
|
||||
Args:
|
||||
max_words: Maximum distinct words to preload.
|
||||
budget_bytes: Bytes of glyph cache to fill. Defaults to half the budget.
|
||||
|
||||
Returns:
|
||||
Tuple of (words preloaded, bytes preloaded).
|
||||
"""
|
||||
from collections import Counter
|
||||
from pyWebLayout.concrete.text import prewarm_text_caches
|
||||
from .ereader_layout import FontScaler
|
||||
|
||||
override = getattr(self.renderer.layouter, 'font_family_override', None)
|
||||
|
||||
# Count by (style, text): the same word in a heading and in body text is a
|
||||
# different rasterisation, and both are worth counting separately.
|
||||
counts: Dict[Tuple[int, str], int] = Counter()
|
||||
styles: Dict[int, Any] = {}
|
||||
for block in self.blocks:
|
||||
words = getattr(block, '_words', None)
|
||||
if not words:
|
||||
continue
|
||||
for word in words:
|
||||
style = word.style
|
||||
if style is None:
|
||||
continue
|
||||
key = id(style)
|
||||
styles.setdefault(key, style)
|
||||
counts[(key, word.text)] += 1
|
||||
|
||||
# Resolve each distinct style once through the same scaling the layouter
|
||||
# applies, so the preloaded keys match what rendering will look up.
|
||||
scaled: Dict[int, Any] = {}
|
||||
for key, style in styles.items():
|
||||
try:
|
||||
scaled[key] = FontScaler.scale_font(style, self.font_scale, override)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
entries = []
|
||||
for (style_key, text), count in counts.items():
|
||||
font = scaled.get(style_key)
|
||||
if font is None:
|
||||
continue
|
||||
entries.append((font.font, text, font.colour, count))
|
||||
|
||||
return prewarm_text_caches(entries, budget_bytes=budget_bytes,
|
||||
max_words=max_words)
|
||||
|
||||
def set_position_changed_callback(
|
||||
self, callback: Callable[[RenderingPosition], None]):
|
||||
"""Set callback for position changes"""
|
||||
|
||||
Reference in New Issue
Block a user