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 const HEAP_START_ADDRESS: u64 = 0x300000000;
118
119 /// Creates a new allocator.
120 ///
121 /// # Safety
122 ///
123 /// - Only one BumpAllocator instance should exist per program
124 /// - It must be set as the global allocator
125 /// - Multiple instances or using alongside another allocator leads to undefined behavior
126 /// - The Rialo runtime must have zero-initialized the heap region (guaranteed by spec)
127 pub const unsafe fn new() -> Self {
128 // SAFETY: Caller must ensure this is only called once and set as global allocator.
129 // The Rialo runtime guarantees the heap region starting at HEAP_START_ADDRESS
130 // is zero-initialized before program execution.
131 Self {
132 _phantom: core::marker::PhantomData,
133 }
134 }
135
136 #[inline(always)]
137 const fn heap_start(&self) -> *mut u8 {
138 Self::HEAP_START_ADDRESS as *mut u8
139 }
140
141 #[inline(always)]
142 fn to_offset(&self, ptr: *mut u8) -> u32 {
143 let addr = ptr as u64;
144 debug_assert!(
145 addr >= Self::HEAP_START_ADDRESS && addr < Self::HEAP_START_ADDRESS + u32::MAX as u64,
146 "Pointer outside valid heap range"
147 );
148 (addr - Self::HEAP_START_ADDRESS) as u32
149 }
150
151 #[inline(always)]
152 fn from_offset(&self, offset: u32) -> *mut u8 {
153 (Self::HEAP_START_ADDRESS + offset as u64) as *mut u8
154 }
155}
156
157// Test implementation with actual allocation
158#[cfg(test)]
159impl<G: bytemuck::Zeroable> BumpAllocator<G> {
160 /// Creates a test allocator with specified heap size
161 fn new_test(size: usize) -> Self {
162 let size = size.min(u32::MAX as usize);
163 assert!(
164 size >= core::mem::size_of::<Header<G>>(),
165 "Heap too small for header"
166 );
167
168 let align = core::mem::align_of::<Header<G>>().max(16);
169 let layout = Layout::from_size_align(size, align).unwrap();
170
171 // SAFETY: We're allocating with proper layout
172 let ptr = unsafe { std::alloc::alloc_zeroed(layout) };
173 let ptr = core::ptr::NonNull::new(ptr).expect("Failed to allocate test heap");
174
175 Self {
176 ptr,
177 layout,
178 _phantom: core::marker::PhantomData,
179 }
180 }
181
182 #[inline(always)]
183 fn heap_start(&self) -> *mut u8 {
184 self.ptr.as_ptr()
185 }
186
187 #[inline(always)]
188 fn to_offset(&self, ptr: *mut u8) -> u32 {
189 (ptr as usize - self.heap_start() as usize) as u32
190 }
191
192 #[inline(always)]
193 fn from_offset(&self, offset: u32) -> *mut u8 {
194 self.heap_start().wrapping_add(offset as usize)
195 }
196}
197
198#[cfg(test)]
199impl<G> Drop for BumpAllocator<G> {
200 fn drop(&mut self) {
201 // SAFETY: ptr and layout match the allocation
202 unsafe {
203 std::alloc::dealloc(self.ptr.as_ptr(), self.layout);
204 }
205 }
206}
207
208impl<G: bytemuck::Zeroable> BumpAllocator<G> {
209 /// Returns reference to the header at the start of the heap
210 #[inline(always)]
211 fn header(&self) -> &Header<G> {
212 // Compile-time check: header must fit in minimum guaranteed heap
213 const {
214 assert!(
215 core::mem::size_of::<Header<G>>() <= MIN_HEAP_LENGTH,
216 "Header too large for minimum heap size"
217 );
218 }
219
220 // SAFETY:
221 // 1. On Rialo: HEAP_START_ADDRESS (0x300000000) is properly aligned by runtime
222 // 2. In tests: Test allocator ensures proper alignment via Layout
223 // 3. Header fits in heap (compile-time check above)
224 // 4. Heap memory is zero-initialized (by Rialo runtime or test allocator)
225 // 5. Header<G> is Zeroable, so zero-initialization is valid
226 unsafe { &*self.heap_start().cast::<Header<G>>() }
227 }
228
229 /// Fast path allocation - assumes success is common case
230 #[inline(always)]
231 fn try_alloc_fast(&self, layout: Layout) -> Option<*mut u8> {
232 let header = self.header();
233 let current_offset = header.get_end_offset();
234
235 let size = match u32::try_from(layout.size()) {
236 Ok(s) => s,
237 Err(_) => return None,
238 };
239
240 debug_assert!(layout.align().is_power_of_two());
241 let align_mask = (layout.align() - 1) as u32;
242
243 let aligned_offset = match current_offset.checked_add(align_mask) {
244 Some(v) => v & !align_mask,
245 None => return None,
246 };
247
248 let end_offset = match aligned_offset.checked_add(size) {
249 Some(end) => end,
250 None => return None,
251 };
252
253 #[cfg(test)]
254 if end_offset as usize > self.layout.size() {
255 return None;
256 }
257
258 header.set_end_offset(end_offset);
259 Some(self.from_offset(aligned_offset))
260 }
261
262 /// Try to allocate at a specific pointer (used for in-place realloc)
263 #[inline]
264 fn try_alloc_at(&self, ptr: *mut u8, layout: Layout) -> Option<*mut u8> {
265 let offset = self.to_offset(ptr);
266
267 let size = match u32::try_from(layout.size()) {
268 Ok(s) => s,
269 Err(_) => return None,
270 };
271
272 let end_offset = match offset.checked_add(size) {
273 Some(end) => end,
274 None => return None,
275 };
276
277 #[cfg(test)]
278 if end_offset as usize > self.layout.size() {
279 return None;
280 }
281
282 self.header().set_end_offset(end_offset);
283 Some(ptr)
284 }
285
286 /// Returns reference to global state reserved at heap start
287 #[inline]
288 pub fn global(&self) -> &G {
289 &self.header().global
290 }
291
292 /// Returns amount of heap used (excluding header)
293 #[cfg(test)]
294 pub fn used(&self) -> usize {
295 self.header().used.get() as usize
296 }
297}
298
299// SAFETY: BumpAllocator correctly implements GlobalAlloc
300unsafe impl<G: bytemuck::Zeroable> GlobalAlloc for BumpAllocator<G> {
301 #[inline]
302 unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
303 // Fast path: assume allocation succeeds
304 self.try_alloc_fast(layout)
305 .unwrap_or_else(|| core::ptr::null_mut())
306 }
307
308 #[inline]
309 unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
310 // Only deallocate if this is the most recent allocation
311 let header = self.header();
312 let ptr_end = ptr.wrapping_add(layout.size());
313 let end_offset = self.to_offset(ptr_end);
314
315 if end_offset == header.get_end_offset() {
316 // This was the last allocation, reclaim it
317 header.set_end_offset(self.to_offset(ptr));
318 }
319 // Otherwise, bump allocator intentionally leaks (by design)
320 }
321
322 #[inline]
323 unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
324 let header = self.header();
325 let ptr_end = ptr.wrapping_add(layout.size());
326 let end_offset = self.to_offset(ptr_end);
327
328 // Check if this is the last allocation
329 if end_offset == header.get_end_offset() {
330 // Last allocation - try to resize in place
331 // SAFETY: Caller guarantees new layout is valid for the same alignment
332 let new_layout = Layout::from_size_align_unchecked(new_size, layout.align());
333 return self
334 .try_alloc_at(ptr, new_layout)
335 .unwrap_or_else(|| core::ptr::null_mut());
336 }
337
338 // Not the last allocation
339 if new_size <= layout.size() {
340 // Shrinking - return same pointer (leak extra space, this is bump allocator)
341 return ptr;
342 }
343
344 // Growing non-last allocation - need new allocation and copy
345 // SAFETY: Caller guarantees new layout is valid for the same alignment
346 let new_layout = Layout::from_size_align_unchecked(new_size, layout.align());
347 match self.try_alloc_fast(new_layout) {
348 Some(new_ptr) => {
349 // SAFETY:
350 // - src is valid for reads of layout.size() bytes (caller guarantee)
351 // - dst is valid for writes of new_size bytes (just allocated)
352 // - Regions don't overlap (new allocation is after old in bump allocator)
353 core::ptr::copy_nonoverlapping(ptr, new_ptr, layout.size());
354 new_ptr
355 }
356 None => core::ptr::null_mut(),
357 }
358 }
359}
360
361#[cfg(test)]
362mod unit_tests;