Skip to main content

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}