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}