zk_nalloc/lib.rs
1//! nalloc: A ZK-Proof optimized memory allocator.
2//!
3//! This crate provides a high-performance, deterministic memory allocator
4//! specifically designed for Zero-Knowledge proof systems. It is framework-agnostic
5//! and works with any ZK system: Halo2, Plonky2, Risc0, SP1, Miden, Cairo, Arkworks, etc.
6//!
7//! # Features
8//!
9//! - **Arena-based allocation**: Pre-reserved memory pools for different workload types
10//! - **Bump allocation**: O(1) allocation via atomic pointer increment
11//! - **Security-first**: Volatile secure wiping for witness data
12//! - **Cache-optimized**: 64-byte alignment for FFT/NTT SIMD operations
13//! - **Cross-platform**: Linux, macOS, Windows, and Unix support
14//! - **Zero ZK dependencies**: Pure memory primitive, no framework lock-in
15//! - **Fallback support**: Gracefully falls back to system allocator when arena exhausted
16//!
17//! # Cargo Features
18//!
19//! - `fallback` (default): Fall back to system allocator when arena is exhausted
20//! - `huge-pages`: Enable Linux 2MB/1GB huge page support
21//! - `guard-pages`: Add guard pages at arena boundaries for overflow detection
22//! - `mlock`: Lock witness memory to prevent swapping (security)
23//!
24//! # Usage
25//!
26//! As a global allocator:
27//! ```rust,no_run
28//! use zk_nalloc::NAlloc;
29//!
30//! #[global_allocator]
31//! static ALLOC: NAlloc = NAlloc::new();
32//!
33//! fn main() {
34//! let data = vec![0u64; 1000];
35//! println!("Allocated {} elements", data.len());
36//! }
37//! ```
38//!
39//! Using specialized arenas directly:
40//! ```rust
41//! use zk_nalloc::NAlloc;
42//!
43//! let alloc = NAlloc::new();
44//! let witness = alloc.witness();
45//! let ptr = witness.alloc(1024, 8);
46//! assert!(!ptr.is_null());
47//!
48//! // Securely wipe when done
49//! unsafe { witness.secure_wipe(); }
50//! ```
51
52pub mod arena;
53pub mod bump;
54pub mod config;
55pub mod platform;
56pub mod polynomial;
57pub mod witness;
58
59pub use arena::{ArenaManager, ArenaStats};
60pub use bump::BumpAlloc;
61pub use config::*;
62pub use platform::sys;
63#[cfg(feature = "guard-pages")]
64pub use platform::GuardedAlloc;
65#[cfg(feature = "huge-pages")]
66pub use platform::HugePageSize;
67pub use platform::{AllocErrorKind, AllocFailed};
68pub use polynomial::PolynomialArena;
69pub use witness::WitnessArena;
70
71use std::alloc::{GlobalAlloc, Layout, System};
72use std::ptr::{copy_nonoverlapping, null_mut};
73use std::sync::atomic::{AtomicPtr, AtomicU8, Ordering};
74
75/// Initialization state for NAlloc.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77#[repr(u8)]
78enum InitState {
79 /// Not yet initialized
80 Uninitialized = 0,
81 /// Currently being initialized by another thread
82 Initializing = 1,
83 /// Successfully initialized with arenas
84 Initialized = 2,
85 /// Failed to initialize, using system allocator fallback
86 Fallback = 3,
87}
88
89/// The global ZK-optimized allocator.
90///
91/// `NAlloc` provides a drop-in replacement for the standard Rust global allocator,
92/// with special optimizations for ZK-Proof workloads.
93///
94/// # Memory Strategy
95///
96/// - **Large allocations (>1MB)**: Routed to Polynomial Arena (FFT vectors)
97/// - **Small allocations**: Routed to Scratch Arena (temporary buffers)
98/// - **Witness data**: Use `NAlloc::witness()` for security-critical allocations
99///
100/// # Thread Safety
101///
102/// This allocator uses lock-free atomic operations for initialization and
103/// allocation. It's safe to use from multiple threads concurrently.
104///
105/// # Fallback Behavior
106///
107/// If arena initialization fails (e.g., out of memory), NAlloc gracefully
108/// falls back to the system allocator rather than panicking. This ensures
109/// your application continues to function even under memory pressure.
110#[must_use]
111pub struct NAlloc {
112 /// Pointer to the ArenaManager (null until initialized)
113 arenas: AtomicPtr<ArenaManager>,
114 /// Initialization state
115 init_state: AtomicU8,
116}
117
118impl NAlloc {
119 /// Create a new `NAlloc` instance.
120 ///
121 /// The arenas are lazily initialized on the first allocation.
122 pub const fn new() -> Self {
123 Self {
124 arenas: AtomicPtr::new(null_mut()),
125 init_state: AtomicU8::new(InitState::Uninitialized as u8),
126 }
127 }
128
129 /// Try to create NAlloc and initialize arenas immediately.
130 ///
131 /// Returns an error if arena allocation fails, allowing the caller
132 /// to handle the failure gracefully.
133 #[must_use]
134 pub fn try_new() -> Result<Self, AllocFailed> {
135 let nalloc = Self::new();
136 nalloc.try_init()?;
137 Ok(nalloc)
138 }
139
140 /// Try to initialize arenas.
141 ///
142 /// Returns Ok if initialization succeeds or was already done.
143 /// Returns Err if initialization fails.
144 fn try_init(&self) -> Result<(), AllocFailed> {
145 let state = self.init_state.load(Ordering::Acquire);
146
147 match state {
148 s if s == InitState::Initialized as u8 => Ok(()),
149 s if s == InitState::Fallback as u8 => {
150 Err(AllocFailed::with_kind(0, AllocErrorKind::OutOfMemory))
151 }
152 _ => {
153 let ptr = self.init_arenas();
154 if ptr.is_null() {
155 Err(AllocFailed::with_kind(0, AllocErrorKind::OutOfMemory))
156 } else {
157 Ok(())
158 }
159 }
160 }
161 }
162
163 /// Initialize the arenas if not already done.
164 ///
165 /// This uses a spin-lock pattern with atomic state to prevent
166 /// recursive allocation issues and handle initialization failures gracefully.
167 #[cold]
168 #[inline(never)]
169 fn init_arenas(&self) -> *mut ArenaManager {
170 // Fast path: already initialized
171 let state = self.init_state.load(Ordering::Acquire);
172 if state == InitState::Initialized as u8 {
173 return self.arenas.load(Ordering::Acquire);
174 }
175 if state == InitState::Fallback as u8 {
176 return null_mut();
177 }
178
179 // Try to acquire initialization lock
180 if self
181 .init_state
182 .compare_exchange(
183 InitState::Uninitialized as u8,
184 InitState::Initializing as u8,
185 Ordering::AcqRel,
186 Ordering::Relaxed,
187 )
188 .is_ok()
189 {
190 // We won the race - initialize
191 match ArenaManager::new() {
192 Ok(manager) => {
193 // Use system allocator to avoid recursive allocation
194 let layout = Layout::new::<ArenaManager>();
195 let raw = unsafe { System.alloc(layout) as *mut ArenaManager };
196
197 if raw.is_null() {
198 // Failed to allocate manager struct - enter fallback mode
199 eprintln!("[nalloc] Warning: Failed to allocate ArenaManager struct, using system allocator");
200 self.init_state
201 .store(InitState::Fallback as u8, Ordering::Release);
202 return null_mut();
203 }
204
205 unsafe {
206 std::ptr::write(raw, manager);
207 }
208 self.arenas.store(raw, Ordering::Release);
209 self.init_state
210 .store(InitState::Initialized as u8, Ordering::Release);
211 return raw;
212 }
213 Err(e) => {
214 // Arena allocation failed - enter fallback mode
215 eprintln!(
216 "[nalloc] Warning: Arena initialization failed ({}), using system allocator",
217 e
218 );
219 self.init_state
220 .store(InitState::Fallback as u8, Ordering::Release);
221 return null_mut();
222 }
223 }
224 }
225
226 // Another thread is initializing - spin wait with timeout (Issue #2)
227 // Inner SPIN_ITERATIONS hint loops yield the CPU before each state check,
228 // avoiding unnecessary memory bus traffic on contended cache lines.
229 for _ in 0..MAX_CAS_RETRIES {
230 for _ in 0..SPIN_ITERATIONS {
231 std::hint::spin_loop();
232 }
233 let state = self.init_state.load(Ordering::Acquire);
234
235 match state {
236 s if s == InitState::Initialized as u8 => {
237 return self.arenas.load(Ordering::Acquire);
238 }
239 s if s == InitState::Fallback as u8 => {
240 return null_mut();
241 }
242 _ => continue,
243 }
244 }
245
246 // Issue #2: Timeout - initialization is stuck or taking too long
247 // Fall back to system allocator rather than spinning forever
248 #[cfg(debug_assertions)]
249 eprintln!("[nalloc] Warning: Arena initialization timed out, using system allocator");
250 null_mut()
251 }
252
253 /// Check if NAlloc is operating in fallback mode (using system allocator).
254 #[must_use]
255 #[inline]
256 pub fn is_fallback_mode(&self) -> bool {
257 self.init_state.load(Ordering::Relaxed) == InitState::Fallback as u8
258 }
259
260 /// Check if NAlloc is fully initialized with arenas.
261 #[must_use]
262 #[inline]
263 pub fn is_initialized(&self) -> bool {
264 self.init_state.load(Ordering::Relaxed) == InitState::Initialized as u8
265 }
266
267 #[inline(always)]
268 fn get_arenas(&self) -> Option<&ArenaManager> {
269 let state = self.init_state.load(Ordering::Acquire);
270
271 if state == InitState::Initialized as u8 {
272 let ptr = self.arenas.load(Ordering::Acquire);
273 if !ptr.is_null() {
274 return Some(unsafe { &*ptr });
275 }
276 }
277
278 if state == InitState::Uninitialized as u8 || state == InitState::Initializing as u8 {
279 let ptr = self.init_arenas();
280 if !ptr.is_null() {
281 return Some(unsafe { &*ptr });
282 }
283 }
284
285 None
286 }
287
288 /// Access the witness arena directly.
289 ///
290 /// Use this for allocating sensitive private inputs that need
291 /// zero-initialization and secure wiping.
292 ///
293 /// # Panics
294 ///
295 /// Panics if arena initialization failed. Use `try_witness()` for
296 /// fallible access.
297 ///
298 /// # Example
299 ///
300 /// ```rust
301 /// use zk_nalloc::NAlloc;
302 ///
303 /// let alloc = NAlloc::new();
304 /// let witness = alloc.witness();
305 /// let secret_ptr = witness.alloc(256, 8);
306 /// assert!(!secret_ptr.is_null());
307 ///
308 /// // Securely wipe when done
309 /// unsafe { witness.secure_wipe(); }
310 /// ```
311 #[inline]
312 pub fn witness(&self) -> WitnessArena {
313 self.try_witness()
314 .expect("Arena initialization failed - use try_witness() for fallible access")
315 }
316
317 /// Try to access the witness arena.
318 ///
319 /// Returns `None` if arena initialization failed.
320 #[must_use]
321 #[inline]
322 pub fn try_witness(&self) -> Option<WitnessArena> {
323 self.get_arenas().map(|a| WitnessArena::new(a.witness()))
324 }
325
326 /// Access the polynomial arena directly.
327 ///
328 /// Use this for FFT/NTT-friendly polynomial coefficient vectors.
329 /// Provides 64-byte alignment by default for SIMD operations.
330 ///
331 /// # Panics
332 ///
333 /// Panics if arena initialization failed. Use `try_polynomial()` for
334 /// fallible access.
335 ///
336 /// # Example
337 ///
338 /// ```rust
339 /// use zk_nalloc::NAlloc;
340 ///
341 /// let alloc = NAlloc::new();
342 /// let poly = alloc.polynomial();
343 /// let coeffs = poly.alloc_fft_friendly(1024); // 1K coefficients
344 /// assert!(!coeffs.is_null());
345 /// assert_eq!((coeffs as usize) % 64, 0); // 64-byte aligned
346 /// ```
347 #[inline]
348 pub fn polynomial(&self) -> PolynomialArena {
349 self.try_polynomial()
350 .expect("Arena initialization failed - use try_polynomial() for fallible access")
351 }
352
353 /// Try to access the polynomial arena.
354 ///
355 /// Returns `None` if arena initialization failed.
356 #[must_use]
357 #[inline]
358 pub fn try_polynomial(&self) -> Option<PolynomialArena> {
359 self.get_arenas()
360 .map(|a| PolynomialArena::new(a.polynomial()))
361 }
362
363 /// Access the scratch arena directly.
364 ///
365 /// Use this for temporary computation space.
366 ///
367 /// # Panics
368 ///
369 /// Panics if arena initialization failed. Use `try_scratch()` for
370 /// fallible access.
371 #[inline]
372 pub fn scratch(&self) -> std::sync::Arc<BumpAlloc> {
373 self.try_scratch()
374 .expect("Arena initialization failed - use try_scratch() for fallible access")
375 }
376
377 /// Try to access the scratch arena.
378 ///
379 /// Returns `None` if arena initialization failed.
380 #[must_use]
381 #[inline]
382 pub fn try_scratch(&self) -> Option<std::sync::Arc<BumpAlloc>> {
383 self.get_arenas().map(|a| a.scratch())
384 }
385
386 /// Reset all arenas, freeing all allocated memory.
387 ///
388 /// The witness arena is securely wiped before reset.
389 ///
390 /// # Safety
391 /// This will invalidate all previously allocated memory.
392 ///
393 /// # Note
394 /// Does nothing if operating in fallback mode.
395 pub unsafe fn reset_all(&self) {
396 if let Some(arenas) = self.get_arenas() {
397 arenas.reset_all();
398 }
399 }
400
401 /// Get statistics about arena usage.
402 ///
403 /// Returns `None` if operating in fallback mode.
404 ///
405 /// Useful for monitoring memory consumption and tuning arena sizes.
406 #[must_use]
407 pub fn stats(&self) -> Option<ArenaStats> {
408 self.get_arenas().map(|a| a.stats())
409 }
410
411 /// Get statistics, returning default stats if in fallback mode.
412 #[must_use]
413 pub fn stats_or_default(&self) -> ArenaStats {
414 self.stats().unwrap_or(ArenaStats {
415 witness_used: 0,
416 witness_capacity: 0,
417 polynomial_used: 0,
418 polynomial_capacity: 0,
419 scratch_used: 0,
420 scratch_capacity: 0,
421 #[cfg(feature = "fallback")]
422 witness_fallback_bytes: 0,
423 #[cfg(feature = "fallback")]
424 polynomial_fallback_bytes: 0,
425 #[cfg(feature = "fallback")]
426 scratch_fallback_bytes: 0,
427 })
428 }
429}
430
431impl Default for NAlloc {
432 fn default() -> Self {
433 Self::new()
434 }
435}
436
437impl Drop for NAlloc {
438 fn drop(&mut self) {
439 // Only clean up if we successfully initialized arenas.
440 // Fallback mode never allocated an ArenaManager on the heap.
441 if *self.init_state.get_mut() == InitState::Initialized as u8 {
442 let ptr = *self.arenas.get_mut();
443 if !ptr.is_null() {
444 unsafe {
445 // Run ArenaManager's own Drop (securely wipes witness, unmaps arenas).
446 std::ptr::drop_in_place(ptr);
447 // Deallocate the heap slot we allocated in init_arenas().
448 let layout = Layout::new::<ArenaManager>();
449 System.dealloc(ptr as *mut u8, layout);
450 }
451 }
452 }
453 }
454}
455
456// Safety: NAlloc uses atomic operations for all shared state
457unsafe impl Send for NAlloc {}
458unsafe impl Sync for NAlloc {}
459
460unsafe impl GlobalAlloc for NAlloc {
461 #[inline(always)]
462 unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
463 debug_assert!(layout.size() > 0);
464 debug_assert!(layout.align() > 0);
465 debug_assert!(layout.align().is_power_of_two());
466
467 // Try to use arenas
468 if let Some(arenas) = self.get_arenas() {
469 // Strategy:
470 // 1. Large allocations (>threshold) go to Polynomial Arena (likely vectors)
471 // 2. Smaller allocations go to Scratch Arena
472 // 3. User can explicitly use Witness Arena via NAlloc::witness()
473
474 if layout.size() > LARGE_ALLOC_THRESHOLD {
475 arenas.polynomial().alloc(layout.size(), layout.align())
476 } else {
477 arenas.scratch().alloc(layout.size(), layout.align())
478 }
479 } else {
480 // Fallback to system allocator
481 System.alloc(layout)
482 }
483 }
484
485 #[inline(always)]
486 unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
487 // In fallback mode, we need to actually deallocate
488 if self.is_fallback_mode() {
489 System.dealloc(ptr, layout);
490 return;
491 }
492
493 // Issue #1: Check if this allocation came from fallback
494 // Arena allocations are within known address ranges; fallback allocations are not
495 if let Some(arenas) = self.get_arenas() {
496 let ptr_addr = ptr as usize;
497 if !arenas.contains_address(ptr_addr) {
498 // This was a fallback allocation - free it via system allocator
499 System.dealloc(ptr, layout);
500 return;
501 }
502 }
503
504 // For arena allocations, deallocation is a no-op.
505 // Memory is reclaimed by calling reset() on the arena.
506 }
507
508 #[inline(always)]
509 unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
510 debug_assert!(!ptr.is_null());
511 debug_assert!(layout.size() > 0);
512 debug_assert!(new_size > 0);
513
514 let old_size = layout.size();
515
516 // If the new size is smaller or equal, just return the same pointer.
517 // (The bump allocator doesn't shrink.)
518 if new_size <= old_size {
519 return ptr;
520 }
521
522 // Allocate a new block
523 let new_layout = Layout::from_size_align_unchecked(new_size, layout.align());
524 let new_ptr = self.alloc(new_layout);
525
526 if new_ptr.is_null() {
527 return null_mut();
528 }
529
530 // Copy the old data
531 copy_nonoverlapping(ptr, new_ptr, old_size);
532
533 // Dealloc the old pointer (no-op for bump allocator, but semantically correct)
534 self.dealloc(ptr, layout);
535
536 new_ptr
537 }
538
539 #[inline(always)]
540 unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
541 let ptr = self.alloc(layout);
542 if !ptr.is_null() {
543 // Note: mmap'd memory is already zeroed, but we zero anyway for
544 // recycled memory or if user specifically requested zeroed allocation.
545 std::ptr::write_bytes(ptr, 0, layout.size());
546 }
547 ptr
548 }
549}
550
551#[cfg(test)]
552mod tests {
553 use super::*;
554 use std::alloc::GlobalAlloc;
555
556 #[test]
557 fn test_global_alloc_api() {
558 let alloc = NAlloc::new();
559 let layout = Layout::from_size_align(1024, 8).unwrap();
560 unsafe {
561 let ptr = alloc.alloc(layout);
562 assert!(!ptr.is_null());
563 // Check that we can write to it
564 ptr.write(42);
565 assert_eq!(ptr.read(), 42);
566 }
567 }
568
569 #[test]
570 fn test_try_new() {
571 // This should succeed on any reasonable system
572 let result = NAlloc::try_new();
573 assert!(result.is_ok());
574
575 let alloc = result.unwrap();
576 assert!(alloc.is_initialized());
577 assert!(!alloc.is_fallback_mode());
578 }
579
580 #[test]
581 fn test_fallback_mode_detection() {
582 let alloc = NAlloc::new();
583 // Force initialization
584 let _ = alloc.stats();
585
586 // Should be initialized (not fallback) on a normal system
587 assert!(alloc.is_initialized() || alloc.is_fallback_mode());
588 }
589
590 #[test]
591 fn test_try_accessors() {
592 let alloc = NAlloc::new();
593
594 // These should return Some on a normal system
595 assert!(alloc.try_witness().is_some());
596 assert!(alloc.try_polynomial().is_some());
597 assert!(alloc.try_scratch().is_some());
598 }
599
600 #[test]
601 fn test_realloc() {
602 let alloc = NAlloc::new();
603 let layout = Layout::from_size_align(64, 8).unwrap();
604 unsafe {
605 let ptr = alloc.alloc(layout);
606 assert!(!ptr.is_null());
607
608 // Write some data
609 for i in 0..64 {
610 ptr.add(i).write(i as u8);
611 }
612
613 // Realloc to a larger size
614 let new_ptr = alloc.realloc(ptr, layout, 128);
615 assert!(!new_ptr.is_null());
616
617 // Verify data was copied
618 for i in 0..64 {
619 assert_eq!(new_ptr.add(i).read(), i as u8);
620 }
621 }
622 }
623
624 #[test]
625 fn test_alloc_zeroed() {
626 let alloc = NAlloc::new();
627 let layout = Layout::from_size_align(1024, 8).unwrap();
628 unsafe {
629 let ptr = alloc.alloc_zeroed(layout);
630 assert!(!ptr.is_null());
631
632 // Verify memory is zeroed
633 for i in 0..1024 {
634 assert_eq!(*ptr.add(i), 0);
635 }
636 }
637 }
638
639 #[test]
640 fn test_stats() {
641 let alloc = NAlloc::new();
642
643 // Trigger arena initialization with an allocation
644 let layout = Layout::from_size_align(1024, 8).unwrap();
645 unsafe {
646 let _ = alloc.alloc(layout);
647 }
648
649 let stats = alloc.stats();
650 assert!(stats.is_some());
651
652 let stats = stats.unwrap();
653 assert!(stats.scratch_used >= 1024);
654 assert!(stats.total_capacity() > 0);
655 }
656
657 #[test]
658 fn test_stats_or_default() {
659 let alloc = NAlloc::new();
660
661 // Should work even before initialization
662 let stats = alloc.stats_or_default();
663 // Just verify it doesn't panic
664 let _ = stats.total_capacity();
665 }
666
667 #[test]
668 fn test_large_allocation_routing() {
669 let alloc = NAlloc::new();
670
671 // Small allocation (<1MB) should go to scratch
672 let small_layout = Layout::from_size_align(1024, 8).unwrap();
673 unsafe {
674 let _ = alloc.alloc(small_layout);
675 }
676
677 let stats_after_small = alloc.stats().unwrap();
678 assert!(stats_after_small.scratch_used >= 1024);
679
680 // Large allocation (>1MB) should go to polynomial
681 let large_layout = Layout::from_size_align(2 * 1024 * 1024, 64).unwrap();
682 unsafe {
683 let _ = alloc.alloc(large_layout);
684 }
685
686 let stats_after_large = alloc.stats().unwrap();
687 assert!(stats_after_large.polynomial_used >= 2 * 1024 * 1024);
688 }
689
690 #[test]
691 fn test_drop_deallocates_arena_manager() {
692 // Verify that Drop runs without panic and actually frees the ArenaManager.
693 // If Drop is missing, valgrind/miri would catch the leak; here we test
694 // that drop_in_place + dealloc completes without UB or double-free.
695 {
696 let alloc = NAlloc::try_new().expect("NAlloc::try_new should succeed");
697 assert!(alloc.is_initialized());
698 // alloc drops here → Drop impl runs → ArenaManager is freed
699 }
700 // If we reach here without SIGSEGV / panic, the Drop impl is correct.
701 // Run a second init to confirm the heap is still healthy.
702 let alloc2 = NAlloc::try_new().expect("heap still healthy after previous drop");
703 assert!(alloc2.is_initialized());
704 }
705
706 #[test]
707 fn test_concurrent_init() {
708 use std::sync::Arc;
709 use std::thread;
710
711 let alloc = Arc::new(NAlloc::new());
712 let mut handles = vec![];
713
714 // Spawn multiple threads that try to initialize simultaneously
715 for _ in 0..8 {
716 let alloc = Arc::clone(&alloc);
717 handles.push(thread::spawn(move || {
718 let layout = Layout::from_size_align(64, 8).unwrap();
719 unsafe {
720 let ptr = alloc.alloc(layout);
721 assert!(!ptr.is_null());
722 }
723 }));
724 }
725
726 for h in handles {
727 h.join().unwrap();
728 }
729
730 // After all threads complete, should be in a consistent state
731 assert!(alloc.is_initialized() || alloc.is_fallback_mode());
732 }
733}