Coverage for pyWebLayout/core/cache.py: 97%
171 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-08 20:34 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-08 20:34 +0000
1"""
2Bounded usage-ranked caches for the text rendering hot path.
4Laying out and rasterising a page re-measures and re-draws the same words over and
5over: a typical page issues ~2800 width measurements and ~2500 glyph rasterisations
6for fewer than 1000 distinct (font, string) pairs. Caching both collapses that work,
7but an unbounded cache is not an option on a memory-constrained target such as a
8Raspberry Pi Zero 2, where the rasterised bitmaps reach ~19MB over a long session.
10Eviction is by **usage count**, not recency. Word frequency in prose is Zipfian and
11stationary -- a small set of words ("the", "and", "of") accounts for most tokens on
12every page, and that set barely shifts as the reader advances -- so the words worth
13keeping are exactly the ones used most.
15Two design choices keep this from costing more than it saves, because `get` runs
16once per word drawn (~2500 times per page):
18* **Counting is O(1) with no reordering.** Each entry carries its own use counter,
19 bumped in place. Ranking structures that reorder on every hit (a frequency-bucket
20 LFU, or an LRU's linked-list splice) were measured 3-5ms per page slower than the
21 hit rate they buy is worth.
22* **Eviction samples rather than sorts.** Finding the globally least-used entry
23 would need a heap kept current on every hit. Instead a small random sample is
24 drawn and the least-used member of it evicted, the same approximation Redis uses
25 for its LFU policy. With the default sample size the evicted entry is very
26 likely to be in the bottom few percent, which is all that matters here.
28Both are single-threaded by design; the rendering path holds the GIL throughout and
29adding locking would cost more than it protects.
30"""
32from __future__ import annotations
34import random
35from typing import Any, Callable, Dict, Generic, Hashable, List, Optional, TypeVar
37K = TypeVar('K', bound=Hashable)
38V = TypeVar('V')
40# Entries examined per eviction. Larger samples approximate true least-frequently-used
41# more closely at linear cost; 8 puts the victim in the bottom ~15% of entries, which
42# is ample when the alternative is a rasterisation that costs ~60us either way.
43DEFAULT_EVICTION_SAMPLE = 8
45# Halving every entry's use count after this many insertions keeps the cache
46# responsive to a change of working set. Without it, entries that were hot long ago
47# retain counts a newly-hot entry cannot beat and are never evicted -- the classic
48# failure of pure frequency eviction. Measured on a real access trace, a font-size
49# change drove hit rate to 0% without aging and left it unchanged with it.
50DEFAULT_AGING_INTERVAL = 10000
52# Index of each field in an entry. Entries are plain lists rather than tuples or
53# objects so the counter can be bumped in place, without rehashing the key.
54_VALUE = 0
55_COUNT = 1
56_SLOT = 2
59class _UsageRanked(Generic[K, V]):
60 """
61 Shared usage-count bookkeeping for the caches below.
63 Entries live in a dict for lookup and, in parallel, in a flat list that makes
64 uniform random sampling possible. Each entry records its own index in that list
65 so removal can swap in the tail element and stay O(1).
67 Subclasses supply the bound by implementing :meth:`_over_budget` and the
68 accounting hooks :meth:`_record_add` / :meth:`_record_remove`.
69 """
71 def __init__(self,
72 aging_interval: Optional[int] = DEFAULT_AGING_INTERVAL,
73 eviction_sample: int = DEFAULT_EVICTION_SAMPLE):
74 if aging_interval is not None and aging_interval <= 0:
75 raise ValueError(f"aging_interval must be positive, got {aging_interval}")
76 if eviction_sample <= 0:
77 raise ValueError(f"eviction_sample must be positive, got {eviction_sample}")
79 self._aging_interval = aging_interval
80 self._eviction_sample = eviction_sample
82 self._entries: Dict[K, List[Any]] = {}
83 self._slots: List[K] = []
84 self._randrange = random.randrange
86 self._inserts_since_aging = 0
87 self._hits = 0
88 self._misses = 0
89 self._evictions = 0
90 self._agings = 0
92 # -- subclass hooks ----------------------------------------------------
94 def _over_budget(self) -> bool:
95 raise NotImplementedError
97 def _record_add(self, key: K, value: V):
98 """Account for a value entering the cache."""
100 def _record_remove(self, key: K):
101 """Account for a value leaving the cache."""
103 # -- core operations ---------------------------------------------------
105 def get(self, key: K) -> Optional[V]:
106 """Return the cached value for `key`, or None, counting the use."""
107 entry = self._entries.get(key)
108 if entry is None:
109 self._misses += 1
110 return None
111 entry[_COUNT] += 1
112 self._hits += 1
113 return entry[_VALUE]
115 def _add_new(self, key: K, value: V):
116 """Insert a key not currently present."""
117 # New entries start at 1 rather than 0 so that a single reuse is enough to
118 # outrank an entry that has never been touched since the last aging pass.
119 self._entries[key] = [value, 1, len(self._slots)]
120 self._slots.append(key)
121 self._record_add(key, value)
123 def _remove(self, key: K):
124 """Remove a key outright, keeping the sampling list dense."""
125 entry = self._entries.pop(key)
126 slot = entry[_SLOT]
127 last = self._slots.pop()
128 if last != key:
129 self._slots[slot] = last
130 self._entries[last][_SLOT] = slot
131 self._record_remove(key)
133 def _evict_one(self) -> bool:
134 """Evict the least-used member of a random sample. False if empty."""
135 count = len(self._slots)
136 if not count: 136 ↛ 137line 136 didn't jump to line 137 because the condition on line 136 was never true
137 return False
139 if count <= self._eviction_sample:
140 victim = min(self._slots, key=lambda k: self._entries[k][_COUNT])
141 else:
142 randrange = self._randrange
143 entries = self._entries
144 slots = self._slots
145 victim = slots[randrange(count)]
146 best = entries[victim][_COUNT]
147 for _ in range(self._eviction_sample - 1):
148 candidate = slots[randrange(count)]
149 score = entries[candidate][_COUNT]
150 if score < best:
151 victim, best = candidate, score
153 self._remove(victim)
154 self._evictions += 1
155 return True
157 def _evict_to_budget(self):
158 while self._over_budget():
159 if not self._evict_one(): 159 ↛ 160line 159 didn't jump to line 160 because the condition on line 159 was never true
160 break
162 def _maybe_age(self):
163 """Halve every use count once the aging interval has elapsed."""
164 if self._aging_interval is None:
165 return
166 self._inserts_since_aging += 1
167 if self._inserts_since_aging < self._aging_interval:
168 return
170 self._inserts_since_aging = 0
171 self._agings += 1
172 for entry in self._entries.values():
173 entry[_COUNT] = entry[_COUNT] // 2 or 1
175 def clear(self):
176 """Drop all entries. Counters are preserved."""
177 self._entries.clear()
178 self._slots.clear()
179 self._inserts_since_aging = 0
181 def _base_stats(self) -> Dict[str, Any]:
182 total = self._hits + self._misses
183 return {
184 'entries': len(self._entries),
185 'hits': self._hits,
186 'misses': self._misses,
187 'evictions': self._evictions,
188 'agings': self._agings,
189 'hit_rate': (self._hits / total) if total else 0.0,
190 }
192 def __len__(self) -> int:
193 return len(self._entries)
195 def __contains__(self, key: object) -> bool:
196 return key in self._entries
199class UsageCache(_UsageRanked[K, V]):
200 """
201 Usage-ranked cache bounded by number of entries.
203 Args:
204 max_entries: Maximum number of entries to retain. Must be positive.
205 aging_interval: Insertions between halving all use counts, or None to
206 disable aging. See :data:`DEFAULT_AGING_INTERVAL`.
207 eviction_sample: Entries sampled per eviction.
208 """
210 def __init__(self, max_entries: int,
211 aging_interval: Optional[int] = DEFAULT_AGING_INTERVAL,
212 eviction_sample: int = DEFAULT_EVICTION_SAMPLE):
213 if max_entries <= 0:
214 raise ValueError(f"max_entries must be positive, got {max_entries}")
215 super().__init__(aging_interval, eviction_sample)
216 self._max_entries = max_entries
218 def _over_budget(self) -> bool:
219 return len(self._entries) > self._max_entries
221 def put(self, key: K, value: V, count: int = 1):
222 """
223 Insert `value`, evicting the least-used entries past the bound.
225 Args:
226 count: Initial use count. Pass a document-derived frequency to rank a
227 preloaded entry ahead of words that have not been seen yet.
228 """
229 existing = self._entries.get(key)
230 if existing is not None:
231 existing[_VALUE] = value
232 existing[_COUNT] += 1
233 return
234 self._add_new(key, value)
235 if count > 1:
236 self._entries[key][_COUNT] = count
237 self._evict_to_budget()
238 self._maybe_age()
240 @property
241 def max_entries(self) -> int:
242 return self._max_entries
244 def resize(self, max_entries: int):
245 """Change the bound, evicting immediately if the cache now overflows."""
246 if max_entries <= 0:
247 raise ValueError(f"max_entries must be positive, got {max_entries}")
248 self._max_entries = max_entries
249 self._evict_to_budget()
251 def stats(self) -> Dict[str, Any]:
252 """Hit/miss/eviction counters and current occupancy."""
253 stats = self._base_stats()
254 stats['max_entries'] = self._max_entries
255 return stats
258class SizedUsageCache(_UsageRanked[K, V]):
259 """
260 Usage-ranked cache bounded by the total size of its values.
262 Args:
263 max_bytes: Maximum total value size to retain. Must be positive.
264 sizer: Returns the size in bytes of a value. Called once per insertion.
265 aging_interval: Insertions between halving all use counts, or None to
266 disable aging. See :data:`DEFAULT_AGING_INTERVAL`.
267 eviction_sample: Entries sampled per eviction.
269 A value larger than `max_bytes` on its own is returned to the caller but not
270 retained, so that one oversized entry cannot flush the whole cache.
271 """
273 def __init__(self, max_bytes: int, sizer: Callable[[V], int],
274 aging_interval: Optional[int] = DEFAULT_AGING_INTERVAL,
275 eviction_sample: int = DEFAULT_EVICTION_SAMPLE):
276 if max_bytes <= 0:
277 raise ValueError(f"max_bytes must be positive, got {max_bytes}")
278 super().__init__(aging_interval, eviction_sample)
279 self._max_bytes = max_bytes
280 self._sizer = sizer
281 self._sizes: Dict[K, int] = {}
282 self._total_bytes = 0
284 def _over_budget(self) -> bool:
285 return self._total_bytes > self._max_bytes
287 def _record_add(self, key: K, value: V):
288 size = self._sizer(value)
289 self._sizes[key] = size
290 self._total_bytes += size
292 def _record_remove(self, key: K):
293 self._total_bytes -= self._sizes.pop(key)
295 def put(self, key: K, value: V, count: int = 1):
296 """
297 Insert `value`, evicting the least-used entries past the bound.
299 Args:
300 count: Initial use count. Pass a document-derived frequency to rank a
301 preloaded entry ahead of words that have not been seen yet.
302 """
303 if key in self._entries:
304 # Re-measure: the replacement may be a different size.
305 self._remove(key)
307 if self._sizer(value) > self._max_bytes:
308 # Too large to ever retain; skip rather than flush everything for it.
309 return
311 self._add_new(key, value)
312 if count > 1:
313 self._entries[key][_COUNT] = count
314 self._evict_to_budget()
315 self._maybe_age()
317 @property
318 def max_bytes(self) -> int:
319 return self._max_bytes
321 @property
322 def total_bytes(self) -> int:
323 return self._total_bytes
325 def resize(self, max_bytes: int):
326 """Change the bound, evicting immediately if the cache now overflows."""
327 if max_bytes <= 0: 327 ↛ 328line 327 didn't jump to line 328 because the condition on line 327 was never true
328 raise ValueError(f"max_bytes must be positive, got {max_bytes}")
329 self._max_bytes = max_bytes
330 self._evict_to_budget()
332 def clear(self):
333 """Drop all entries. Counters are preserved."""
334 super().clear()
335 self._sizes.clear()
336 self._total_bytes = 0
338 def stats(self) -> Dict[str, Any]:
339 """Hit/miss/eviction counters and current occupancy."""
340 stats = self._base_stats()
341 stats['total_bytes'] = self._total_bytes
342 stats['max_bytes'] = self._max_bytes
343 return stats