Skip to main content

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}