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>
245 lines
8.4 KiB
Python
245 lines
8.4 KiB
Python
"""
|
|
Unit tests for the bounded usage-ranked caches.
|
|
|
|
Covers the guarantees the text rendering path depends on: that the bounds are never
|
|
exceeded, that eviction prefers the least-used entries, that aging lets a new
|
|
working set displace an old one, and that document-frequency seeding survives a
|
|
scan of unfamiliar keys.
|
|
"""
|
|
|
|
import unittest
|
|
|
|
from pyWebLayout.core.cache import (
|
|
UsageCache,
|
|
SizedUsageCache,
|
|
DEFAULT_AGING_INTERVAL,
|
|
)
|
|
|
|
|
|
class TestUsageCache(unittest.TestCase):
|
|
"""Entry-count-bounded cache."""
|
|
|
|
def test_rejects_invalid_bounds(self):
|
|
for bad in (0, -1):
|
|
with self.assertRaises(ValueError):
|
|
UsageCache(bad)
|
|
with self.assertRaises(ValueError):
|
|
UsageCache(4, aging_interval=0)
|
|
with self.assertRaises(ValueError):
|
|
UsageCache(4, eviction_sample=0)
|
|
|
|
def test_stores_and_returns_values(self):
|
|
cache = UsageCache(4)
|
|
cache.put('a', 1)
|
|
self.assertEqual(cache.get('a'), 1)
|
|
self.assertIsNone(cache.get('missing'))
|
|
self.assertIn('a', cache)
|
|
self.assertEqual(len(cache), 1)
|
|
|
|
def test_never_exceeds_max_entries(self):
|
|
cache = UsageCache(10)
|
|
for i in range(500):
|
|
cache.put(i, i)
|
|
self.assertLessEqual(len(cache), 10)
|
|
self.assertEqual(cache.stats()['entries'], 10)
|
|
|
|
def test_evicts_least_used(self):
|
|
# One hot key among many cold ones must survive a long cold scan. The
|
|
# sample is smaller than the cache, so this is probabilistic in principle;
|
|
# a hot key's count is far enough above the rest to make it reliable.
|
|
cache = UsageCache(20, eviction_sample=8)
|
|
cache.put('hot', 'value')
|
|
for _ in range(200):
|
|
cache.get('hot')
|
|
for i in range(400):
|
|
cache.put(f'cold{i}', i)
|
|
cache.get('hot')
|
|
self.assertEqual(cache.get('hot'), 'value')
|
|
|
|
def test_repeated_put_does_not_duplicate(self):
|
|
cache = UsageCache(10)
|
|
for _ in range(50):
|
|
cache.put('a', 1)
|
|
self.assertEqual(len(cache), 1)
|
|
|
|
def test_put_updates_existing_value(self):
|
|
cache = UsageCache(10)
|
|
cache.put('a', 1)
|
|
cache.put('a', 2)
|
|
self.assertEqual(cache.get('a'), 2)
|
|
|
|
def test_seeded_count_outranks_fresh_entries(self):
|
|
"""A document-frequency seed must survive a scan of unseen keys."""
|
|
cache = UsageCache(20, eviction_sample=8)
|
|
cache.put('frequent', 'value', count=5000)
|
|
for i in range(400):
|
|
cache.put(f'new{i}', i)
|
|
self.assertEqual(cache.get('frequent'), 'value')
|
|
|
|
def test_aging_lets_a_new_working_set_take_over(self):
|
|
"""Without aging, stale high counts lock the cache permanently."""
|
|
cache = UsageCache(20, aging_interval=50, eviction_sample=8)
|
|
for i in range(20):
|
|
cache.put(f'old{i}', i, count=10000)
|
|
|
|
# A completely different working set, each key used a few times.
|
|
for round_ in range(60):
|
|
for i in range(10):
|
|
key = f'new{i}'
|
|
if cache.get(key) is None:
|
|
cache.put(key, i)
|
|
|
|
survivors = sum(1 for i in range(10) if f'new{i}' in cache)
|
|
self.assertGreater(survivors, 0,
|
|
"aging should let the new working set displace the old")
|
|
self.assertGreater(cache.stats()['agings'], 0)
|
|
|
|
def test_aging_can_be_disabled(self):
|
|
cache = UsageCache(10, aging_interval=None)
|
|
for i in range(100):
|
|
cache.put(i, i)
|
|
self.assertEqual(cache.stats()['agings'], 0)
|
|
|
|
def test_resize_evicts_immediately(self):
|
|
cache = UsageCache(100)
|
|
for i in range(100):
|
|
cache.put(i, i)
|
|
cache.resize(10)
|
|
self.assertEqual(len(cache), 10)
|
|
with self.assertRaises(ValueError):
|
|
cache.resize(0)
|
|
|
|
def test_clear_empties_but_keeps_counters(self):
|
|
cache = UsageCache(10)
|
|
cache.put('a', 1)
|
|
cache.get('a')
|
|
cache.clear()
|
|
self.assertEqual(len(cache), 0)
|
|
self.assertNotIn('a', cache)
|
|
self.assertEqual(cache.stats()['hits'], 1)
|
|
|
|
def test_stats_track_hits_and_misses(self):
|
|
cache = UsageCache(10)
|
|
cache.put('a', 1)
|
|
cache.get('a')
|
|
cache.get('a')
|
|
cache.get('b')
|
|
stats = cache.stats()
|
|
self.assertEqual(stats['hits'], 2)
|
|
self.assertEqual(stats['misses'], 1)
|
|
self.assertAlmostEqual(stats['hit_rate'], 2 / 3)
|
|
self.assertEqual(stats['max_entries'], 10)
|
|
|
|
def test_internal_slot_list_stays_consistent(self):
|
|
"""Eviction swaps the tail into the freed slot; indices must stay valid."""
|
|
cache = UsageCache(8)
|
|
for i in range(300):
|
|
cache.put(i, i)
|
|
for key in list(cache._entries):
|
|
self.assertEqual(cache._slots[cache._entries[key][2]], key)
|
|
self.assertEqual(len(cache._slots), len(cache._entries))
|
|
|
|
|
|
class TestSizedUsageCache(unittest.TestCase):
|
|
"""Byte-bounded cache, as used for glyph bitmaps."""
|
|
|
|
@staticmethod
|
|
def sizer(value):
|
|
return value
|
|
|
|
def test_rejects_invalid_bounds(self):
|
|
for bad in (0, -1):
|
|
with self.assertRaises(ValueError):
|
|
SizedUsageCache(bad, self.sizer)
|
|
|
|
def test_never_exceeds_max_bytes(self):
|
|
cache = SizedUsageCache(1000, self.sizer)
|
|
for i in range(500):
|
|
cache.put(i, 100)
|
|
self.assertLessEqual(cache.total_bytes, 1000)
|
|
|
|
def test_tracks_total_bytes(self):
|
|
cache = SizedUsageCache(1000, self.sizer)
|
|
cache.put('a', 100)
|
|
cache.put('b', 250)
|
|
self.assertEqual(cache.total_bytes, 350)
|
|
|
|
def test_oversized_value_is_not_retained(self):
|
|
"""One huge entry must not flush everything else out."""
|
|
cache = SizedUsageCache(1000, self.sizer)
|
|
cache.put('small', 100)
|
|
cache.put('huge', 5000)
|
|
self.assertNotIn('huge', cache)
|
|
self.assertIn('small', cache)
|
|
self.assertEqual(cache.total_bytes, 100)
|
|
|
|
def test_replacing_a_value_remeasures_it(self):
|
|
cache = SizedUsageCache(1000, self.sizer)
|
|
cache.put('a', 100)
|
|
cache.put('a', 300)
|
|
self.assertEqual(cache.total_bytes, 300)
|
|
self.assertEqual(len(cache), 1)
|
|
|
|
def test_evicts_least_used(self):
|
|
cache = SizedUsageCache(1000, self.sizer, eviction_sample=8)
|
|
cache.put('hot', 100)
|
|
for _ in range(200):
|
|
cache.get('hot')
|
|
for i in range(400):
|
|
cache.put(f'cold{i}', 100)
|
|
cache.get('hot')
|
|
self.assertIn('hot', cache)
|
|
|
|
def test_seeded_count_outranks_fresh_entries(self):
|
|
cache = SizedUsageCache(1000, self.sizer, eviction_sample=8)
|
|
cache.put('frequent', 100, count=5000)
|
|
for i in range(400):
|
|
cache.put(f'new{i}', 100)
|
|
self.assertIn('frequent', cache)
|
|
|
|
def test_resize_evicts_immediately(self):
|
|
cache = SizedUsageCache(10000, self.sizer)
|
|
for i in range(100):
|
|
cache.put(i, 100)
|
|
cache.resize(500)
|
|
self.assertLessEqual(cache.total_bytes, 500)
|
|
|
|
def test_clear_resets_byte_accounting(self):
|
|
cache = SizedUsageCache(1000, self.sizer)
|
|
cache.put('a', 100)
|
|
cache.clear()
|
|
self.assertEqual(cache.total_bytes, 0)
|
|
self.assertEqual(len(cache), 0)
|
|
|
|
def test_stats_report_bounds(self):
|
|
cache = SizedUsageCache(1000, self.sizer)
|
|
cache.put('a', 100)
|
|
stats = cache.stats()
|
|
self.assertEqual(stats['total_bytes'], 100)
|
|
self.assertEqual(stats['max_bytes'], 1000)
|
|
self.assertEqual(stats['entries'], 1)
|
|
|
|
def test_bookkeeping_stays_consistent_under_churn(self):
|
|
"""Byte total and slot list must not drift over many evictions."""
|
|
cache = SizedUsageCache(2000, self.sizer, aging_interval=97)
|
|
for i in range(2000):
|
|
cache.put(i, (i % 7 + 1) * 50)
|
|
if i % 3 == 0:
|
|
cache.get(i)
|
|
self.assertEqual(cache.total_bytes,
|
|
sum(cache._sizes[k] for k in cache._entries))
|
|
self.assertEqual(len(cache._slots), len(cache._entries))
|
|
self.assertLessEqual(cache.total_bytes, cache.max_bytes)
|
|
|
|
|
|
class TestDefaults(unittest.TestCase):
|
|
|
|
def test_aging_is_enabled_by_default(self):
|
|
self.assertIsNotNone(DEFAULT_AGING_INTERVAL)
|
|
self.assertGreater(DEFAULT_AGING_INTERVAL, 0)
|
|
self.assertIsNotNone(UsageCache(4)._aging_interval)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|