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 the heap base. The loader supplies the usable heap
20//! size at runtime (`set_heap_limit`); an allocation that would exceed it is refused and surfaces
21//! as a clean allocation error (`handle_alloc_error`), so the allocator never needs the heap size
22//! at compile time. Writing past the heap is only possible by bypassing this allocator, in which
23//! case accessing memory beyond the mapped region eventually traps.
24//!
25//! # Key Assumptions
26//!
27//! This allocator relies on guarantees provided by the Rialo runtime:
28//!
29//! 1. **Heap location**: On RISC-V the loader supplies the heap base at runtime (recorded
30//! via `set_heap_base`, called from the entrypoint before any allocation); on other
31//! targets it is the fixed address `0x300000000`.
32//! 2. **Zero-initialization**: The Rialo runtime zero-initializes the heap region
33//! before program execution begins
34//! 3. **Bounded allocation**: On RISC-V the loader also supplies the usable heap size (recorded
35//! via `set_heap_limit`); allocations beyond it are refused. Accessing memory beyond the mapped
36//! region, only reachable by bypassing this allocator, still traps.
37//!
38//! These assumptions are part of Rialo's documented runtime behavior and are validated
39//! in tests using a simulated heap environment.
40//!
41//! # Deallocation Behavior
42//!
43//! As a bump allocator, this implementation:
44//! - Can reclaim space from the most recent allocation if deallocated
45//! - Intentionally leaks memory for all other deallocations (by design)
46//! - Is optimized for Rialo's short-lived transaction model where all memory
47//! is reclaimed when the transaction completes
48//!
49//! # Usage
50//!
51//! The allocator is typically set up via the `custom_heap_default!` macro in the
52//! entrypoint crate. Programs don't interact with it directly - it's used automatically
53//! by Rust's allocation APIs (`Vec`, `Box`, etc.).
54
55use core::{
56 alloc::{GlobalAlloc, Layout},
57 cell::Cell,
58};
59
60/// Minimum guaranteed heap size.
61///
62/// NOTE: Actual heap size may be larger if requested via Compute Budget.
63/// The allocator automatically uses all available heap space.
64pub const MIN_HEAP_LENGTH: usize = 32 * 1024;
65
66/// Bump allocator that grows upward from the heap base.
67///
68/// Generic parameter `G` allows for optional global state storage at the heap start.
69/// Use `G = ()` (the default) for no global state.
70///
71/// # Safety
72///
73/// Only one instance should exist per program, and it must be set as the global allocator.
74/// Creating multiple instances or using alongside another allocator is undefined behavior.
75pub struct BumpAllocator<G = ()> {
76 #[cfg(test)]
77 ptr: core::ptr::NonNull<u8>,
78 #[cfg(test)]
79 layout: Layout,
80
81 _phantom: core::marker::PhantomData<G>,
82}
83
84/// Header stored at the start of the heap containing allocator metadata.
85///
86/// The header is zero-initialized by the Rialo runtime (or explicitly by tests).
87/// Using `Cell<u32>` provides interior mutability for updating the allocation offset.
88#[repr(C)]
89struct Header<G> {
90 /// Offset from end of header to first free byte (not from heap start)
91 used: Cell<u32>,
92 /// Optional global state (zero-sized if G = ())
93 global: G,
94}
95
96impl<G> Header<G> {
97 /// Size of the header including alignment padding
98 const SIZE: u32 = {
99 let size = core::mem::size_of::<Header<G>>();
100 // Size validation happens in header() where we check against MIN_HEAP_LENGTH
101 size as u32
102 };
103
104 /// Get the offset from heap start to the first free byte
105 #[inline(always)]
106 fn get_end_offset(&self) -> u32 {
107 self.used.get().wrapping_add(Self::SIZE)
108 }
109
110 /// Set the offset from heap start to the first free byte
111 #[inline(always)]
112 fn set_end_offset(&self, offset: u32) {
113 self.used.set(offset.wrapping_sub(Self::SIZE));
114 }
115}
116
117/// `Sync` wrapper over `UnsafeCell<u32>` for the loader-supplied heap base and heap limit (RISC-V
118/// target). A `static` must be `Sync`, which `UnsafeCell` is not; the wrapper carries the
119/// `unsafe impl Sync` below, sound because the guest VM is single-threaded.
120#[cfg(all(not(test), target_arch = "riscv64"))]
121struct HeapCell(core::cell::UnsafeCell<u32>);
122
123// SAFETY: the guest VM executes on a single thread, so these statics are never accessed concurrently.
124#[cfg(all(not(test), target_arch = "riscv64"))]
125unsafe impl Sync for HeapCell {}
126
127/// Loader-supplied heap base. On RISC-V the heap is not at a fixed address: the loader places it
128/// right above the program's read-only and read-write data and passes the base to the entrypoint,
129/// recorded here via `set_heap_base`. Growing upward from this per-program base means a program
130/// only ever backs the heap it actually requested, regardless of how large its data sections are.
131#[cfg(all(not(test), target_arch = "riscv64"))]
132static HEAP_BASE: HeapCell = HeapCell(core::cell::UnsafeCell::new(0));
133
134/// Loader-supplied usable heap size in bytes (the program's requested `heap_size`). Bounds the
135/// default allocator (see `BumpAllocator::heap_limit`); recorded via `set_heap_limit`.
136#[cfg(all(not(test), target_arch = "riscv64"))]
137static HEAP_LIMIT: HeapCell = HeapCell(core::cell::UnsafeCell::new(0));
138
139/// Records the heap base supplied by the loader.
140///
141/// Called by the `entrypoint!`/`entrypoint_no_alloc!` macros at the very start of execution,
142/// before any allocation can occur. A program that installs `BumpAllocator` through a
143/// hand-rolled entrypoint, bypassing those macros, MUST call this (and `set_heap_limit`) itself
144/// before its first allocation; otherwise the heap base stays 0 and allocations trap on unmapped
145/// memory.
146#[cfg(all(not(test), target_arch = "riscv64"))]
147#[inline(always)]
148pub fn set_heap_base(base: u32) {
149 // SAFETY: single-threaded VM; called once from the entrypoint before the allocator is used,
150 // so there is no aliasing or concurrent access.
151 unsafe { *HEAP_BASE.0.get() = base };
152}
153
154/// Records the usable heap size (bytes) supplied by the loader.
155///
156/// Called by the `entrypoint!`/`entrypoint_no_alloc!` macros alongside `set_heap_base`. Bounds the
157/// default allocator to the program's requested `heap_size` so an over-allocation fails with a
158/// clean allocation error instead of growing into the serialized account region.
159#[cfg(all(not(test), target_arch = "riscv64"))]
160#[inline(always)]
161pub fn set_heap_limit(limit: u32) {
162 // SAFETY: single-threaded VM; called once from the entrypoint before the allocator is used,
163 // so there is no aliasing or concurrent access.
164 unsafe { *HEAP_LIMIT.0.get() = limit };
165}
166
167// Non-test (Rialo target) implementation
168#[cfg(not(test))]
169impl<G> BumpAllocator<G> {
170 /// Start address of the memory region used for program heap (non-RISC-V targets).
171 #[cfg(not(target_arch = "riscv64"))]
172 const HEAP_START_ADDRESS: u64 = 0x300000000;
173
174 /// Creates a new allocator.
175 ///
176 /// # Safety
177 ///
178 /// - Only one BumpAllocator instance should exist per program
179 /// - It must be set as the global allocator
180 /// - Multiple instances or using alongside another allocator leads to undefined behavior
181 /// - The Rialo runtime must have zero-initialized the heap region (guaranteed by spec)
182 pub const unsafe fn new() -> Self {
183 // SAFETY: Caller must ensure this is only called once and set as global allocator.
184 // The Rialo runtime guarantees the heap region is zero-initialized before program
185 // execution.
186 Self {
187 _phantom: core::marker::PhantomData,
188 }
189 }
190
191 /// Base address of the heap region.
192 ///
193 /// Fixed on non-RISC-V targets; supplied at runtime by the loader on RISC-V.
194 #[cfg(not(target_arch = "riscv64"))]
195 #[inline(always)]
196 fn base_address(&self) -> u64 {
197 Self::HEAP_START_ADDRESS
198 }
199
200 #[cfg(target_arch = "riscv64")]
201 #[inline(always)]
202 fn base_address(&self) -> u64 {
203 // SAFETY: single-threaded VM; `HEAP_BASE` is set once at entry before any allocation,
204 // and is only ever read here, so there is no aliasing or concurrent access.
205 let base = unsafe { *HEAP_BASE.0.get() };
206 // A zero base means `set_heap_base` was never called, i.e. the `BumpAllocator` was
207 // installed without going through `entrypoint!`/`entrypoint_no_alloc!`. Allocations
208 // would otherwise target unmapped low memory and trap opaquely. Debug-only: compiled
209 // out in release, so no cost on the allocation hot path on-chain.
210 debug_assert!(
211 base != 0,
212 "heap base is 0: call `set_heap_base` before the first allocation"
213 );
214 u64::from(base)
215 }
216
217 /// Usable heap size in bytes, supplied by the loader (RISC-V target).
218 ///
219 /// `try_alloc_*` refuse allocations whose end offset from the heap base would exceed this, so a
220 /// program that over-allocates fails with a clean allocation error at its requested `heap_size`
221 /// boundary instead of growing into the serialized account region above the heap. The bound is
222 /// advisory: it constrains only programs that use this default allocator. A program that
223 /// replaces or bypasses the allocator is not constrained by this bound; a raw write past the
224 /// heap can land anywhere in the serialized account region above it (any instruction account,
225 /// not just the program's own writable ones), but the runtime re-validates every written-back
226 /// change against on-chain authority (`can_data_be_changed`/`can_data_be_resized`, owner and
227 /// kelvins checks), so an illegitimate change fails the transaction rather than persisting.
228 #[cfg(target_arch = "riscv64")]
229 #[inline(always)]
230 fn heap_limit(&self) -> u32 {
231 // SAFETY: single-threaded VM; `HEAP_LIMIT` is set once at entry before any allocation, and
232 // is only ever read here, so there is no aliasing or concurrent access.
233 let limit = unsafe { *HEAP_LIMIT.0.get() };
234 // A zero limit means `set_heap_limit` was never called. Every allocation would then
235 // immediately fail (`end_offset > 0`), aborting with Custom(11) and no clear cause.
236 // Debug-only: compiled out in release, so no cost on the allocation hot path on-chain.
237 debug_assert!(
238 limit != 0,
239 "heap limit is 0: call `set_heap_limit` before the first allocation"
240 );
241 limit
242 }
243
244 #[inline(always)]
245 fn heap_start(&self) -> *mut u8 {
246 self.base_address() as *mut u8
247 }
248
249 #[inline(always)]
250 fn to_offset(&self, ptr: *mut u8) -> u32 {
251 let addr = ptr as u64;
252 let base = self.base_address();
253 debug_assert!(
254 addr >= base && addr < base + u32::MAX as u64,
255 "Pointer outside valid heap range"
256 );
257 (addr - base) as u32
258 }
259
260 #[allow(clippy::wrong_self_convention)]
261 #[inline(always)]
262 fn from_offset(&self, offset: u32) -> *mut u8 {
263 (self.base_address() + offset as u64) as *mut u8
264 }
265}
266
267// Test implementation with actual allocation
268#[cfg(test)]
269impl<G: bytemuck::Zeroable> BumpAllocator<G> {
270 /// Creates a test allocator with specified heap size
271 fn new_test(size: usize) -> Self {
272 let size = size.min(u32::MAX as usize);
273 assert!(
274 size >= core::mem::size_of::<Header<G>>(),
275 "Heap too small for header"
276 );
277
278 let align = core::mem::align_of::<Header<G>>().max(16);
279 let layout = Layout::from_size_align(size, align).unwrap();
280
281 // SAFETY: We're allocating with proper layout
282 let ptr = unsafe { std::alloc::alloc_zeroed(layout) };
283 let ptr = core::ptr::NonNull::new(ptr).expect("Failed to allocate test heap");
284
285 Self {
286 ptr,
287 layout,
288 _phantom: core::marker::PhantomData,
289 }
290 }
291
292 #[inline(always)]
293 fn heap_start(&self) -> *mut u8 {
294 self.ptr.as_ptr()
295 }
296
297 #[inline(always)]
298 fn to_offset(&self, ptr: *mut u8) -> u32 {
299 (ptr as usize - self.heap_start() as usize) as u32
300 }
301
302 #[allow(clippy::wrong_self_convention)]
303 #[inline(always)]
304 fn from_offset(&self, offset: u32) -> *mut u8 {
305 self.heap_start().wrapping_add(offset as usize)
306 }
307}
308
309#[cfg(test)]
310impl<G> Drop for BumpAllocator<G> {
311 fn drop(&mut self) {
312 // SAFETY: ptr and layout match the allocation
313 unsafe {
314 std::alloc::dealloc(self.ptr.as_ptr(), self.layout);
315 }
316 }
317}
318
319impl<G: bytemuck::Zeroable> BumpAllocator<G> {
320 /// Returns reference to the header at the start of the heap
321 #[inline(always)]
322 fn header(&self) -> &Header<G> {
323 // Compile-time check: header must fit in minimum guaranteed heap
324 const {
325 assert!(
326 core::mem::size_of::<Header<G>>() <= MIN_HEAP_LENGTH,
327 "Header too large for minimum heap size"
328 );
329 }
330
331 // SAFETY:
332 // 1. On Rialo: the heap base is page-aligned (runtime-supplied on RISC-V, the fixed
333 // 0x300000000 elsewhere), so the Header is aligned
334 // 2. In tests: Test allocator ensures proper alignment via Layout
335 // 3. Header fits in heap (compile-time check above)
336 // 4. Heap memory is zero-initialized (by Rialo runtime or test allocator)
337 // 5. Header<G> is Zeroable, so zero-initialization is valid
338 unsafe { &*self.heap_start().cast::<Header<G>>() }
339 }
340
341 /// Fast path allocation - assumes success is common case
342 #[inline(always)]
343 fn try_alloc_fast(&self, layout: Layout) -> Option<*mut u8> {
344 let header = self.header();
345 let current_offset = header.get_end_offset();
346
347 let size = match u32::try_from(layout.size()) {
348 Ok(s) => s,
349 Err(_) => return None,
350 };
351
352 debug_assert!(layout.align().is_power_of_two());
353 let align_mask = (layout.align() - 1) as u32;
354
355 let aligned_offset = match current_offset.checked_add(align_mask) {
356 Some(v) => v & !align_mask,
357 None => return None,
358 };
359
360 #[allow(clippy::question_mark)]
361 let end_offset = match aligned_offset.checked_add(size) {
362 Some(end) => end,
363 None => return None,
364 };
365
366 #[cfg(test)]
367 if end_offset as usize > self.layout.size() {
368 return None;
369 }
370
371 // Advisory bound: refuse allocations past the loader-supplied heap size so an
372 // over-allocation fails cleanly instead of growing into the account region.
373 #[cfg(all(not(test), target_arch = "riscv64"))]
374 if end_offset > self.heap_limit() {
375 return None;
376 }
377
378 header.set_end_offset(end_offset);
379 Some(self.from_offset(aligned_offset))
380 }
381
382 #[allow(clippy::question_mark)]
383 /// Try to allocate at a specific pointer (used for in-place realloc)
384 #[inline]
385 fn try_alloc_at(&self, ptr: *mut u8, layout: Layout) -> Option<*mut u8> {
386 let offset = self.to_offset(ptr);
387
388 let size = match u32::try_from(layout.size()) {
389 Ok(s) => s,
390 Err(_) => return None,
391 };
392
393 let end_offset = match offset.checked_add(size) {
394 Some(end) => end,
395 None => return None,
396 };
397
398 #[cfg(test)]
399 if end_offset as usize > self.layout.size() {
400 return None;
401 }
402
403 // Advisory bound: refuse in-place growth past the loader-supplied heap size.
404 #[cfg(all(not(test), target_arch = "riscv64"))]
405 if end_offset > self.heap_limit() {
406 return None;
407 }
408
409 self.header().set_end_offset(end_offset);
410 Some(ptr)
411 }
412
413 /// Returns reference to global state reserved at heap start
414 #[inline]
415 pub fn global(&self) -> &G {
416 &self.header().global
417 }
418
419 /// Returns amount of heap used (excluding header)
420 #[cfg(test)]
421 pub fn used(&self) -> usize {
422 self.header().used.get() as usize
423 }
424}
425
426// SAFETY: BumpAllocator correctly implements GlobalAlloc
427unsafe impl<G: bytemuck::Zeroable> GlobalAlloc for BumpAllocator<G> {
428 #[inline]
429 unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
430 // Fast path: assume allocation succeeds
431 self.try_alloc_fast(layout).unwrap_or(core::ptr::null_mut())
432 }
433
434 #[inline]
435 unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
436 // Only deallocate if this is the most recent allocation
437 let header = self.header();
438 let ptr_end = ptr.wrapping_add(layout.size());
439 let end_offset = self.to_offset(ptr_end);
440
441 if end_offset == header.get_end_offset() {
442 // This was the last allocation, reclaim it
443 header.set_end_offset(self.to_offset(ptr));
444 }
445 // Otherwise, bump allocator intentionally leaks (by design)
446 }
447
448 #[inline]
449 unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
450 let header = self.header();
451 let ptr_end = ptr.wrapping_add(layout.size());
452 let end_offset = self.to_offset(ptr_end);
453
454 // Check if this is the last allocation
455 if end_offset == header.get_end_offset() {
456 // Last allocation - try to resize in place
457 // SAFETY: Caller guarantees new layout is valid for the same alignment
458 let new_layout = Layout::from_size_align_unchecked(new_size, layout.align());
459 return self
460 .try_alloc_at(ptr, new_layout)
461 .unwrap_or(core::ptr::null_mut());
462 }
463
464 // Not the last allocation
465 if new_size <= layout.size() {
466 // Shrinking - return same pointer (leak extra space, this is bump allocator)
467 return ptr;
468 }
469
470 // Growing non-last allocation - need new allocation and copy
471 // SAFETY: Caller guarantees new layout is valid for the same alignment
472 let new_layout = Layout::from_size_align_unchecked(new_size, layout.align());
473 match self.try_alloc_fast(new_layout) {
474 Some(new_ptr) => {
475 // SAFETY:
476 // - src is valid for reads of layout.size() bytes (caller guarantee)
477 // - dst is valid for writes of new_size bytes (just allocated)
478 // - Regions don't overlap (new allocation is after old in bump allocator)
479 core::ptr::copy_nonoverlapping(ptr, new_ptr, layout.size());
480 new_ptr
481 }
482 None => core::ptr::null_mut(),
483 }
484 }
485}
486
487#[cfg(test)]
488mod unit_tests;