blue_lang_runtime/alloc.rs
1//! How many allocations a stretch of a blue program made, and how many bytes.
2//!
3//! `blue profile` reports time per definition; this is the other half of what
4//! a definition costs (`theory/BLUE-MEMORY.md` §VI M0: you cannot configure
5//! what you cannot account for). One counter, read at two points: a caller
6//! snapshots [`allocations`] when an activation enters and again when it
7//! leaves, and the difference is what that activation allocated, everything it
8//! called included. Subtracting what its callees allocated gives the
9//! exclusive figure, exactly as the profiler does with time.
10//!
11//! [`Counting`] is a `GlobalAlloc` over `System` that counts each allocation
12//! on the thread that made it. A binary opts in with one line:
13//!
14//! ```ignore
15//! #[global_allocator]
16//! static ALLOC: blue_lang_runtime::alloc::Counting = blue_lang_runtime::alloc::Counting;
17//! ```
18//!
19//! The count is per thread so a profile of one interpreter is not charged for
20//! another thread's work, and it costs one thread-local access per allocation
21//! and per free (so a room can say what it left live). Until a binary installs [`Counting`],
22//! [`allocations`] answers `None`: a program that was never counted reads as
23//! unmeasured, never as having allocated nothing.
24//!
25//! The same access charges an [`Account`]: the live bytes one evaluation, or
26//! one blue process, holds, against the bound `max_alloc_bytes` sets
27//! (`crate::memory`). [`charged_to`] makes an account the one running on this
28//! thread for a scope, so the tree-walker's root, each process slice and each
29//! message in transit are charged to whoever owns them.
30
31use std::alloc::{GlobalAlloc, Layout, System};
32use std::cell::Cell;
33use std::sync::atomic::{AtomicBool, Ordering};
34
35/// Allocations made and bytes requested on one thread, from its start.
36#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
37pub struct Allocations {
38 /// Allocation calls. A `realloc` that grows counts as one.
39 pub count: u64,
40 /// Bytes requested. A growing `realloc` counts the bytes it added.
41 pub bytes: u64,
42 /// Bytes returned. A shrinking `realloc` counts the bytes it gave back.
43 /// Charged to the thread that frees, which for blue's single-threaded
44 /// interpreters is the thread that allocated.
45 pub freed: u64,
46}
47
48impl Allocations {
49 /// What was allocated between `earlier` and `self`, on one thread.
50 #[must_use]
51 pub fn since(self, earlier: Self) -> Self {
52 Self {
53 count: self.count.saturating_sub(earlier.count),
54 bytes: self.bytes.saturating_sub(earlier.bytes),
55 freed: self.freed.saturating_sub(earlier.freed),
56 }
57 }
58
59 /// Bytes allocated and not yet returned over the stretch this measures:
60 /// what it left live. Negative when it freed more than it allocated
61 /// (memory from before the stretch was released inside it).
62 #[must_use]
63 pub fn retained(self) -> i64 {
64 i64::try_from(self.bytes).unwrap_or(i64::MAX)
65 - i64::try_from(self.freed).unwrap_or(i64::MAX)
66 }
67}
68
69impl std::ops::AddAssign for Allocations {
70 fn add_assign(&mut self, other: Self) {
71 self.count += other.count;
72 self.bytes += other.bytes;
73 self.freed += other.freed;
74 }
75}
76
77/// Set by the first allocation [`Counting`] serves, so [`allocations`] can tell
78/// "not counted" from "counted zero".
79static INSTALLED: AtomicBool = AtomicBool::new(false);
80
81/// One thread's counters, and the account its allocations are charged to.
82///
83/// One `const`-initialised thread-local: no lazy init, so no allocation from
84/// inside the allocator, and no destructor to run during thread teardown. An
85/// allocation made while the thread's locals are torn down is served and not
86/// counted (`try_with`).
87struct Tally {
88 count: Cell<u64>,
89 bytes: Cell<u64>,
90 freed: Cell<u64>,
91 held: Cell<i64>,
92 bound: Cell<i64>,
93 exhausted: Cell<bool>,
94 wire: Cell<*const AtomicBool>,
95}
96
97/// The account a [`charged_to`] scope set aside, put back when it ends.
98#[derive(Clone, Copy)]
99struct Charge {
100 held: i64,
101 bound: i64,
102 exhausted: bool,
103 wire: *const AtomicBool,
104}
105
106impl Tally {
107 const fn new() -> Self {
108 Self {
109 count: Cell::new(0),
110 bytes: Cell::new(0),
111 freed: Cell::new(0),
112 held: Cell::new(0),
113 bound: Cell::new(i64::MAX),
114 exhausted: Cell::new(false),
115 wire: Cell::new(std::ptr::null()),
116 }
117 }
118
119 fn charge(&self, bytes: usize) {
120 self.count.set(self.count.get() + 1);
121 self.bytes.set(self.bytes.get() + bytes as u64);
122 self.hold(i64::try_from(bytes).unwrap_or(i64::MAX));
123 }
124
125 fn credit(&self, bytes: usize) {
126 self.freed.set(self.freed.get() + bytes as u64);
127 self.hold(-i64::try_from(bytes).unwrap_or(i64::MAX));
128 }
129
130 fn hold(&self, bytes: i64) {
131 let held = self.held.get().saturating_add(bytes);
132 self.held.set(held);
133 if held > self.bound.get() && !self.exhausted.get() {
134 self.trip();
135 }
136 }
137
138 /// Mark the account exhausted and store `true` through its wire.
139 ///
140 /// The wire is only ever a pointer [`charged_to`] took from a reference
141 /// that outlives its scope, set on entry and put back on every way out of
142 /// the scope (the guard's `Drop`), and the thread-local is read by its own
143 /// thread only: while the pointer is set, what it points at is alive.
144 fn trip(&self) {
145 self.exhausted.set(true);
146 let wire = self.wire.get();
147 if !wire.is_null() {
148 unsafe { (*wire).store(true, Ordering::Relaxed) };
149 }
150 }
151
152 fn swap(&self, next: Charge) -> Charge {
153 let outer = Charge {
154 held: self.held.replace(next.held),
155 bound: self.bound.replace(next.bound),
156 exhausted: self.exhausted.replace(next.exhausted),
157 wire: self.wire.replace(next.wire),
158 };
159 if next.held > next.bound && !next.exhausted {
160 self.trip();
161 }
162 outer
163 }
164}
165
166thread_local! {
167 static TALLY: Tally = const { Tally::new() };
168}
169
170/// This thread's running totals, or `None` when [`Counting`] is not the global
171/// allocator of this binary.
172#[must_use]
173pub fn allocations() -> Option<Allocations> {
174 if !INSTALLED.load(Ordering::Relaxed) {
175 return None;
176 }
177 TALLY
178 .try_with(|t| Allocations {
179 count: t.count.get(),
180 bytes: t.bytes.get(),
181 freed: t.freed.get(),
182 })
183 .ok()
184}
185
186fn charge(bytes: usize) {
187 let _ = TALLY.try_with(|t| t.charge(bytes));
188}
189
190fn credit(bytes: usize) {
191 let _ = TALLY.try_with(|t| t.credit(bytes));
192}
193
194/// The live bytes one account holds, and the bound it is held to.
195///
196/// An evaluation is charged what its own thread allocates and frees while the
197/// account is [`charged_to`]: `held` is the net, so a value built and dropped
198/// costs nothing once dropped, and a value still reachable when the scope ends
199/// stays on the account. One account per evaluation and one per blue process
200/// (a process is share-nothing, so what it holds is its own).
201#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
202pub struct Account {
203 /// Bytes allocated and not returned while charged. Negative when the
204 /// account returned more than it took (it freed what was there before).
205 pub held: i64,
206 /// The most `held` may reach; `None` is unbounded.
207 pub bound: Option<u64>,
208 /// `held` passed `bound` while charged. Stays set.
209 pub exhausted: bool,
210}
211
212impl Account {
213 /// An account holding nothing, held to `bound`.
214 #[must_use]
215 pub const fn new(bound: Option<u64>) -> Self {
216 Self {
217 held: 0,
218 bound,
219 exhausted: false,
220 }
221 }
222}
223
224struct Charged<'a> {
225 account: &'a mut Account,
226 outer: Charge,
227}
228
229impl Drop for Charged<'_> {
230 fn drop(&mut self) {
231 let _ = TALLY.try_with(|t| {
232 let inner = t.swap(self.outer);
233 self.account.held = inner.held;
234 self.account.exhausted = inner.exhausted;
235 });
236 }
237}
238
239/// Run `f` with every allocation and free this thread makes charged to
240/// `account` instead of the account running now, which is set aside and put
241/// back unchanged when `f` returns or unwinds.
242///
243/// The moment `held` passes `bound` the account is exhausted, and `wire` (an
244/// interpreter's interrupt flag, `Interpreter::set_interrupt`) is set, so the
245/// tree-walker's next step halts. That is the only refusal the allocator
246/// makes: it never fails an allocation, since a failed allocation aborts the
247/// process. With [`Counting`] not installed nothing is charged and nothing is
248/// refused.
249pub fn charged_to<R>(account: &mut Account, wire: Option<&AtomicBool>, f: impl FnOnce() -> R) -> R {
250 let next = Charge {
251 held: account.held,
252 bound: account
253 .bound
254 .map_or(i64::MAX, |b| i64::try_from(b).unwrap_or(i64::MAX)),
255 exhausted: account.exhausted,
256 wire: wire.map_or(std::ptr::null(), |w| w as *const AtomicBool),
257 };
258 let Ok(outer) = TALLY.try_with(|t| t.swap(next)) else {
259 return f();
260 };
261 let _scope = Charged { account, outer };
262 f()
263}
264
265/// Run `f` charged to no evaluation, and answer the bytes it left live.
266#[must_use]
267pub fn apart<R>(f: impl FnOnce() -> R) -> (R, i64) {
268 let mut account = Account::new(None);
269 let value = charged_to(&mut account, None, f);
270 (value, account.held)
271}
272
273/// Charge `bytes` to the account running now, as if it had allocated them.
274pub fn charge_held(bytes: i64) {
275 let _ = TALLY.try_with(|t| t.hold(bytes));
276}
277
278/// The bound of the account running now: `None` when it has none, or when
279/// [`Counting`] is not installed and no bound could be enforced.
280#[must_use]
281pub fn bound() -> Option<u64> {
282 if !INSTALLED.load(Ordering::Relaxed) {
283 return None;
284 }
285 TALLY
286 .try_with(|t| t.bound.get())
287 .ok()
288 .filter(|b| *b != i64::MAX)
289 .and_then(|b| u64::try_from(b).ok())
290}
291
292/// Whether the account running now has passed its bound.
293#[must_use]
294pub fn exhausted() -> bool {
295 TALLY.try_with(|t| t.exhausted.get()).unwrap_or(false)
296}
297
298/// The system allocator, counting what each thread allocates. See the module
299/// documentation for how a binary installs it.
300pub struct Counting;
301
302// SAFETY: every method forwards to `System` with the caller's arguments
303// unchanged; the counting touches only `Cell`s and an atomic flag, neither of
304// which allocates.
305unsafe impl GlobalAlloc for Counting {
306 unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
307 INSTALLED.store(true, Ordering::Relaxed);
308 charge(layout.size());
309 unsafe { System.alloc(layout) }
310 }
311
312 unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
313 INSTALLED.store(true, Ordering::Relaxed);
314 charge(layout.size());
315 unsafe { System.alloc_zeroed(layout) }
316 }
317
318 unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
319 credit(layout.size());
320 unsafe { System.dealloc(ptr, layout) }
321 }
322
323 unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
324 if new_size > layout.size() {
325 charge(new_size - layout.size());
326 } else {
327 credit(layout.size() - new_size);
328 }
329 unsafe { System.realloc(ptr, layout, new_size) }
330 }
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336
337 /// This crate's own test binary does not install [`Counting`], so the
338 /// answer must be "unmeasured", not a zero that reads as "allocated
339 /// nothing". `tests/alloc_count.rs` installs it and measures.
340 #[test]
341 fn an_uncounted_binary_reads_as_unmeasured() {
342 let _ = vec![1u8; 64];
343 assert_eq!(allocations(), None);
344 }
345
346 #[test]
347 fn since_is_the_difference_and_never_underflows() {
348 let a = Allocations {
349 count: 3,
350 bytes: 100,
351 freed: 40,
352 };
353 let b = Allocations {
354 count: 5,
355 bytes: 160,
356 freed: 50,
357 };
358 assert_eq!(
359 b.since(a),
360 Allocations {
361 count: 2,
362 bytes: 60,
363 freed: 10
364 }
365 );
366 assert_eq!(a.since(b), Allocations::default());
367 }
368}