Skip to main content

shuyi_alloc_count/
lib.rs

1//! Shuyi Engine's heap counter (ADR-0046 §9): [`CountingAlloc`], a global
2//! allocator around another that counts what is asked of it, and with the
3//! `global` feature, the `global` module: the one counting allocator of
4//! every debug build of a game on `shuyi-app` and of its tool program.
5//!
6//! What it counts:
7//!
8//! - **the bytes asked for**, each [`Layout`](core::alloc::Layout)'s
9//!   size: never the inner allocator's own overhead, so a reading is what
10//!   the program asked for, the same on every machine;
11//! - **live bytes**, allocated and not yet freed, and **their peak**, the
12//!   most at once since the start or a [`CountingAlloc::reset_peak`];
13//! - **calls** to `alloc`, `alloc_zeroed` and `realloc`, taken: each read
14//!   ([`CountingAlloc::take_allocs`]) gives the calls since the last.
15//!
16//! A global allocator counts the **whole process**: every thread's
17//! allocations count, not only the game's.
18//!
19//! **Debug builds count; release builds don't.** `shuyi-app` and
20//! `shuyi-cli` turn on `global`, so a game's debug build and its tool
21//! program's run on `global`'s counter, and `App` puts its counts in
22//! each frame's record (`shuyi_ui::perf::FrameRecord::heap`). A game with
23//! a `#[global_allocator]` of its own opts out, since a binary has one:
24//! in its `.cargo/config.toml`,
25//!
26//! ```toml
27//! [build]
28//! rustflags = ["--cfg", "shuyi_no_heap_count"]
29//! ```
30//!
31//! Without `std` the crate is `no_std`: a console's backend wraps the
32//! SDK's own allocator in a [`CountingAlloc`].
33//!
34//! **Memory tags** (ADR-0046 §6) say what the bytes are for. A
35//! [`CountingAlloc::with_tags`] also counts each block under the tag
36//! current on its thread when it was allocated: the frame's phase
37//! (`update`, `draw`, …), the game's span, or the asset kind being
38//! decoded, the innermost one entered winning ([`tags`]). Its
39//! [`rows`](CountingAlloc::rows) give each tag's live bytes and blocks,
40//! the bytes ever allocated under it and its peak ([`TagCounts`]). Tags
41//! come from a process-wide table of 256: tag 0 is `other`, what is
42//! allocated outside any tag, and tag 255 `overflow`, which every name
43//! past the 254th shares ([`tags::overflowed`] counts them). A `realloc`
44//! moves its block to the current tag, so a list that grows in `update`
45//! counts in `update`.
46//!
47//! **The cost:** each block carries its tag in a header in front of it,
48//! of its alignment or 8 bytes if that is more ([`Tagged`]), and each call
49//! counts twice. The `global` counter has tags, in debug builds only; a
50//! [`CountingAlloc::new`] has none, and no header.
51
52#![cfg_attr(not(feature = "std"), no_std)]
53// Counts are whole bytes (ADR-0032).
54#![deny(clippy::float_arithmetic)]
55
56extern crate alloc;
57
58// The `GlobalAlloc` impls, the tags' table and the counts' cells: this
59// crate's only `unsafe`.
60#[allow(unsafe_code)]
61mod counter;
62#[cfg(feature = "global")]
63pub mod global;
64
65pub use counter::{CountingAlloc, Tagged, Untagged, tags};
66
67/// What a [`CountingAlloc`] counted, read at once
68/// ([`CountingAlloc::counts`]). Build it with [`Counts::new`] (ADR-0039).
69#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
70#[non_exhaustive]
71pub struct Counts {
72    /// The bytes allocated and not yet freed.
73    pub live_bytes: u64,
74    /// The most bytes live at once since the start or the last
75    /// [`CountingAlloc::reset_peak`].
76    pub peak_bytes: u64,
77    /// The calls to `alloc`, `alloc_zeroed` and `realloc` since the last
78    /// reading.
79    pub allocs: u32,
80}
81
82impl Counts {
83    /// `live_bytes` live, `peak_bytes` at the most, and `allocs` calls.
84    pub const fn new(live_bytes: u64, peak_bytes: u64, allocs: u32) -> Self {
85        Self {
86            live_bytes,
87            peak_bytes,
88            allocs,
89        }
90    }
91}
92
93/// One memory tag's counts in a [`CountingAlloc::with_tags`]
94/// ([`CountingAlloc::rows`]), in bytes asked for. Build it with
95/// [`TagCounts::new`] (ADR-0039).
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97#[non_exhaustive]
98pub struct TagCounts {
99    /// The tag ([`tags::tag`]).
100    pub tag: u16,
101    /// Its name's class, as [`tags::tag`] was given it;
102    /// [`tags::OTHER_CLASS`] for `other` and `overflow`.
103    pub class: u8,
104    /// Its name: `other` for tag 0, `overflow` for tag 255.
105    pub name: &'static str,
106    /// The bytes live under it.
107    pub live_bytes: u64,
108    /// The blocks live under it.
109    pub live_blocks: u32,
110    /// The bytes ever allocated under it: a `realloc` adds its new size
111    /// (a machine word: on a 32-bit target it wraps past 4 GiB).
112    pub allocated_bytes: u64,
113    /// The most bytes live under it at once.
114    pub peak_bytes: u64,
115}
116
117impl TagCounts {
118    /// Tag `tag`, named `name` of `class`, with nothing counted.
119    pub const fn new(tag: u16, class: u8, name: &'static str) -> Self {
120        Self {
121            tag,
122            class,
123            name,
124            live_bytes: 0,
125            live_blocks: 0,
126            allocated_bytes: 0,
127            peak_bytes: 0,
128        }
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    /// The lines of `manifest`'s `[header]` table, without comments and
135    /// blank lines.
136    fn table<'a>(manifest: &'a str, header: &str) -> Vec<&'a str> {
137        let lines = manifest.lines().skip_while(|l| l.trim() != header).skip(1);
138        lines
139            .take_while(|l| !l.starts_with('['))
140            .filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
141            .collect()
142    }
143
144    /// This crate copies the workspace lints because one of them differs
145    /// (`unsafe_code`, ADR-0046 §9); the copy must not drift.
146    #[test]
147    fn the_lints_are_the_workspace_lints_except_unsafe_code() {
148        let here = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
149        let read = |path: &std::path::Path| std::fs::read_to_string(path).unwrap();
150        let root = here.parent().unwrap().parent().unwrap();
151        let workspace = read(&root.join("Cargo.toml"));
152        let clippy = table(&workspace, "[workspace.lints.clippy]");
153        assert!(clippy.len() > 5, "{clippy:?}");
154        let rust: Vec<String> = table(&workspace, "[workspace.lints.rust]")
155            .iter()
156            .map(|l| l.replace("unsafe_code = \"forbid\"", "unsafe_code = \"deny\""))
157            .collect();
158        assert!(
159            rust.contains(&"unsafe_code = \"deny\"".to_owned()),
160            "{rust:?}"
161        );
162        let manifest = read(&here.join("Cargo.toml"));
163        assert_eq!(table(&manifest, "[lints.clippy]"), clippy);
164        assert_eq!(table(&manifest, "[lints.rust]"), rust);
165    }
166
167    /// One module may use `unsafe`, the counter's: the crate's other files
168    /// never name it.
169    #[test]
170    fn only_the_counter_allows_unsafe_code() {
171        let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
172        let lib = std::fs::read_to_string(src.join("lib.rs")).unwrap();
173        let lines: Vec<&str> = lib.lines().collect();
174        let allows: Vec<usize> = (0..lines.len())
175            .filter(|&i| lines[i] == "#[allow(unsafe_code)]")
176            .collect();
177        assert_eq!(allows.len(), 1, "{allows:?}");
178        assert_eq!(lines[allows[0] + 1], "mod counter;");
179        let global = std::fs::read_to_string(src.join("global.rs")).unwrap();
180        assert!(!global.contains("unsafe"), "global.rs");
181    }
182}