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

1""" 

2Bounded usage-ranked caches for the text rendering hot path. 

3 

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. 

9 

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. 

14 

15Two design choices keep this from costing more than it saves, because `get` runs 

16once per word drawn (~2500 times per page): 

17 

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. 

27 

28Both are single-threaded by design; the rendering path holds the GIL throughout and 

29adding locking would cost more than it protects. 

30""" 

31 

32from __future__ import annotations 

33 

34import random 

35from typing import Any, Callable, Dict, Generic, Hashable, List, Optional, TypeVar 

36 

37K = TypeVar('K', bound=Hashable) 

38V = TypeVar('V') 

39 

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 

44 

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 

51 

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 

57 

58 

59class _UsageRanked(Generic[K, V]): 

60 """ 

61 Shared usage-count bookkeeping for the caches below. 

62 

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). 

66 

67 Subclasses supply the bound by implementing :meth:`_over_budget` and the 

68 accounting hooks :meth:`_record_add` / :meth:`_record_remove`. 

69 """ 

70 

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}") 

78 

79 self._aging_interval = aging_interval 

80 self._eviction_sample = eviction_sample 

81 

82 self._entries: Dict[K, List[Any]] = {} 

83 self._slots: List[K] = [] 

84 self._randrange = random.randrange 

85 

86 self._inserts_since_aging = 0 

87 self._hits = 0 

88 self._misses = 0 

89 self._evictions = 0 

90 self._agings = 0 

91 

92 # -- subclass hooks ---------------------------------------------------- 

93 

94 def _over_budget(self) -> bool: 

95 raise NotImplementedError 

96 

97 def _record_add(self, key: K, value: V): 

98 """Account for a value entering the cache.""" 

99 

100 def _record_remove(self, key: K): 

101 """Account for a value leaving the cache.""" 

102 

103 # -- core operations --------------------------------------------------- 

104 

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] 

114 

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) 

122 

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) 

132 

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 

138 

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 

152 

153 self._remove(victim) 

154 self._evictions += 1 

155 return True 

156 

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 

161 

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 

169 

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 

174 

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 

180 

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 } 

191 

192 def __len__(self) -> int: 

193 return len(self._entries) 

194 

195 def __contains__(self, key: object) -> bool: 

196 return key in self._entries 

197 

198 

199class UsageCache(_UsageRanked[K, V]): 

200 """ 

201 Usage-ranked cache bounded by number of entries. 

202 

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 """ 

209 

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 

217 

218 def _over_budget(self) -> bool: 

219 return len(self._entries) > self._max_entries 

220 

221 def put(self, key: K, value: V, count: int = 1): 

222 """ 

223 Insert `value`, evicting the least-used entries past the bound. 

224 

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() 

239 

240 @property 

241 def max_entries(self) -> int: 

242 return self._max_entries 

243 

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() 

250 

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 

256 

257 

258class SizedUsageCache(_UsageRanked[K, V]): 

259 """ 

260 Usage-ranked cache bounded by the total size of its values. 

261 

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. 

268 

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 """ 

272 

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 

283 

284 def _over_budget(self) -> bool: 

285 return self._total_bytes > self._max_bytes 

286 

287 def _record_add(self, key: K, value: V): 

288 size = self._sizer(value) 

289 self._sizes[key] = size 

290 self._total_bytes += size 

291 

292 def _record_remove(self, key: K): 

293 self._total_bytes -= self._sizes.pop(key) 

294 

295 def put(self, key: K, value: V, count: int = 1): 

296 """ 

297 Insert `value`, evicting the least-used entries past the bound. 

298 

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) 

306 

307 if self._sizer(value) > self._max_bytes: 

308 # Too large to ever retain; skip rather than flush everything for it. 

309 return 

310 

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() 

316 

317 @property 

318 def max_bytes(self) -> int: 

319 return self._max_bytes 

320 

321 @property 

322 def total_bytes(self) -> int: 

323 return self._total_bytes 

324 

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() 

331 

332 def clear(self): 

333 """Drop all entries. Counters are preserved.""" 

334 super().clear() 

335 self._sizes.clear() 

336 self._total_bytes = 0 

337 

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