Skip to main content

rialo_s_program_entrypoint/allocator/
mod.rs

1// Copyright (c) Subzero Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Custom heap allocator for Rialo programs that supports dynamic heap sizes.
5//!
6//! # Overview
7//!
8//! This module provides a bump allocator optimized for Rialo's execution model.
9//! Unlike the default allocator which assumes a fixed 32KB heap, this implementation
10//! automatically utilizes whatever heap size is allocated by the runtime, including
11//! custom sizes requested via Compute Budget instructions.
12//!
13//! # How It Works
14//!
15//! The allocator stores a small header (4-8 bytes) at the start of the heap containing:
16//! - Current allocation offset
17//! - Optional global state (via generic parameter `G`)
18//!
19//! Memory is allocated by growing upward from `HEAP_START_ADDRESS`. When an allocation
20//! would exceed available heap space, accessing that memory triggers a segfault, which
21//! is how Rialo signals out-of-memory conditions. This design eliminates the need for
22//! the allocator to know the heap size at compile time.
23//!
24//! # Key Assumptions
25//!
26//! This allocator relies on guarantees provided by the Rialo runtime:
27//!
28//! 1. **Heap location**: The heap always starts at address `0x300000000`
29//! 2. **Zero-initialization**: The Rialo runtime zero-initializes the heap region
30//!    before program execution begins
31//! 3. **Segfault on overflow**: Accessing memory beyond the allocated heap causes
32//!    a segfault that terminates the transaction
33//!
34//! These assumptions are part of Rialo's documented runtime behavior and are validated
35//! in tests using a simulated heap environment.
36//!
37//! # Deallocation Behavior
38//!
39//! As a bump allocator, this implementation:
40//! - Can reclaim space from the most recent allocation if deallocated
41//! - Intentionally leaks memory for all other deallocations (by design)
42//! - Is optimized for Rialo's short-lived transaction model where all memory
43//!   is reclaimed when the transaction completes
44//!
45//! # Usage
46//!
47//! The allocator is typically set up via the `custom_heap_default!` macro in the
48//! entrypoint crate. Programs don't interact with it directly - it's used automatically
49//! by Rust's allocation APIs (`Vec`, `Box`, etc.).
50
51use core::{
52    alloc::{GlobalAlloc, Layout},
53    cell::Cell,
54};
55
56/// Minimum guaranteed heap size.
57///
58/// NOTE: Actual heap size may be larger if requested via Compute Budget.
59/// The allocator automatically uses all available heap space.
60pub const MIN_HEAP_LENGTH: usize = 32 * 1024;
61
62/// Bump allocator that grows upward from HEAP_START.
63///
64/// Generic parameter `G` allows for optional global state storage at the heap start.
65/// Use `G = ()` (the default) for no global state.
66///
67/// # Safety
68///
69/// Only one instance should exist per program, and it must be set as the global allocator.
70/// Creating multiple instances or using alongside another allocator is undefined behavior.
71pub struct BumpAllocator<G = ()> {
72    #[cfg(test)]
73    ptr: core::ptr::NonNull<u8>,
74    #[cfg(test)]
75    layout: Layout,
76
77    _phantom: core::marker::PhantomData<G>,
78}
79
80/// Header stored at the start of the heap containing allocator metadata.
81///
82/// The header is zero-initialized by the Rialo runtime (or explicitly by tests).
83/// Using `Cell<u32>` provides interior mutability for updating the allocation offset.
84#[repr(C)]
85struct Header<G> {
86    /// Offset from end of header to first free byte (not from heap start)
87    used: Cell<u32>,
88    /// Optional global state (zero-sized if G = ())
89    global: G,
90}
91
92impl<G> Header<G> {
93    /// Size of the header including alignment padding
94    const SIZE: u32 = {
95        let size = core::mem::size_of::<Header<G>>();
96        // Size validation happens in header() where we check against MIN_HEAP_LENGTH
97        size as u32
98    };
99
100    /// Get the offset from heap start to the first free byte
101    #[inline(always)]
102    fn get_end_offset(&self) -> u32 {
103        self.used.get().wrapping_add(Self::SIZE)
104    }
105
106    /// Set the offset from heap start to the first free byte
107    #[inline(always)]
108    fn set_end_offset(&self, offset: u32) {
109        self.used.set(offset.wrapping_sub(Self::SIZE));
110    }
111}
112
113// Non-test (Rialo target) implementation
114#[cfg(not(test))]
115impl<G> BumpAllocator<G> {
116    /// Start address of the memory region used for program heap.
117    #[cfg(not(target_arch = "riscv64"))]
118    const HEAP_START_ADDRESS: u64 = 0x300000000;
119    #[cfg(target_arch = "riscv64")]
120    pub const HEAP_START_ADDRESS: u64 = 0x100000;
121
122    /// Creates a new allocator.
123    ///
124    /// # Safety
125    ///
126    /// - Only one BumpAllocator instance should exist per program
127    /// - It must be set as the global allocator
128    /// - Multiple instances or using alongside another allocator leads to undefined behavior
129    /// - The Rialo runtime must have zero-initialized the heap region (guaranteed by spec)
130    pub const unsafe fn new() -> Self {
131        // SAFETY: Caller must ensure this is only called once and set as global allocator.
132        // The Rialo runtime guarantees the heap region starting at HEAP_START_ADDRESS
133        // is zero-initialized before program execution.
134        Self {
135            _phantom: core::marker::PhantomData,
136        }
137    }
138
139    #[inline(always)]
140    const fn heap_start(&self) -> *mut u8 {
141        Self::HEAP_START_ADDRESS as *mut u8
142    }
143
144    #[inline(always)]
145    fn to_offset(&self, ptr: *mut u8) -> u32 {
146        let addr = ptr as u64;
147        debug_assert!(
148            addr >= Self::HEAP_START_ADDRESS && addr < Self::HEAP_START_ADDRESS + u32::MAX as u64,
149            "Pointer outside valid heap range"
150        );
151        (addr - Self::HEAP_START_ADDRESS) as u32
152    }
153
154    #[allow(clippy::wrong_self_convention)]
155    #[inline(always)]
156    fn from_offset(&self, offset: u32) -> *mut u8 {
157        (Self::HEAP_START_ADDRESS + offset as u64) as *mut u8
158    }
159}
160
161// Test implementation with actual allocation
162#[cfg(test)]
163impl<G: bytemuck::Zeroable> BumpAllocator<G> {
164    /// Creates a test allocator with specified heap size
165    fn new_test(size: usize) -> Self {
166        let size = size.min(u32::MAX as usize);
167        assert!(
168            size >= core::mem::size_of::<Header<G>>(),
169            "Heap too small for header"
170        );
171
172        let align = core::mem::align_of::<Header<G>>().max(16);
173        let layout = Layout::from_size_align(size, align).unwrap();
174
175        // SAFETY: We're allocating with proper layout
176        let ptr = unsafe { std::alloc::alloc_zeroed(layout) };
177        let ptr = core::ptr::NonNull::new(ptr).expect("Failed to allocate test heap");
178
179        Self {
180            ptr,
181            layout,
182            _phantom: core::marker::PhantomData,
183        }
184    }
185
186    #[inline(always)]
187    fn heap_start(&self) -> *mut u8 {
188        self.ptr.as_ptr()
189    }
190
191    #[inline(always)]
192    fn to_offset(&self, ptr: *mut u8) -> u32 {
193        (ptr as usize - self.heap_start() as usize) as u32
194    }
195
196    #[allow(clippy::wrong_self_convention)]
197    #[inline(always)]
198    fn from_offset(&self, offset: u32) -> *mut u8 {
199        self.heap_start().wrapping_add(offset as usize)
200    }
201}
202
203#[cfg(test)]
204impl<G> Drop for BumpAllocator<G> {
205    fn drop(&mut self) {
206        // SAFETY: ptr and layout match the allocation
207        unsafe {
208            std::alloc::dealloc(self.ptr.as_ptr(), self.layout);
209        }
210    }
211}
212
213impl<G: bytemuck::Zeroable> BumpAllocator<G> {
214    /// Returns reference to the header at the start of the heap
215    #[inline(always)]
216    fn header(&self) -> &Header<G> {
217        // Compile-time check: header must fit in minimum guaranteed heap
218        const {
219            assert!(
220                core::mem::size_of::<Header<G>>() <= MIN_HEAP_LENGTH,
221                "Header too large for minimum heap size"
222            );
223        }
224
225        // SAFETY:
226        // 1. On Rialo: HEAP_START_ADDRESS (0x300000000) is properly aligned by runtime
227        // 2. In tests: Test allocator ensures proper alignment via Layout
228        // 3. Header fits in heap (compile-time check above)
229        // 4. Heap memory is zero-initialized (by Rialo runtime or test allocator)
230        // 5. Header<G> is Zeroable, so zero-initialization is valid
231        unsafe { &*self.heap_start().cast::<Header<G>>() }
232    }
233
234    /// Fast path allocation - assumes success is common case
235    #[inline(always)]
236    fn try_alloc_fast(&self, layout: Layout) -> Option<*mut u8> {
237        let header = self.header();
238        let current_offset = header.get_end_offset();
239
240        let size = match u32::try_from(layout.size()) {
241            Ok(s) => s,
242            Err(_) => return None,
243        };
244
245        debug_assert!(layout.align().is_power_of_two());
246        let align_mask = (layout.align() - 1) as u32;
247
248        let aligned_offset = match current_offset.checked_add(align_mask) {
249            Some(v) => v & !align_mask,
250            None => return None,
251        };
252
253        #[allow(clippy::question_mark)]
254        let end_offset = match aligned_offset.checked_add(size) {
255            Some(end) => end,
256            None => return None,
257        };
258
259        #[cfg(test)]
260        if end_offset as usize > self.layout.size() {
261            return None;
262        }
263
264        header.set_end_offset(end_offset);
265        Some(self.from_offset(aligned_offset))
266    }
267
268    #[allow(clippy::question_mark)]
269    /// Try to allocate at a specific pointer (used for in-place realloc)
270    #[inline]
271    fn try_alloc_at(&self, ptr: *mut u8, layout: Layout) -> Option<*mut u8> {
272        let offset = self.to_offset(ptr);
273
274        let size = match u32::try_from(layout.size()) {
275            Ok(s) => s,
276            Err(_) => return None,
277        };
278
279        let end_offset = match offset.checked_add(size) {
280            Some(end) => end,
281            None => return None,
282        };
283
284        #[cfg(test)]
285        if end_offset as usize > self.layout.size() {
286            return None;
287        }
288
289        self.header().set_end_offset(end_offset);
290        Some(ptr)
291    }
292
293    /// Returns reference to global state reserved at heap start
294    #[inline]
295    pub fn global(&self) -> &G {
296        &self.header().global
297    }
298
299    /// Returns amount of heap used (excluding header)
300    #[cfg(test)]
301    pub fn used(&self) -> usize {
302        self.header().used.get() as usize
303    }
304}
305
306// SAFETY: BumpAllocator correctly implements GlobalAlloc
307unsafe impl<G: bytemuck::Zeroable> GlobalAlloc for BumpAllocator<G> {
308    #[inline]
309    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
310        // Fast path: assume allocation succeeds
311        self.try_alloc_fast(layout).unwrap_or(core::ptr::null_mut())
312    }
313
314    #[inline]
315    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
316        // Only deallocate if this is the most recent allocation
317        let header = self.header();
318        let ptr_end = ptr.wrapping_add(layout.size());
319        let end_offset = self.to_offset(ptr_end);
320
321        if end_offset == header.get_end_offset() {
322            // This was the last allocation, reclaim it
323            header.set_end_offset(self.to_offset(ptr));
324        }
325        // Otherwise, bump allocator intentionally leaks (by design)
326    }
327
328    #[inline]
329    unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
330        let header = self.header();
331        let ptr_end = ptr.wrapping_add(layout.size());
332        let end_offset = self.to_offset(ptr_end);
333
334        // Check if this is the last allocation
335        if end_offset == header.get_end_offset() {
336            // Last allocation - try to resize in place
337            // SAFETY: Caller guarantees new layout is valid for the same alignment
338            let new_layout = Layout::from_size_align_unchecked(new_size, layout.align());
339            return self
340                .try_alloc_at(ptr, new_layout)
341                .unwrap_or(core::ptr::null_mut());
342        }
343
344        // Not the last allocation
345        if new_size <= layout.size() {
346            // Shrinking - return same pointer (leak extra space, this is bump allocator)
347            return ptr;
348        }
349
350        // Growing non-last allocation - need new allocation and copy
351        // SAFETY: Caller guarantees new layout is valid for the same alignment
352        let new_layout = Layout::from_size_align_unchecked(new_size, layout.align());
353        match self.try_alloc_fast(new_layout) {
354            Some(new_ptr) => {
355                // SAFETY:
356                // - src is valid for reads of layout.size() bytes (caller guarantee)
357                // - dst is valid for writes of new_size bytes (just allocated)
358                // - Regions don't overlap (new allocation is after old in bump allocator)
359                core::ptr::copy_nonoverlapping(ptr, new_ptr, layout.size());
360                new_ptr
361            }
362            None => core::ptr::null_mut(),
363        }
364    }
365}
366
367#[cfg(test)]
368mod unit_tests;