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}