djvu_rs/render_cache.rs
1//! Process-wide ceiling for the page render caches.
2//!
3//! Rendering a page memoises what it decoded — the wavelet background, the JB2
4//! mask, the converted RGB pixmaps, the composited tiles (see
5//! [`crate::djvu_render::PageLayers`]). That makes the second render of a page
6//! nearly free, and it is why a viewer can pan and zoom without re-decoding.
7//!
8//! Until 0.33 nothing bounded it. The eviction API
9//! ([`crate::DjVuDocument::enforce_cache_budget`] and friends) needed
10//! `&mut DjVuDocument`, and every render entry point holds a shared `&DjVuPage`
11//! — so a program that only rendered could never shrink what it grew. A sweep
12//! through a colour book cost about 5.3 MB per page and never gave any of it
13//! back.
14//!
15//! This module closes that: every page cache registers itself here, every cache
16//! fill reports its new size, and when the total goes over
17//! [`budget`] the least-recently-used layers are dropped until it is under
18//! again. The default ceiling is [`DEFAULT_BUDGET`]; set your own with
19//! [`set_budget`], or lift it entirely with `set_budget(usize::MAX)`.
20//!
21//! The unit of eviction is a *layer*, not a page (#813): each decoded
22//! background, mask, converted pixmap and tile store carries its own last-used
23//! tick and size, and a sweep ranks them across every live page. A page whose
24//! mask was just used keeps its mask while its stale background goes. Only the
25//! layer being filled at that moment is protected, so the resident total can
26//! exceed the ceiling by at most one layer. (Until this change the sweep
27//! dropped whole pages and protected the whole page being rendered, so the
28//! overshoot was a page's cache and a warm layer went with its cold
29//! neighbours.)
30//!
31//! Eviction is safe at any moment. A cached layer is handed to a render as a
32//! shared handle, so dropping the cache's own handle mid-render only means the
33//! render finishes with the copy it already holds. A render in progress
34//! therefore holds the layers it has already fetched whether or not the cache
35//! still does; that memory is the render's, not the cache's, and is not part
36//! of the resident total.
37//!
38//! The ceiling is process-wide on purpose: memory is a process-wide resource,
39//! and a page does not know which document it belongs to. Per-document control
40//! is still available through [`crate::DjVuDocument::enforce_cache_budget`],
41//! which stays page-granular.
42//!
43//! See PERF_EXPERIMENTS.md READ_CACHE_BOUNDED and RENDER_CACHE_LAYER_EVICT.
44
45use std::sync::atomic::{AtomicUsize, Ordering};
46use std::sync::{Arc, Mutex, PoisonError, Weak};
47
48use crate::djvu_render::{CacheLayer, PageLayers};
49
50/// The ceiling applied when the program sets none: 256 MiB.
51///
52/// Large enough that a viewer keeps a working set of full-resolution pages
53/// warm, small enough that a batch sweep over a long book does not grow without
54/// limit.
55pub const DEFAULT_BUDGET: usize = 256 * 1024 * 1024;
56
57/// The current ceiling. `usize::MAX` means "no ceiling".
58static BUDGET: AtomicUsize = AtomicUsize::new(DEFAULT_BUDGET);
59
60/// Bytes held by every registered page cache, kept current by
61/// `PageLayers::report_bytes` rather than re-measured on each fill.
62static RESIDENT: AtomicUsize = AtomicUsize::new(0);
63
64/// Every live page cache, weakly held so a dropped page needs no unregister
65/// step. Dead entries are pruned by the next sweep.
66static REGISTRY: Mutex<Vec<Weak<PageLayers>>> = Mutex::new(Vec::new());
67
68fn registry() -> std::sync::MutexGuard<'static, Vec<Weak<PageLayers>>> {
69 REGISTRY.lock().unwrap_or_else(PoisonError::into_inner)
70}
71
72/// The ceiling on the total resident bytes of all page render caches.
73pub fn budget() -> usize {
74 BUDGET.load(Ordering::Relaxed)
75}
76
77/// Set the ceiling. Pass `usize::MAX` to render without one (the behaviour of
78/// 0.32 and earlier).
79///
80/// The new ceiling is applied immediately: if the caches are already over it,
81/// this sweeps. Returns the bytes freed.
82pub fn set_budget(bytes: usize) -> usize {
83 BUDGET.store(bytes, Ordering::Relaxed);
84 enforce()
85}
86
87/// Approximate resident bytes held by every page render cache in this process.
88pub fn resident_bytes() -> usize {
89 RESIDENT.load(Ordering::Relaxed)
90}
91
92/// Drop least-recently-used cached layers until the total is at most
93/// [`budget`]. Returns the bytes freed; 0 when already under the ceiling.
94///
95/// Renders call this on their own. Call it directly after freeing documents, or
96/// at a moment of your choosing in a memory-sensitive program.
97pub fn enforce() -> usize {
98 sweep(None)
99}
100
101/// Drop every registered page cache, whatever the ceiling. Returns bytes freed.
102///
103/// Intended for tests and for a program that wants a known-cold starting point;
104/// the caches rebuild lazily and identically.
105pub fn clear() -> usize {
106 let live = live_caches();
107 let mut freed = 0;
108 for layers in &live {
109 freed += layers.cached_bytes();
110 layers.evict_shared();
111 }
112 freed
113}
114
115/// The registry length at which `register` compacts away dead entries.
116static PRUNE_AT: AtomicUsize = AtomicUsize::new(64);
117
118/// Register a newly created page cache. Called once per page, on the first
119/// access to its cache.
120pub(crate) fn register(layers: &Arc<PageLayers>) {
121 let mut reg = registry();
122 reg.push(Arc::downgrade(layers));
123 // A sweep prunes dead entries, but a program that stays under the ceiling
124 // never sweeps. Compact when the list has doubled since the last
125 // compaction, so opening and closing documents cannot grow it without
126 // bound while keeping the cost amortised.
127 if reg.len() >= PRUNE_AT.load(Ordering::Relaxed) {
128 reg.retain(|w| w.strong_count() > 0);
129 PRUNE_AT.store((reg.len() * 2).max(64), Ordering::Relaxed);
130 }
131}
132
133/// Fold a cache's size change into the process-wide total; returns the total.
134pub(crate) fn adjust_resident(previous: usize, current: usize) -> usize {
135 if current >= previous {
136 RESIDENT.fetch_add(current - previous, Ordering::AcqRel) + (current - previous)
137 } else {
138 RESIDENT.fetch_sub(previous - current, Ordering::AcqRel) - (previous - current)
139 }
140}
141
142/// The hot-path check: sweep only when `total` is already over the ceiling.
143///
144/// `keep` identifies the layer the caller is filling right now (see
145/// `PageLayers::layer_id`). It is never evicted — evicting it would drop the
146/// value the caller is about to return and guarantee a re-decode on the very
147/// next access. Every other layer, on the same page or another, is fair game.
148pub(crate) fn sweep_if_over(total: usize, keep: *const ()) {
149 if total > budget() {
150 sweep(Some(keep));
151 }
152}
153
154/// Snapshot the live caches, pruning entries whose page is gone.
155fn live_caches() -> Vec<Arc<PageLayers>> {
156 let mut reg = registry();
157 reg.retain(|w| w.strong_count() > 0);
158 reg.iter().filter_map(Weak::upgrade).collect()
159}
160
161/// Evict least-recently-used layers, across all pages, until the total is
162/// within the ceiling.
163fn sweep(keep: Option<*const ()>) -> usize {
164 let budget = budget();
165 if budget == usize::MAX {
166 return 0;
167 }
168 let live = live_caches();
169
170 // Measured once per layer here rather than read from RESIDENT: the global
171 // counter is updated per fill and can lag a concurrent report by one step,
172 // and a sweep that evicts on a stale number throws away warm layers.
173 let mut total: usize = 0;
174 // (tick, bytes, index into `live`, index into that page's layers)
175 let mut cands: Vec<(u64, usize, usize, usize)> = Vec::new();
176 for (page, layers) in live.iter().enumerate() {
177 for (index, layer) in layers.layers().into_iter().enumerate() {
178 let bytes = layer.resident_bytes();
179 if bytes == 0 {
180 continue;
181 }
182 total += bytes;
183 let id: *const () = (layer as *const dyn CacheLayer).cast();
184 if keep != Some(id) {
185 cands.push((layer.last_used(), bytes, page, index));
186 }
187 }
188 }
189 if total <= budget {
190 return 0;
191 }
192
193 // Least-recently-used first. A layer's tick is bumped on every hit, so a
194 // page's warm mask outranks its own cold background as much as it outranks
195 // another page's.
196 cands.sort_by_key(|&(tick, _, _, _)| tick);
197 let mut freed = 0;
198 let mut touched = vec![false; live.len()];
199 for (_, bytes, page, index) in cands {
200 if total <= budget {
201 break;
202 }
203 live[page].layers()[index].drop_cached();
204 touched[page] = true;
205 freed += bytes;
206 total = total.saturating_sub(bytes);
207 }
208 // One re-measure per page that lost something, so the global counter and
209 // each page's own `reported` figure follow the eviction.
210 for (page, touched) in touched.into_iter().enumerate() {
211 if touched {
212 live[page].report_bytes();
213 }
214 }
215 freed
216}