Skip to main content

lgui_core/memory/
governor.rs

1use std::{
2    sync::{
3        atomic::{AtomicU64, AtomicUsize, Ordering},
4        Arc, Mutex,
5    },
6    time::{Duration, Instant},
7};
8
9use super::registry::RegistryState;
10use super::{
11    CacheDomain, CacheRegistration, CacheScope, CacheUsage, DomainInstanceId, DomainRegistration,
12    DomainSnapshot, MemoryAction, MemoryEvent, MemoryOptions, MemorySnapshot, TrimReason,
13    TrimRequest, TrimSnapshot,
14};
15
16#[cfg(feature = "persistent-cache")]
17use super::PersistentCacheStore;
18
19const FRAME_BUDGET_CHECK_INTERVAL: Duration = Duration::from_millis(250);
20
21#[derive(Clone)]
22pub struct MemoryGovernor {
23    inner: Arc<MemoryGovernorInner>,
24}
25
26struct MemoryGovernorInner {
27    options: Mutex<MemoryOptions>,
28    registry: Arc<Mutex<RegistryState>>,
29    epoch: AtomicU64,
30    transient_reserved: Arc<AtomicUsize>,
31    large_tasks_in_flight: Arc<AtomicUsize>,
32    last_trim: Mutex<Option<TrimSnapshot>>,
33    last_frame_budget_check: Mutex<Option<Instant>>,
34    #[cfg(feature = "persistent-cache")]
35    persistent: Option<Arc<dyn PersistentCacheStore>>,
36}
37
38impl MemoryGovernor {
39    pub fn new(options: MemoryOptions) -> Self {
40        Self::with_store(
41            options,
42            #[cfg(feature = "persistent-cache")]
43            None,
44        )
45    }
46
47    pub(crate) fn with_store(
48        options: MemoryOptions,
49        #[cfg(feature = "persistent-cache")] persistent: Option<Arc<dyn PersistentCacheStore>>,
50    ) -> Self {
51        options
52            .validate()
53            .expect("invalid application memory policy");
54        Self {
55            inner: Arc::new(MemoryGovernorInner {
56                options: Mutex::new(options),
57                registry: Arc::new(Mutex::new(RegistryState::default())),
58                epoch: AtomicU64::new(1),
59                transient_reserved: Arc::new(AtomicUsize::new(0)),
60                large_tasks_in_flight: Arc::new(AtomicUsize::new(0)),
61                last_trim: Mutex::new(None),
62                last_frame_budget_check: Mutex::new(None),
63                #[cfg(feature = "persistent-cache")]
64                persistent,
65            }),
66        }
67    }
68
69    pub fn options(&self) -> MemoryOptions {
70        *self.inner.options.lock().expect("memory options poisoned")
71    }
72
73    pub fn set_options(&self, options: MemoryOptions) {
74        options
75            .validate()
76            .expect("invalid application memory policy");
77        *self.inner.options.lock().expect("memory options poisoned") = options;
78        self.bump_epoch();
79        self.rebalance_budgets();
80        self.enforce_budget(true);
81        #[cfg(feature = "persistent-cache")]
82        if let Some(store) = self.persistent_cache() {
83            let _ = store.trim_to(options.budget.persistent_bytes);
84        }
85    }
86
87    pub fn next_instance_id(&self) -> DomainInstanceId {
88        let mut registry = self
89            .inner
90            .registry
91            .lock()
92            .expect("memory registry poisoned");
93        let id = registry.next_instance;
94        registry.next_instance = registry.next_instance.wrapping_add(1).max(1);
95        DomainInstanceId(id)
96    }
97
98    pub fn register(&self, registration: DomainRegistration) -> CacheRegistration {
99        let mut registry = self
100            .inner
101            .registry
102            .lock()
103            .expect("memory registry poisoned");
104        let id = registry.next_registration;
105        registry.next_registration = registry.next_registration.wrapping_add(1).max(1);
106        registry.entries.insert(id, registration);
107        drop(registry);
108        self.rebalance_budgets();
109        self.bump_epoch();
110        let governor = self.clone();
111        CacheRegistration::new(id, Arc::downgrade(&self.inner.registry), move || {
112            governor.rebalance_budgets();
113            governor.bump_epoch();
114        })
115    }
116
117    pub fn snapshot(&self) -> MemorySnapshot {
118        let epoch = self.inner.epoch.load(Ordering::Acquire);
119        let entries = self.registered_entries();
120        let mut usage = CacheUsage::default();
121        let mut domains = Vec::with_capacity(entries.len());
122        for (id, registration) in entries {
123            let domain_usage = registration.adapter.usage();
124            usage.add_assign(domain_usage);
125            domains.push(DomainSnapshot {
126                registration_id: id,
127                domain: registration.domain,
128                instance: registration.instance,
129                owner: registration.owner,
130                usage: domain_usage,
131            });
132        }
133        let options = self.options();
134        let cache_soft = options.budget.cache_soft_bytes;
135        MemorySnapshot {
136            epoch,
137            options,
138            transient_reserved_bytes: self.inner.transient_reserved.load(Ordering::Acquire),
139            large_tasks_in_flight: self.inner.large_tasks_in_flight.load(Ordering::Acquire),
140            pinned_overflow_bytes: usage.pinned_bytes.saturating_sub(cache_soft),
141            usage,
142            domains,
143            last_trim: *self
144                .inner
145                .last_trim
146                .lock()
147                .expect("memory trim state poisoned"),
148        }
149    }
150
151    pub fn trim(&self, reason: TrimReason, scope: CacheScope, target_bytes: usize) -> usize {
152        let epoch = self.bump_epoch();
153        let started = Instant::now();
154        let entries = if scope == CacheScope::Persistent {
155            Vec::new()
156        } else {
157            self.registered_entries()
158        };
159        let assignments = entries
160            .iter()
161            .filter(|(_, registration)| {
162                registration.domain != CacheDomain::Persistent
163                    && (scope == CacheScope::AllRebuildable
164                        || registration.domain != CacheDomain::HostScene)
165            })
166            .map(|(_, registration)| {
167                (
168                    registration,
169                    self.assigned_budget(registration.domain, &entries),
170                )
171            })
172            .collect::<Vec<_>>();
173        let assigned_total = assignments
174            .iter()
175            .map(|(_, assigned)| *assigned as u128)
176            .sum::<u128>();
177        let mut released = 0usize;
178        for (registration, assigned) in assignments {
179            let domain_target = if target_bytes == 0 {
180                0
181            } else if target_bytes == usize::MAX {
182                assigned
183            } else if assigned_total == 0 {
184                0
185            } else {
186                let proportional =
187                    (assigned as u128).saturating_mul(target_bytes as u128) / assigned_total;
188                usize::try_from(proportional).unwrap_or(usize::MAX)
189            };
190            let result = registration.adapter.trim(TrimRequest {
191                reason,
192                scope,
193                target_bytes: domain_target,
194            });
195            released = released.saturating_add(result.released_bytes());
196        }
197        #[cfg(feature = "persistent-cache")]
198        if scope == CacheScope::Persistent {
199            if let Some(store) = self.inner.persistent.as_ref() {
200                let target = u64::try_from(target_bytes).unwrap_or(u64::MAX);
201                if let (Ok(before), Ok(after)) = (store.stats(), store.trim_to(target)) {
202                    released = released.saturating_add(
203                        usize::try_from(before.bytes.saturating_sub(after.bytes))
204                            .unwrap_or(usize::MAX),
205                    );
206                }
207            }
208        }
209        let duration_micros = started.elapsed().as_micros().min(u64::MAX as u128) as u64;
210        *self
211            .inner
212            .last_trim
213            .lock()
214            .expect("memory trim state poisoned") = Some(TrimSnapshot {
215            reason,
216            requested_at_epoch: epoch,
217            released_bytes: released,
218            duration_micros,
219        });
220        released
221    }
222
223    pub fn invalidate_domain(&self, domain: CacheDomain) -> usize {
224        let entries = self.registered_entries();
225        let mut released = 0usize;
226        for (_, registration) in entries {
227            if registration.domain == domain {
228                released = released.saturating_add(
229                    registration
230                        .adapter
231                        .trim(TrimRequest {
232                            reason: TrimReason::Explicit,
233                            scope: CacheScope::Memory,
234                            target_bytes: 0,
235                        })
236                        .released_bytes(),
237                );
238            }
239        }
240        self.bump_epoch();
241        released
242    }
243
244    pub fn notify(&self, event: MemoryEvent) {
245        match self.options().event_action(event) {
246            MemoryAction::None => {}
247            MemoryAction::EnforceBudget => {
248                if event == MemoryEvent::FrameCommitted {
249                    if self.begin_frame_budget_check() {
250                        self.finish_frame_budget_check();
251                    }
252                } else {
253                    self.enforce_budget(true);
254                }
255            }
256            MemoryAction::Trim {
257                scope,
258                target_bytes,
259            } => {
260                self.trim(event.trim_reason(), scope, target_bytes);
261            }
262        }
263    }
264
265    pub fn try_reserve(&self, bytes: usize) -> Option<MemoryReservation> {
266        let hard = self.options().budget.transient_hard_bytes;
267        let reserved = &self.inner.transient_reserved;
268        if hard == usize::MAX {
269            return Some(MemoryReservation {
270                bytes,
271                accounted_bytes: 0,
272                reserved: Arc::clone(reserved),
273            });
274        }
275        let mut current = reserved.load(Ordering::Acquire);
276        loop {
277            let next = current.checked_add(bytes)?;
278            if next > hard {
279                self.trim(TrimReason::HardBudget, CacheScope::Memory, 0);
280                return None;
281            }
282            match reserved.compare_exchange_weak(current, next, Ordering::AcqRel, Ordering::Acquire)
283            {
284                Ok(_) => {
285                    return Some(MemoryReservation {
286                        bytes,
287                        accounted_bytes: bytes,
288                        reserved: Arc::clone(reserved),
289                    })
290                }
291                Err(actual) => current = actual,
292            }
293        }
294    }
295
296    pub fn try_reserve_task(&self, bytes: usize) -> Option<MemoryTaskReservation> {
297        let limit = self.options().budget.max_parallel_large_tasks;
298        let in_flight = &self.inner.large_tasks_in_flight;
299        let mut current = in_flight.load(Ordering::Acquire);
300        loop {
301            if current >= limit {
302                return None;
303            }
304            match in_flight.compare_exchange_weak(
305                current,
306                current + 1,
307                Ordering::AcqRel,
308                Ordering::Acquire,
309            ) {
310                Ok(_) => break,
311                Err(actual) => current = actual,
312            }
313        }
314        let Some(reservation) = self.try_reserve(bytes) else {
315            in_flight.fetch_sub(1, Ordering::AcqRel);
316            return None;
317        };
318        Some(MemoryTaskReservation {
319            _reservation: reservation,
320            in_flight: Arc::clone(in_flight),
321        })
322    }
323
324    #[cfg(feature = "persistent-cache")]
325    pub fn persistent_cache(&self) -> Option<Arc<dyn PersistentCacheStore>> {
326        self.options()
327            .persistent_cache_enabled
328            .then(|| self.inner.persistent.as_ref().map(Arc::clone))
329            .flatten()
330    }
331
332    #[cfg(feature = "persistent-cache")]
333    pub fn persistent_cache_stats(
334        &self,
335    ) -> Result<super::PersistentCacheStats, super::CacheStoreError> {
336        self.inner.persistent.as_ref().map_or_else(
337            || Ok(super::PersistentCacheStats::default()),
338            |store| store.stats(),
339        )
340    }
341
342    #[cfg(feature = "persistent-cache")]
343    pub fn clear_persistent_cache(
344        &self,
345        namespace: Option<&str>,
346    ) -> Result<super::PersistentCacheStats, super::CacheStoreError> {
347        if let Some(store) = self.inner.persistent.as_ref() {
348            store.clear(namespace)?;
349            self.bump_epoch();
350            return store.stats();
351        }
352        Ok(super::PersistentCacheStats::default())
353    }
354
355    fn enforce_budget(&self, include_soft_limit: bool) {
356        let options = self.options();
357        let usage = self.budget_usage();
358        let evictable = usage.managed_bytes.saturating_sub(usage.protected_bytes);
359        let reason = if evictable > options.budget.cache_hard_bytes {
360            Some(TrimReason::HardBudget)
361        } else if include_soft_limit && evictable > options.budget.cache_soft_bytes {
362            Some(TrimReason::SoftBudget)
363        } else {
364            None
365        };
366        if let Some(reason) = reason {
367            self.trim(reason, CacheScope::Memory, options.budget.cache_soft_bytes);
368        }
369    }
370
371    pub(crate) fn begin_frame_budget_check(&self) -> bool {
372        if self.options().event_action(MemoryEvent::FrameCommitted) != MemoryAction::EnforceBudget {
373            return false;
374        }
375        let now = Instant::now();
376        let mut last = self
377            .inner
378            .last_frame_budget_check
379            .lock()
380            .expect("memory budget check state poisoned");
381        if last.is_some_and(|previous| {
382            now.saturating_duration_since(previous) < FRAME_BUDGET_CHECK_INTERVAL
383        }) {
384            return false;
385        }
386        *last = Some(now);
387        true
388    }
389
390    pub(crate) fn finish_frame_budget_check(&self) {
391        self.enforce_budget(false);
392    }
393
394    fn budget_usage(&self) -> BudgetUsage {
395        let probes = self.registered_usage_probes();
396        let mut result = BudgetUsage::default();
397        for (domain, adapter) in probes {
398            let usage = adapter.usage();
399            let managed = usage.managed_bytes();
400            result.managed_bytes = result.managed_bytes.saturating_add(managed);
401            let protected = if domain == CacheDomain::HostScene {
402                managed
403            } else {
404                usage.pinned_bytes.min(managed)
405            };
406            result.protected_bytes = result.protected_bytes.saturating_add(protected);
407        }
408        result
409    }
410
411    fn registered_usage_probes(&self) -> Vec<(CacheDomain, super::CacheAdapter)> {
412        self.inner
413            .registry
414            .lock()
415            .expect("memory registry poisoned")
416            .entries
417            .values()
418            .map(|registration| (registration.domain, registration.adapter.clone()))
419            .collect()
420    }
421
422    fn registered_entries(&self) -> Vec<(u64, DomainRegistration)> {
423        self.inner
424            .registry
425            .lock()
426            .expect("memory registry poisoned")
427            .entries
428            .iter()
429            .map(|(id, registration)| (*id, registration.clone()))
430            .collect()
431    }
432
433    fn rebalance_budgets(&self) {
434        let entries = self.registered_entries();
435        for (_, registration) in &entries {
436            registration
437                .adapter
438                .set_budget(self.assigned_budget(registration.domain, &entries));
439        }
440    }
441
442    fn assigned_budget(&self, domain: CacheDomain, entries: &[(u64, DomainRegistration)]) -> usize {
443        let options = self.options();
444        if domain == CacheDomain::Persistent {
445            return options.budget.persistent_bytes.min(usize::MAX as u64) as usize;
446        }
447        let instances = entries
448            .iter()
449            .filter(|(_, registration)| registration.domain == domain)
450            .count()
451            .max(1);
452        options.domain_budget(domain).saturating_div(instances)
453    }
454
455    fn bump_epoch(&self) -> u64 {
456        self.inner.epoch.fetch_add(1, Ordering::AcqRel) + 1
457    }
458}
459
460#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
461struct BudgetUsage {
462    managed_bytes: usize,
463    protected_bytes: usize,
464}
465
466pub struct MemoryReservation {
467    bytes: usize,
468    accounted_bytes: usize,
469    reserved: Arc<AtomicUsize>,
470}
471
472pub struct MemoryTaskReservation {
473    _reservation: MemoryReservation,
474    in_flight: Arc<AtomicUsize>,
475}
476
477impl MemoryTaskReservation {
478    pub const fn bytes(&self) -> usize {
479        self._reservation.bytes()
480    }
481}
482
483impl Drop for MemoryTaskReservation {
484    fn drop(&mut self) {
485        self.in_flight.fetch_sub(1, Ordering::AcqRel);
486    }
487}
488
489impl MemoryReservation {
490    pub const fn bytes(&self) -> usize {
491        self.bytes
492    }
493}
494
495impl Drop for MemoryReservation {
496    fn drop(&mut self) {
497        self.reserved
498            .fetch_sub(self.accounted_bytes, Ordering::AcqRel);
499    }
500}