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;