Skip to main content

taktora_bounded_alloc/
lib.rs

1//! Static pre-allocated bounded `#[global_allocator]` with hard caps
2//! on per-allocation size and total live block count.
3//!
4//! See `FEAT_0040` and `REQ_0300..REQ_0304` for the design contract.
5//!
6//! # Quick start
7//!
8//! ```ignore
9//! use taktora_bounded_alloc::declare_global_allocator;
10//!
11//! // Declares a `#[global_allocator]` static named `ALLOC` with a
12//! // 512 × 1024-byte arena. Maximum per-allocation size is 1024
13//! // bytes; maximum live blocks is 512.
14//! declare_global_allocator!(ALLOC, 512, 1024);
15//!
16//! fn main() {
17//!     let s = String::from("hello");
18//!     assert_eq!(ALLOC.alloc_count(), 1);
19//!     drop(s);
20//!     assert_eq!(ALLOC.dealloc_count(), 1);
21//! }
22//! ```
23//!
24//! # Lock-after-init
25//!
26//! Call `ALLOC.lock()` once the program reaches its steady-state
27//! point (e.g. after `Executor::build`). Any subsequent allocation
28//! call panics — which under `panic = "abort"` aborts the process,
29//! catching stray heap activity that escaped review.
30//!
31//! ```ignore
32//! ALLOC.lock();
33//! let _ = String::from("this aborts the process");
34//! ```
35//!
36//! The consuming binary's `Cargo.toml` must set
37//! `[profile.release] panic = "abort"` (and `[profile.dev]` if dev
38//! builds also need the guarantee) — otherwise the panic itself
39//! will allocate the unwinder's payload string.
40
41#![no_std]
42#![deny(unsafe_code)]
43#![warn(missing_docs)]
44
45#[cfg(feature = "std")]
46extern crate std;
47
48use core::alloc::{GlobalAlloc, Layout};
49use core::cell::UnsafeCell;
50use core::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
51
52// ── Block ──────────────────────────────────────────────────────────────────
53
54/// One arena block. `align(64)` is the maximum `layout.align()` the
55/// allocator can serve; allocations whose `Layout::align()` exceeds
56/// 64 are rejected with `null`.
57///
58/// `BLOCK_SIZE` should be a multiple of 64 to avoid intra-block
59/// padding that would inflate the arena's footprint beyond
60/// `MAX_BLOCKS * BLOCK_SIZE`. Smaller values still work — they
61/// just waste a few bytes per block.
62#[repr(C, align(64))]
63#[doc(hidden)]
64pub struct Block<const N: usize>(UnsafeCell<[u8; N]>);
65
66impl<const N: usize> Block<N> {
67    #[doc(hidden)]
68    #[must_use]
69    pub const fn new() -> Self {
70        Self(UnsafeCell::new([0; N]))
71    }
72
73    #[doc(hidden)]
74    #[must_use]
75    pub const fn as_mut_ptr(&self) -> *mut u8 {
76        self.0.get().cast::<u8>()
77    }
78}
79
80impl<const N: usize> Default for Block<N> {
81    fn default() -> Self {
82        Self::new()
83    }
84}
85
86// SAFETY: blocks are accessed via the bitmap's compare-exchange
87// protocol — only the thread that flipped a bit `1 -> 0` holds a
88// logical `&mut` to the corresponding block, and only until it flips
89// the bit back to `1` in `dealloc`. No aliasing across threads, so
90// `Sync` is sound.
91#[allow(unsafe_code)]
92unsafe impl<const N: usize> Sync for Block<N> {}
93
94// ── BoundedAllocator ───────────────────────────────────────────────────────
95
96/// Static pre-allocated bounded global allocator.
97///
98/// Generic parameters
99///
100/// * `MAX_BLOCKS` — maximum number of simultaneously-live blocks.
101/// * `BLOCK_SIZE` — maximum bytes per allocation. Should be a
102///   multiple of 64 to avoid intra-block padding.
103/// * `BITMAP_WORDS` — must be `(MAX_BLOCKS + 63) / 64`. Use the
104///   [`declare_global_allocator!`] / [`bounded_allocator!`]
105///   macros to compute it automatically.
106pub struct BoundedAllocator<
107    const MAX_BLOCKS: usize,
108    const BLOCK_SIZE: usize,
109    const BITMAP_WORDS: usize,
110> {
111    /// The arena. Each block is `align(64)` and `BLOCK_SIZE` bytes
112    /// (rounded up to 64 if smaller). Total footprint ≈
113    /// `MAX_BLOCKS * BLOCK_SIZE` for `BLOCK_SIZE` ≥ 64.
114    arena: [Block<BLOCK_SIZE>; MAX_BLOCKS],
115    /// Per-block free flag. Bit `i` of word `w` represents block
116    /// `w * 64 + i`. `1` = free, `0` = in use. Words past
117    /// `MAX_BLOCKS / 64` may have bogus "free" bits in their tail —
118    /// `try_claim_block` filters those out by checking the derived
119    /// block index against `MAX_BLOCKS`.
120    bitmap: [AtomicU64; BITMAP_WORDS],
121    /// Running count of successful allocations since process start.
122    alloc_count: AtomicUsize,
123    /// Running count of `dealloc` calls since process start.
124    dealloc_count: AtomicUsize,
125    /// High-water mark of simultaneously-live blocks.
126    peak_in_use: AtomicUsize,
127    /// Live block count (`alloc_count` - `dealloc_count`, atomically).
128    in_use: AtomicUsize,
129    /// Lock flag. When `true`, every `alloc` panics — required by
130    /// `REQ_0302`. One-way; no `unlock` method exists.
131    locked: AtomicBool,
132}
133
134// SAFETY: a global allocator must be `Sync`. Every field above is
135// independently `Sync` (atomics + `Block`'s manual `Sync` impl),
136// which is sufficient.
137
138impl<const MAX_BLOCKS: usize, const BLOCK_SIZE: usize, const BITMAP_WORDS: usize>
139    BoundedAllocator<MAX_BLOCKS, BLOCK_SIZE, BITMAP_WORDS>
140{
141    /// Construct a fresh allocator. Intended for use as a `static`
142    /// initialiser.
143    ///
144    /// All bitmap words are initialised to all-ones, meaning every
145    /// block is free. The few tail bits past `MAX_BLOCKS` in the
146    /// last word are also set, but the bit-scan refuses to allocate
147    /// them (block index >= `MAX_BLOCKS` rejection).
148    #[must_use]
149    pub const fn new() -> Self {
150        Self {
151            arena: [const { Block::new() }; MAX_BLOCKS],
152            bitmap: [const { AtomicU64::new(u64::MAX) }; BITMAP_WORDS],
153            alloc_count: AtomicUsize::new(0),
154            dealloc_count: AtomicUsize::new(0),
155            peak_in_use: AtomicUsize::new(0),
156            in_use: AtomicUsize::new(0),
157            locked: AtomicBool::new(false),
158        }
159    }
160
161    /// Engage lock-after-init mode. Every subsequent `alloc` call
162    /// panics immediately. One-way: there is no `unlock` method.
163    pub fn lock(&self) {
164        self.locked.store(true, Ordering::Release);
165    }
166
167    /// Is the allocator locked? See [`Self::lock`].
168    #[must_use]
169    pub fn is_locked(&self) -> bool {
170        self.locked.load(Ordering::Acquire)
171    }
172
173    /// Total successful `alloc` calls since process start.
174    #[must_use]
175    pub fn alloc_count(&self) -> usize {
176        self.alloc_count.load(Ordering::Relaxed)
177    }
178
179    /// Total `dealloc` calls since process start.
180    #[must_use]
181    pub fn dealloc_count(&self) -> usize {
182        self.dealloc_count.load(Ordering::Relaxed)
183    }
184
185    /// High-water mark of simultaneously-live blocks.
186    #[must_use]
187    pub fn peak_blocks_used(&self) -> usize {
188        self.peak_in_use.load(Ordering::Relaxed)
189    }
190
191    /// Currently-live block count.
192    #[must_use]
193    pub fn live_blocks(&self) -> usize {
194        self.in_use.load(Ordering::Relaxed)
195    }
196
197    /// Total arena bytes addressed by this allocator.
198    #[must_use]
199    pub const fn capacity_bytes(&self) -> usize {
200        MAX_BLOCKS * BLOCK_SIZE
201    }
202
203    /// Try to claim a free block. Returns its index (`0..MAX_BLOCKS`)
204    /// or `None` if the arena is fully allocated.
205    fn try_claim_block(&self) -> Option<usize> {
206        for word_idx in 0..BITMAP_WORDS {
207            loop {
208                let word = self.bitmap[word_idx].load(Ordering::Acquire);
209                if word == 0 {
210                    // No free bits in this word.
211                    break;
212                }
213                let bit_idx = word.trailing_zeros() as usize;
214                let block_idx = word_idx * 64 + bit_idx;
215                if block_idx >= MAX_BLOCKS {
216                    // Bogus tail bit — fall through to the next word.
217                    break;
218                }
219                let mask = 1_u64 << bit_idx;
220                let new_word = word & !mask;
221                // Lost-race retry stays inside the outer `loop`.
222                if self.bitmap[word_idx]
223                    .compare_exchange(word, new_word, Ordering::AcqRel, Ordering::Acquire)
224                    .is_ok()
225                {
226                    return Some(block_idx);
227                }
228            }
229        }
230        None
231    }
232
233    /// Release block `block_idx` back to the free pool.
234    fn release_block(&self, block_idx: usize) {
235        debug_assert!(block_idx < MAX_BLOCKS);
236        let word_idx = block_idx / 64;
237        let bit_idx = block_idx % 64;
238        let mask = 1_u64 << bit_idx;
239        self.bitmap[word_idx].fetch_or(mask, Ordering::AcqRel);
240    }
241
242    /// Compute the block index for a pointer returned by a previous
243    /// `alloc`. The pointer must lie inside `self.arena`.
244    fn block_index_of(&self, ptr: *mut u8) -> usize {
245        let stride = core::mem::size_of::<Block<BLOCK_SIZE>>();
246        let arena_base = self.arena.as_ptr().cast::<u8>() as usize;
247        let offset = (ptr as usize).wrapping_sub(arena_base);
248        offset / stride
249    }
250}
251
252impl<const MAX_BLOCKS: usize, const BLOCK_SIZE: usize, const BITMAP_WORDS: usize> Default
253    for BoundedAllocator<MAX_BLOCKS, BLOCK_SIZE, BITMAP_WORDS>
254{
255    fn default() -> Self {
256        Self::new()
257    }
258}
259
260// SAFETY: `unsafe impl GlobalAlloc` is the trait's requirement; the
261// implementation upholds the trait contract (returns a pointer to a
262// region of at least `layout.size()` bytes, aligned to
263// `layout.align()`, or null on failure). Aliasing is prevented by
264// the bitmap CAS — a thread observing a `1 -> 0` transition on a
265// bit is the unique owner of the corresponding block until it CASs
266// the bit back to `1` in `dealloc`.
267#[allow(unsafe_code)]
268unsafe impl<const MAX_BLOCKS: usize, const BLOCK_SIZE: usize, const BITMAP_WORDS: usize> GlobalAlloc
269    for BoundedAllocator<MAX_BLOCKS, BLOCK_SIZE, BITMAP_WORDS>
270{
271    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
272        assert!(
273            !self.is_locked(),
274            "taktora-bounded-alloc: allocation attempted after lock() (REQ_0302); \
275             sized {} bytes, alignment {}",
276            layout.size(),
277            layout.align()
278        );
279        if layout.size() > BLOCK_SIZE {
280            return core::ptr::null_mut();
281        }
282        // Block alignment is 64 (from `repr(C, align(64))` on `Block`).
283        if layout.align() > 64 {
284            return core::ptr::null_mut();
285        }
286        let Some(block_idx) = self.try_claim_block() else {
287            return core::ptr::null_mut();
288        };
289        let ptr = self.arena[block_idx].as_mut_ptr();
290        self.alloc_count.fetch_add(1, Ordering::Relaxed);
291        let in_use_after = self.in_use.fetch_add(1, Ordering::Relaxed) + 1;
292        // Update high-water mark monotonically.
293        let mut peak = self.peak_in_use.load(Ordering::Relaxed);
294        while in_use_after > peak {
295            match self.peak_in_use.compare_exchange_weak(
296                peak,
297                in_use_after,
298                Ordering::Relaxed,
299                Ordering::Relaxed,
300            ) {
301                Ok(_) => break,
302                Err(observed) => peak = observed,
303            }
304        }
305        ptr
306    }
307
308    unsafe fn dealloc(&self, ptr: *mut u8, _layout: Layout) {
309        let block_idx = self.block_index_of(ptr);
310        self.release_block(block_idx);
311        self.dealloc_count.fetch_add(1, Ordering::Relaxed);
312        self.in_use.fetch_sub(1, Ordering::Relaxed);
313    }
314}
315
316// ── Convenience macros ────────────────────────────────────────────────────
317
318/// Declare a `static BoundedAllocator` registered as
319/// `#[global_allocator]`, computing the bitmap word count from
320/// `MAX_BLOCKS` automatically.
321///
322/// # Example
323///
324/// ```ignore
325/// taktora_bounded_alloc::declare_global_allocator!(ALLOC, 512, 1024);
326/// ```
327///
328/// Expands to roughly:
329///
330/// ```ignore
331/// #[global_allocator]
332/// static ALLOC: taktora_bounded_alloc::BoundedAllocator<512, 1024, 8> =
333///     taktora_bounded_alloc::BoundedAllocator::new();
334/// ```
335#[macro_export]
336macro_rules! declare_global_allocator {
337    ($name:ident, $max_blocks:expr, $block_size:expr $(,)?) => {
338        #[global_allocator]
339        static $name: $crate::BoundedAllocator<
340            { $max_blocks },
341            { $block_size },
342            { ($max_blocks as usize).div_ceil(64) },
343        > = $crate::BoundedAllocator::new();
344    };
345}
346
347/// Construct a const-evaluable `BoundedAllocator` expression with
348/// the bitmap word count computed automatically. Useful in
349/// regular (non-global-allocator) `static` declarations and tests.
350///
351/// ```ignore
352/// static TEST_ALLOC: BoundedAllocator<4, 32, 1> =
353///     taktora_bounded_alloc::bounded_allocator!(4, 32);
354/// ```
355#[macro_export]
356macro_rules! bounded_allocator {
357    ($max_blocks:expr, $block_size:expr $(,)?) => {
358        $crate::BoundedAllocator::<
359            { $max_blocks },
360            { $block_size },
361            { ($max_blocks as usize).div_ceil(64) },
362        >::new()
363    };
364}
365
366// ── CountingAllocator (std-only) ───────────────────────────────────────────
367
368#[cfg(feature = "std")]
369pub use self::counting::CountingAllocator;
370
371#[cfg(feature = "std")]
372mod counting {
373    //! Unbounded counter wrapper around `std::alloc::System`.
374    //!
375    //! Distinct from `BoundedAllocator` — `CountingAllocator` does
376    //! **not** enforce caps; it delegates every allocation to the
377    //! system allocator and only counts. Intended for test
378    //! harnesses that need cross-thread alloc/dealloc accounting
379    //! without restricting heap size.
380
381    use core::alloc::{GlobalAlloc, Layout};
382    use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
383    use std::alloc::System;
384
385    /// `#[global_allocator]`-compatible wrapper around the system
386    /// allocator that counts every successful allocation and
387    /// deallocation while a thread-shared `TRACKING` flag is set.
388    ///
389    /// # Example
390    ///
391    /// ```ignore
392    /// use taktora_bounded_alloc::CountingAllocator;
393    ///
394    /// #[global_allocator]
395    /// static A: CountingAllocator = CountingAllocator::new();
396    ///
397    /// fn main() {
398    ///     A.reset();
399    ///     A.set_tracking(true);
400    ///     // ... measure a region of interest ...
401    ///     A.set_tracking(false);
402    ///     println!("allocs in region: {}", A.alloc_count());
403    /// }
404    /// ```
405    pub struct CountingAllocator {
406        alloc_count: AtomicUsize,
407        dealloc_count: AtomicUsize,
408        tracking: AtomicBool,
409    }
410
411    impl CountingAllocator {
412        /// Construct a new counter. Suitable for use in a `static`.
413        #[must_use]
414        pub const fn new() -> Self {
415            Self {
416                alloc_count: AtomicUsize::new(0),
417                dealloc_count: AtomicUsize::new(0),
418                tracking: AtomicBool::new(false),
419            }
420        }
421
422        /// Enable or disable counting. When `false`, allocations
423        /// pass through without incrementing the counters.
424        pub fn set_tracking(&self, on: bool) {
425            self.tracking.store(on, Ordering::Release);
426        }
427
428        /// Is counting currently enabled?
429        #[must_use]
430        pub fn is_tracking(&self) -> bool {
431            self.tracking.load(Ordering::Acquire)
432        }
433
434        /// Reset both counters to zero.
435        pub fn reset(&self) {
436            self.alloc_count.store(0, Ordering::Relaxed);
437            self.dealloc_count.store(0, Ordering::Relaxed);
438        }
439
440        /// Total successful allocations counted since the most
441        /// recent `reset`.
442        #[must_use]
443        pub fn alloc_count(&self) -> usize {
444            self.alloc_count.load(Ordering::Relaxed)
445        }
446
447        /// Total deallocations counted since the most recent `reset`.
448        #[must_use]
449        pub fn dealloc_count(&self) -> usize {
450            self.dealloc_count.load(Ordering::Relaxed)
451        }
452    }
453
454    impl Default for CountingAllocator {
455        fn default() -> Self {
456            Self::new()
457        }
458    }
459
460    // SAFETY: delegates to the system allocator; the wrapper only
461    // adds atomic counter increments, which are themselves
462    // thread-safe.
463    #[allow(unsafe_code)]
464    unsafe impl GlobalAlloc for CountingAllocator {
465        unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
466            if self.tracking.load(Ordering::Relaxed) {
467                self.alloc_count.fetch_add(1, Ordering::Relaxed);
468            }
469            // SAFETY: forwarding the caller's contract unchanged.
470            unsafe { System.alloc(layout) }
471        }
472        unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
473            if self.tracking.load(Ordering::Relaxed) {
474                self.alloc_count.fetch_add(1, Ordering::Relaxed);
475            }
476            // SAFETY: forwarding the caller's contract unchanged.
477            unsafe { System.alloc_zeroed(layout) }
478        }
479        unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
480            if self.tracking.load(Ordering::Relaxed) {
481                self.alloc_count.fetch_add(1, Ordering::Relaxed);
482            }
483            // SAFETY: ptr/layout pair previously returned by alloc;
484            // forwarding unchanged.
485            unsafe { System.realloc(ptr, layout, new_size) }
486        }
487        unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
488            if self.tracking.load(Ordering::Relaxed) {
489                self.dealloc_count.fetch_add(1, Ordering::Relaxed);
490            }
491            // SAFETY: ptr/layout pair previously returned by alloc.
492            unsafe { System.dealloc(ptr, layout) }
493        }
494    }
495}