Skip to main content

concinnity_memory/
ledger.rs

1// concinnity-memory/src/ledger.rs
2//
3// Tagged byte accounting: who is holding memory, in which realm, against what
4// budget.
5//
6// The global counters say how much the heap holds; they cannot say what for. A
7// `GlobalAlloc` only ever sees a `Layout`, so attribution has to come from the
8// subsystems that know what they are holding -- the streaming pools know their
9// resident bytes, a GPU backend knows what it placed on the device -- and they
10// report it here. Host and device reports share one vocabulary so a readout can
11// show RAM and VRAM through the same lens.
12//
13// Reports are per-pool events (a load, an eviction, a budget change), not
14// per-allocation, so one atomic per tag is not a hot line and needs none of the
15// sharding the global counters use.
16
17use core::sync::atomic::{AtomicU64, Ordering::Relaxed};
18
19use crate::tag::{MemTag, Realm};
20
21// One tag's counters in one realm. A budget of zero is "no budget set": a
22// subsystem budgeted at zero bytes is not a case worth modelling.
23struct Cell {
24    bytes: AtomicU64,
25    peak: AtomicU64,
26    budget: AtomicU64,
27}
28
29impl Cell {
30    const fn new() -> Self {
31        Self {
32            bytes: AtomicU64::new(0),
33            peak: AtomicU64::new(0),
34            budget: AtomicU64::new(0),
35        }
36    }
37
38    fn observe(&self, bytes: u64) {
39        self.peak.fetch_max(bytes, Relaxed);
40    }
41}
42
43// What one tag holds in one realm, as read at a moment.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub struct TagUsage {
46    pub tag: MemTag,
47    pub realm: Realm,
48    pub bytes: u64,
49    // High-water mark of `bytes` since process start.
50    pub peak_bytes: u64,
51    // The ceiling this tag is expected to stay under, when one was declared.
52    pub budget: Option<u64>,
53}
54
55impl TagUsage {
56    // Nothing reported. Distinct from a reported zero only in that no reporter
57    // has touched the tag, which is why a readout hides it rather than drawing
58    // an empty row.
59    pub const fn empty(tag: MemTag, realm: Realm) -> Self {
60        Self {
61            tag,
62            realm,
63            bytes: 0,
64            peak_bytes: 0,
65            budget: None,
66        }
67    }
68
69    pub(crate) fn is_reported(&self) -> bool {
70        self.bytes > 0 || self.peak_bytes > 0 || self.budget.is_some()
71    }
72
73    pub fn over_budget(&self) -> bool {
74        self.budget.is_some_and(|b| self.bytes > b)
75    }
76
77    // How much of the budget is spent, or `None` when the tag has no budget.
78    pub fn fraction(&self) -> Option<f32> {
79        self.budget
80            .filter(|&b| b > 0)
81            .map(|b| self.bytes as f32 / b as f32)
82    }
83}
84
85/// Every tag's usage in every realm, read in one pass.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub struct LedgerSnapshot {
88    entries: [TagUsage; MemTag::COUNT * Realm::COUNT],
89}
90
91impl LedgerSnapshot {
92    /// One tag's usage in one realm.
93    pub fn get(&self, tag: MemTag, realm: Realm) -> TagUsage {
94        self.entries[entry_index(tag, realm)]
95    }
96
97    /// The tags something has reported into, in realm order. What a readout
98    /// lists: an unreported tag is absent rather than a row of zeroes.
99    pub fn reported(&self, realm: Realm) -> impl Iterator<Item = TagUsage> + '_ {
100        MemTag::ALL
101            .into_iter()
102            .map(move |tag| self.get(tag, realm))
103            .filter(TagUsage::is_reported)
104    }
105
106    /// Bytes attributed across every tag in `realm`. Always a floor on the
107    /// realm's real usage: it counts what reporters explain, not everything
108    /// allocated.
109    pub fn realm_bytes(&self, realm: Realm) -> u64 {
110        MemTag::ALL
111            .into_iter()
112            .map(|tag| self.get(tag, realm).bytes)
113            .sum()
114    }
115
116    /// Whether nothing has reported into any tag.
117    pub fn is_empty(&self) -> bool {
118        !self.entries.iter().any(TagUsage::is_reported)
119    }
120}
121
122impl Default for LedgerSnapshot {
123    fn default() -> Self {
124        Self {
125            entries: core::array::from_fn(|i| {
126                TagUsage::empty(MemTag::ALL[i / Realm::COUNT], Realm::ALL[i % Realm::COUNT])
127            }),
128        }
129    }
130}
131
132const fn entry_index(tag: MemTag, realm: Realm) -> usize {
133    tag.index() * Realm::COUNT + realm.index()
134}
135
136/// The tagged accounting itself. One process-global instance backs the engine
137/// (`crate::ledger()`); the type is public so the accounting is testable on its
138/// own instance.
139pub struct Ledger {
140    cells: [Cell; MemTag::COUNT * Realm::COUNT],
141}
142
143impl Ledger {
144    /// A ledger with every cell at zero and no budgets set.
145    pub const fn new() -> Self {
146        Self {
147            cells: [const { Cell::new() }; MemTag::COUNT * Realm::COUNT],
148        }
149    }
150
151    fn cell(&self, tag: MemTag, realm: Realm) -> &Cell {
152        &self.cells[entry_index(tag, realm)]
153    }
154
155    /// Take on `bytes` under `tag`. For a subsystem that grows and shrinks in
156    /// increments; one that knows its total should `set` it instead.
157    pub fn add(&self, tag: MemTag, realm: Realm, bytes: u64) {
158        let cell = self.cell(tag, realm);
159        let total = cell.bytes.fetch_add(bytes, Relaxed).saturating_add(bytes);
160        cell.observe(total);
161    }
162
163    /// Give up `bytes` under `tag`. Saturates at zero: a reporter that
164    /// double-releases should read as holding nothing, never as holding an
165    /// enormous amount.
166    pub fn release(&self, tag: MemTag, realm: Realm, bytes: u64) {
167        let cell = self.cell(tag, realm);
168        let _ = cell
169            .bytes
170            .fetch_update(Relaxed, Relaxed, |held| Some(held.saturating_sub(bytes)));
171    }
172
173    /// Declare the tag's whole holding. Idempotent, so a pool that recomputes
174    /// its resident bytes each frame cannot drift the way paired add/release
175    /// calls can.
176    pub fn set(&self, tag: MemTag, realm: Realm, bytes: u64) {
177        let cell = self.cell(tag, realm);
178        cell.bytes.store(bytes, Relaxed);
179        cell.observe(bytes);
180    }
181
182    /// The ceiling the tag is expected to stay under. `None` clears it.
183    pub fn set_budget(&self, tag: MemTag, realm: Realm, budget: Option<u64>) {
184        self.cell(tag, realm)
185            .budget
186            .store(budget.unwrap_or(0), Relaxed);
187    }
188
189    /// Set (or clear, with `None`) the byte budget for one tag and realm.
190    pub fn usage(&self, tag: MemTag, realm: Realm) -> TagUsage {
191        let cell = self.cell(tag, realm);
192        let budget = cell.budget.load(Relaxed);
193        TagUsage {
194            tag,
195            realm,
196            bytes: cell.bytes.load(Relaxed),
197            peak_bytes: cell.peak.load(Relaxed),
198            budget: (budget > 0).then_some(budget),
199        }
200    }
201
202    /// One tag's current usage in one realm.
203    pub fn snapshot(&self) -> LedgerSnapshot {
204        LedgerSnapshot {
205            entries: core::array::from_fn(|i| {
206                self.usage(MemTag::ALL[i / Realm::COUNT], Realm::ALL[i % Realm::COUNT])
207            }),
208        }
209    }
210
211    /// Drop every report and budget, including the peaks. For a host tearing a
212    /// world down and building another, whose old numbers describe nothing.
213    pub fn clear(&self) {
214        for cell in &self.cells {
215            cell.bytes.store(0, Relaxed);
216            cell.peak.store(0, Relaxed);
217            cell.budget.store(0, Relaxed);
218        }
219    }
220}
221
222impl Default for Ledger {
223    fn default() -> Self {
224        Self::new()
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn an_untouched_ledger_reports_nothing() {
234        let ledger = Ledger::new();
235        let snap = ledger.snapshot();
236        assert!(snap.is_empty());
237        assert_eq!(snap.reported(Realm::Host).count(), 0);
238        assert_eq!(snap.realm_bytes(Realm::Device), 0);
239    }
240
241    #[test]
242    fn increments_accumulate_and_release_gives_them_back() {
243        let ledger = Ledger::new();
244        ledger.add(MemTag::Audio, Realm::Host, 1024);
245        ledger.add(MemTag::Audio, Realm::Host, 512);
246        assert_eq!(ledger.usage(MemTag::Audio, Realm::Host).bytes, 1536);
247
248        ledger.release(MemTag::Audio, Realm::Host, 512);
249        assert_eq!(ledger.usage(MemTag::Audio, Realm::Host).bytes, 1024);
250    }
251
252    // A reporter that gives back more than it took reads as holding nothing.
253    // The alternative -- wrapping past zero -- would show as exabytes held.
254    #[test]
255    fn releasing_more_than_held_saturates_at_zero() {
256        let ledger = Ledger::new();
257        ledger.add(MemTag::Meshes, Realm::Device, 100);
258        ledger.release(MemTag::Meshes, Realm::Device, 4096);
259        assert_eq!(ledger.usage(MemTag::Meshes, Realm::Device).bytes, 0);
260    }
261
262    // The idempotent form: a pool republishing its total every frame must not
263    // accumulate.
264    #[test]
265    fn set_replaces_rather_than_accumulates() {
266        let ledger = Ledger::new();
267        ledger.set(MemTag::Textures, Realm::Device, 8_000);
268        ledger.set(MemTag::Textures, Realm::Device, 6_000);
269        assert_eq!(ledger.usage(MemTag::Textures, Realm::Device).bytes, 6_000);
270    }
271
272    #[test]
273    fn peak_holds_the_high_water_mark_across_both_report_styles() {
274        let ledger = Ledger::new();
275        ledger.set(MemTag::Textures, Realm::Device, 9_000);
276        ledger.set(MemTag::Textures, Realm::Device, 1_000);
277        ledger.add(MemTag::Textures, Realm::Device, 500);
278
279        let usage = ledger.usage(MemTag::Textures, Realm::Device);
280        assert_eq!(usage.bytes, 1_500);
281        assert_eq!(usage.peak_bytes, 9_000);
282    }
283
284    #[test]
285    fn a_budget_reads_back_and_clears() {
286        let ledger = Ledger::new();
287        ledger.set_budget(MemTag::Chunks, Realm::Device, Some(4_096));
288        assert_eq!(
289            ledger.usage(MemTag::Chunks, Realm::Device).budget,
290            Some(4_096)
291        );
292
293        ledger.set_budget(MemTag::Chunks, Realm::Device, None);
294        assert_eq!(ledger.usage(MemTag::Chunks, Realm::Device).budget, None);
295    }
296
297    #[test]
298    fn over_budget_and_fraction_track_the_ceiling() {
299        let ledger = Ledger::new();
300        ledger.set_budget(MemTag::Meshes, Realm::Device, Some(1_000));
301        ledger.set(MemTag::Meshes, Realm::Device, 500);
302        let under = ledger.usage(MemTag::Meshes, Realm::Device);
303        assert!(!under.over_budget());
304        assert_eq!(under.fraction(), Some(0.5));
305
306        ledger.set(MemTag::Meshes, Realm::Device, 1_200);
307        assert!(ledger.usage(MemTag::Meshes, Realm::Device).over_budget());
308    }
309
310    // An unbudgeted tag has no fraction to draw; a readout shows the bytes
311    // alone rather than inventing a scale.
312    #[test]
313    fn an_unbudgeted_tag_has_no_fraction() {
314        let ledger = Ledger::new();
315        ledger.set(MemTag::Scratch, Realm::Host, 4_096);
316        let usage = ledger.usage(MemTag::Scratch, Realm::Host);
317        assert_eq!(usage.fraction(), None);
318        assert!(!usage.over_budget());
319    }
320
321    // Host and device are separate accounts under one tag: a streamed texture
322    // costs device bytes without touching the host row.
323    #[test]
324    fn the_realms_are_counted_separately() {
325        let ledger = Ledger::new();
326        ledger.set(MemTag::Textures, Realm::Device, 2_048);
327        ledger.set(MemTag::Textures, Realm::Host, 64);
328
329        let snap = ledger.snapshot();
330        assert_eq!(snap.get(MemTag::Textures, Realm::Device).bytes, 2_048);
331        assert_eq!(snap.get(MemTag::Textures, Realm::Host).bytes, 64);
332        assert_eq!(snap.realm_bytes(Realm::Device), 2_048);
333        assert_eq!(snap.realm_bytes(Realm::Host), 64);
334    }
335
336    // A readout lists reported tags only, in the fixed vocabulary order, so
337    // rows never reorder underneath a reader.
338    #[test]
339    fn reported_lists_touched_tags_in_vocabulary_order() {
340        let ledger = Ledger::new();
341        ledger.set(MemTag::Chunks, Realm::Device, 3);
342        ledger.set(MemTag::Textures, Realm::Device, 1);
343        ledger.set(MemTag::Meshes, Realm::Device, 2);
344        ledger.set(MemTag::Audio, Realm::Host, 9);
345
346        let tags: std::vec::Vec<MemTag> = ledger
347            .snapshot()
348            .reported(Realm::Device)
349            .map(|u| u.tag)
350            .collect();
351        assert_eq!(tags, [MemTag::Textures, MemTag::Meshes, MemTag::Chunks]);
352    }
353
354    // A tag that has held memory stays listed at zero: "loaded nothing right
355    // now" is a different reading from "nobody reports this".
356    #[test]
357    fn a_tag_that_dropped_to_zero_stays_reported() {
358        let ledger = Ledger::new();
359        ledger.set(MemTag::Textures, Realm::Device, 4_096);
360        ledger.set(MemTag::Textures, Realm::Device, 0);
361
362        let snap = ledger.snapshot();
363        assert!(snap.get(MemTag::Textures, Realm::Device).is_reported());
364        assert_eq!(snap.reported(Realm::Device).count(), 1);
365    }
366
367    #[test]
368    fn clear_drops_every_report_and_budget() {
369        let ledger = Ledger::new();
370        ledger.set(MemTag::Textures, Realm::Device, 4_096);
371        ledger.set_budget(MemTag::Textures, Realm::Device, Some(8_192));
372        ledger.clear();
373
374        assert!(ledger.snapshot().is_empty());
375        assert_eq!(ledger.usage(MemTag::Textures, Realm::Device).peak_bytes, 0);
376    }
377
378    // Every cell must be its own account; an indexing slip would show as one
379    // tag's bytes appearing under another.
380    #[test]
381    fn every_tag_and_realm_addresses_its_own_cell() {
382        let ledger = Ledger::new();
383        for (i, tag) in MemTag::ALL.into_iter().enumerate() {
384            for (j, realm) in Realm::ALL.into_iter().enumerate() {
385                ledger.set(tag, realm, (i * Realm::COUNT + j + 1) as u64);
386            }
387        }
388        let snap = ledger.snapshot();
389        for (i, tag) in MemTag::ALL.into_iter().enumerate() {
390            for (j, realm) in Realm::ALL.into_iter().enumerate() {
391                let usage = snap.get(tag, realm);
392                assert_eq!(usage.bytes, (i * Realm::COUNT + j + 1) as u64);
393                assert_eq!(usage.tag, tag);
394                assert_eq!(usage.realm, realm);
395            }
396        }
397    }
398}