Skip to main content

gc_lite/
arena.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright (c) 2025-2026 John Ray <996351336@qq.com>
3
4use core::{alloc::Layout, cell::Cell, mem::size_of, ptr::NonNull};
5
6/// Minimum free slot size: two pointer-width values (`size` + `next`).
7/// On 64-bit: 16 bytes; on 32-bit: 8 bytes.
8const MIN_ALLOC_SIZE: usize = size_of::<usize>() * 2;
9
10/// Arena capacity (64 KB)
11pub const ARENA_CAPACITY: usize = 64 * 1024;
12/// Allocations larger than this skip the arena and go directly to system malloc
13pub const MAX_ARENA_ALLOC: usize = 16 * 1024;
14
15/// A free hole before sorting & merging during sweep
16pub(crate) struct Hole {
17    pub offset: usize,
18    pub size: usize,
19}
20
21/// Bump-pointer arena + free-list hybrid allocator
22#[derive(Debug)]
23pub struct GcArena {
24    base: NonNull<u8>,
25    capacity: usize,
26    /// Current bump frontier offset
27    bump: Cell<usize>,
28    /// Head of the free-list (reclaimed dead node space)
29    free_head: Cell<Option<NonNull<u8>>>,
30    /// Number of live nodes currently in this arena
31    live_count: Cell<usize>,
32}
33
34impl GcArena {
35    /// Create a new arena, allocating `capacity` bytes from the system allocator.
36    pub fn new(capacity: usize) -> Option<Self> {
37        let layout = Layout::from_size_align(capacity, 16).ok()?;
38        let ptr = unsafe { std::alloc::alloc(layout) };
39        if ptr.is_null() {
40            return None;
41        }
42        Some(Self {
43            base: NonNull::new(ptr)?,
44            capacity,
45            bump: Cell::new(0),
46            free_head: Cell::new(None),
47            live_count: Cell::new(0),
48        })
49    }
50
51    /// Main allocation path: bump → free-list → `None`.
52    ///
53    /// Bump is checked first because in practice most dead nodes are
54    /// reclaimed via frontier rewind (see [`collect_hole`]), so the free-list
55    /// is usually empty. Swapping the order avoids an unnecessary linked-list
56    /// traversal on the common (bump) path.
57    pub fn alloc(&self, layout: Layout) -> Option<NonNull<u8>> {
58        let needed = layout.size().max(MIN_ALLOC_SIZE);
59
60        // 1. Bump allocation (fast path — no memory indirection)
61        let align = layout.align();
62        let offset = (self.bump.get() + align - 1) & !(align - 1);
63        if offset + needed <= self.capacity {
64            self.bump.set(offset + needed);
65            self.live_count.set(self.live_count.get() + 1);
66            let ptr = unsafe { self.base.as_ptr().add(offset) };
67            return Some(unsafe { NonNull::new_unchecked(ptr) });
68        }
69
70        // 2. Bump full → try free-list (slow path: linked-list walk)
71        if let Some(ptr) = self.alloc_from_free_list(needed) {
72            self.live_count.set(self.live_count.get() + 1);
73            return Some(ptr);
74        }
75
76        // 3. Truly full
77        None
78    }
79
80    /// First-fit free-list search.
81    fn alloc_from_free_list(&self, needed: usize) -> Option<NonNull<u8>> {
82        let mut prev: Option<NonNull<u8>> = None;
83        let mut curr = self.free_head.get();
84        while let Some(entry) = curr {
85            let size = unsafe { read_free_size(entry) };
86            if size >= needed {
87                // Hit: remove from the list
88                let next = unsafe { read_free_next(entry) };
89                if let Some(p) = prev {
90                    unsafe { write_free_next(p, next) };
91                } else {
92                    self.free_head.set(next);
93                }
94                return Some(entry);
95            }
96            prev = curr;
97            curr = unsafe { read_free_next(entry) };
98        }
99        None
100    }
101
102    /// Reclaim a dead node into the hole list (called by sweep).
103    ///
104    /// Holes are sorted, merged and rebuilt into the free-list in a single
105    /// batch at the end of sweep via [`finish_sweep`].
106    pub(crate) fn collect_hole(&self, holes: &mut Vec<Hole>, node: NonNull<u8>, node_size: usize) {
107        // The arena always allocates at least MIN_ALLOC_SIZE bytes, so
108        // the effective size used for bump accounting must be rounded up.
109        let actual_size = node_size.max(MIN_ALLOC_SIZE);
110
111        // Frontier merge optimisation: if the dead node is right at the
112        // bump frontier, just rewind the bump pointer instead of entering
113        // the free-list.
114        let node_end = node.as_ptr() as usize + actual_size;
115        let bump_end = self.base.as_ptr() as usize + self.bump.get();
116        if node_end == bump_end {
117            // ★ Perfect rewind
118            self.bump
119                .set(node.as_ptr() as usize - self.base.as_ptr() as usize);
120            self.live_count.set(self.live_count.get().saturating_sub(1));
121            return;
122        }
123
124        // Record the hole for batch processing in finish_sweep
125        let offset = node.as_ptr() as usize - self.base.as_ptr() as usize;
126        holes.push(Hole {
127            offset,
128            size: actual_size,
129        });
130        self.live_count.set(self.live_count.get().saturating_sub(1));
131    }
132
133    /// Called at the end of sweep: sort, merge adjacent holes and rebuild the free-list.
134    pub(crate) fn finish_sweep(&self, holes: &mut Vec<Hole>) {
135        if self.live_count.get() == 0 {
136            // All dead → full reset, no free-list needed
137            self.bump.set(0);
138            self.free_head.set(None);
139            holes.clear();
140            return;
141        }
142
143        // 1. Sort by address (sweep order ≠ address order because
144        //    free-list reuse breaks the correspondence, so sort is mandatory).
145        holes.sort_by_key(|h| h.offset);
146
147        // 2. In-place adjacent merge via two-pointer compaction.
148        //    Avoids a second Vec allocation compared to the previous approach.
149        let merged_len = if holes.is_empty() {
150            0
151        } else {
152            let mut wi = 0;
153            for ri in 1..holes.len() {
154                if holes[wi].offset + holes[wi].size == holes[ri].offset {
155                    holes[wi].size += holes[ri].size;
156                } else {
157                    wi += 1;
158                    if wi != ri {
159                        holes.swap(wi, ri);
160                    }
161                }
162            }
163            wi + 1
164        };
165        holes.truncate(merged_len);
166
167        // 3. Rebuild the free-list (reverse order for correct head-insertion)
168        self.free_head.set(None);
169        for hole in holes.drain(..).rev() {
170            let ptr = unsafe { NonNull::new_unchecked(self.base.as_ptr().add(hole.offset)) };
171            unsafe { write_free_size(ptr, hole.size) };
172            unsafe { write_free_next(ptr, self.free_head.get()) };
173            self.free_head.set(Some(ptr));
174        }
175    }
176}
177
178impl Drop for GcArena {
179    fn drop(&mut self) {
180        let layout = Layout::from_size_align(self.capacity, 16).unwrap();
181        unsafe { std::alloc::dealloc(self.base.as_ptr(), layout) };
182    }
183}
184
185// ── FreeEntry read / write (re-uses dead GcHead space) ──────────────────
186//
187// Memory layout of a dead node (first 2 × pointer-width bytes):
188//   offset 0:          size (size_of::<usize> bytes, reuses attrs + partition area)
189//   offset size_of::<usize>: next (size_of::<usize> bytes, reuses first portion of weak_id)
190
191unsafe fn read_free_size(entry: NonNull<u8>) -> usize {
192    unsafe { *(entry.as_ptr().cast::<usize>()) }
193}
194
195unsafe fn write_free_size(entry: NonNull<u8>, size: usize) {
196    unsafe {
197        *(entry.as_ptr().cast::<usize>()) = size;
198    }
199}
200
201unsafe fn read_free_next(entry: NonNull<u8>) -> Option<NonNull<u8>> {
202    unsafe { *(entry.as_ptr().add(size_of::<usize>()).cast::<Option<NonNull<u8>>>()) }
203}
204
205unsafe fn write_free_next(entry: NonNull<u8>, next: Option<NonNull<u8>>) {
206    unsafe {
207        *(entry.as_ptr().add(size_of::<usize>()).cast::<Option<NonNull<u8>>>()) = next;
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    /// Helper: allocate a single u64 (8 bytes) and return its offset from base.
216    fn alloc_one(arena: &GcArena) -> Option<NonNull<u8>> {
217        arena.alloc(Layout::new::<u64>())
218    }
219
220    /// Helper: read a u64 at the given pointer.
221    unsafe fn read_u64(p: NonNull<u8>) -> u64 {
222        unsafe { *(p.as_ptr().cast::<u64>()) }
223    }
224
225    /// Helper: write a u64 at the given pointer.
226    unsafe fn write_u64(p: NonNull<u8>, val: u64) {
227        unsafe {
228            *(p.as_ptr().cast::<u64>()) = val;
229        }
230    }
231
232    // ── Bump allocation basics ──────────────────────────────────────────
233
234    #[test]
235    fn test_arena_new_and_bump() {
236        let arena = GcArena::new(1024).unwrap();
237        let p1 = alloc_one(&arena).unwrap();
238        let p2 = alloc_one(&arena).unwrap();
239        // Pointers must be distinct and within arena bounds
240        assert_ne!(p1, p2);
241        let base = arena.base.as_ptr() as usize;
242        assert!((p1.as_ptr() as usize) >= base);
243        assert!((p2.as_ptr() as usize) < (base + 1024));
244    }
245
246    #[test]
247    fn test_arena_exhaustion() {
248        let arena = GcArena::new(64).unwrap();
249        // Each alloc uses at least MIN_ALLOC_SIZE (16) bytes.
250        // 64 / 16 = 4 allocations fit exactly.
251        assert!(alloc_one(&arena).is_some());
252        assert!(alloc_one(&arena).is_some());
253        assert!(alloc_one(&arena).is_some());
254        assert!(alloc_one(&arena).is_some());
255        // The 5th should fail
256        assert!(alloc_one(&arena).is_none());
257    }
258
259    #[test]
260    fn test_bump_allocs_are_sequential_non_overlapping() {
261        let arena = GcArena::new(256).unwrap();
262        let p1 = alloc_one(&arena).unwrap();
263        let p2 = alloc_one(&arena).unwrap();
264        let p3 = alloc_one(&arena).unwrap();
265        // Bump pointers should be strictly increasing
266        assert!(p1.as_ptr() < p2.as_ptr());
267        assert!(p2.as_ptr() < p3.as_ptr());
268    }
269
270    // ── Free-list: collect holes and rebuild ───────────────────────────
271
272    /// Fill remaining bump space so subsequent allocations hit the free-list.
273    /// Advances bump directly without touching the free-list.
274    fn exhaust_bump(arena: &GcArena) {
275        let remaining = arena.capacity - arena.bump.get();
276        if remaining > 0 {
277            // Simulate N sequential MIN_ALLOC_SIZE bumps in one shot.
278            let count = remaining / MIN_ALLOC_SIZE;
279            arena.bump.set(arena.capacity);
280            arena
281                .live_count
282                .set(arena.live_count.get() + count);
283        }
284    }
285
286    #[test]
287    fn test_free_list_reuses_hole() {
288        let arena = GcArena::new(256).unwrap();
289        let p1 = alloc_one(&arena).unwrap();
290        let p2 = alloc_one(&arena).unwrap();
291        let _p3 = alloc_one(&arena).unwrap();
292
293        // "Sweep" p2: collect it as a hole
294        let mut holes = Vec::new();
295        arena.collect_hole(&mut holes, p2, 8);
296
297        // finish_sweep should rebuild the free-list
298        arena.finish_sweep(&mut holes);
299        assert!(holes.is_empty());
300
301        // Bump still has space, so bump-first order uses bump for the
302        // next alloc. Exhaust bump so the next alloc hits the free-list.
303        exhaust_bump(&arena);
304
305        // Now allocate again — it should come from the free-list,
306        // specifically from p2's old location.
307        let p_new = alloc_one(&arena).unwrap();
308        assert_eq!(p_new, p2, "free-list should reuse the hole at p2's address");
309    }
310
311    #[test]
312    fn test_free_list_two_adjacent_holes_merge() {
313        let arena = GcArena::new(256).unwrap();
314        let p1 = alloc_one(&arena).unwrap();
315        let p2 = alloc_one(&arena).unwrap();
316        let p3 = alloc_one(&arena).unwrap();
317        let _p4 = alloc_one(&arena).unwrap();
318
319        // Collect p2 and p3 (adjacent)
320        let mut holes = Vec::new();
321        arena.collect_hole(&mut holes, p2, 8);
322        arena.collect_hole(&mut holes, p3, 8);
323        assert_eq!(holes.len(), 2);
324
325        arena.finish_sweep(&mut holes);
326
327        // Exhaust bump so allocation hits the free-list.
328        exhaust_bump(&arena);
329
330        // The two adjacent 8-byte holes should be merged into one 16-byte hole.
331        // Allocating a 16-byte object should reuse the merged hole.
332        let big = arena
333            .alloc(Layout::from_size_align(16, 8).unwrap())
334            .unwrap();
335        assert_eq!(big, p2, "merged hole should cover p2..p3+8");
336    }
337
338    #[test]
339    fn test_free_list_three_adjacent_holes_merge() {
340        let arena = GcArena::new(256).unwrap();
341        let p1 = alloc_one(&arena).unwrap();
342        let p2 = alloc_one(&arena).unwrap();
343        let p3 = alloc_one(&arena).unwrap();
344        let p4 = alloc_one(&arena).unwrap();
345        let _p5 = alloc_one(&arena).unwrap();
346
347        // Collect p2, p3, p4 (all adjacent)
348        let mut holes = Vec::new();
349        arena.collect_hole(&mut holes, p2, 8);
350        arena.collect_hole(&mut holes, p3, 8);
351        arena.collect_hole(&mut holes, p4, 8);
352        arena.finish_sweep(&mut holes);
353
354        // Exhaust bump so allocation hits the free-list.
355        exhaust_bump(&arena);
356
357        // Allocate a 24-byte slot — should land on the merged triple hole
358        let big = arena
359            .alloc(Layout::from_size_align(24, 8).unwrap())
360            .unwrap();
361        assert_eq!(big, p2, "triple-merged hole should start at p2");
362    }
363
364    // ── Frontier rewind optimisation ───────────────────────────────────
365
366    #[test]
367    fn test_frontier_rewind_last_node() {
368        let arena = GcArena::new(256).unwrap();
369        let p1 = alloc_one(&arena).unwrap();
370        let p2 = alloc_one(&arena).unwrap();
371        let p3 = alloc_one(&arena).unwrap();
372
373        // Collect p3 (the last allocated node — right at bump frontier)
374        let mut holes = Vec::new();
375        arena.collect_hole(&mut holes, p3, 8);
376        // collect_hole should have recognised the frontier match and
377        // rewound bump instead of pushing to holes.
378        assert!(
379            holes.is_empty(),
380            "frontier rewind should not produce a hole"
381        );
382
383        // Bump should be back to what it was after p2
384        let p_new = alloc_one(&arena).unwrap();
385        assert_eq!(p_new, p3, "frontier rewind should allow reuse of p3's slot");
386    }
387
388    #[test]
389    fn test_frontier_rewind_chain() {
390        let arena = GcArena::new(256).unwrap();
391        let p1 = alloc_one(&arena).unwrap();
392        let p2 = alloc_one(&arena).unwrap();
393        let p3 = alloc_one(&arena).unwrap();
394        let p4 = alloc_one(&arena).unwrap();
395
396        // Collect p3 and p4 in reverse order (p4 is at frontier)
397        let mut holes = Vec::new();
398        arena.collect_hole(&mut holes, p4, 8); // frontier → rewind
399        arena.collect_hole(&mut holes, p3, 8); // now at new frontier → rewind
400        assert!(holes.is_empty(), "both should use frontier rewind");
401
402        // Bump back to after p2, allocate should reuse p3
403        let p_new = alloc_one(&arena).unwrap();
404        assert_eq!(p_new, p3, "frontier rewind chain works");
405    }
406
407    // ── Full reset ─────────────────────────────────────────────────────
408
409    #[test]
410    fn test_arena_full_reset() {
411        let arena = GcArena::new(256).unwrap();
412        let p1 = alloc_one(&arena).unwrap();
413        let p2 = alloc_one(&arena).unwrap();
414
415        // Collect both — live_count becomes 0
416        let mut holes = Vec::new();
417        arena.collect_hole(&mut holes, p1, 8);
418        arena.collect_hole(&mut holes, p2, 8);
419        assert_eq!(arena.live_count.get(), 0);
420
421        // finish_sweep should reset the arena entirely
422        arena.finish_sweep(&mut holes);
423        assert_eq!(arena.bump.get(), 0);
424        assert!(arena.free_head.get().is_none());
425
426        // Now allocations should start from the beginning (base)
427        let p_new1 = alloc_one(&arena).unwrap();
428        assert_eq!(p_new1, p1, "full reset reuses from base");
429    }
430
431    // ── Mixed: frontier rewind + holes ─────────────────────────────────
432
433    #[test]
434    fn test_mixed_frontier_and_holes() {
435        let arena = GcArena::new(256).unwrap();
436        let p1 = alloc_one(&arena).unwrap();
437        let p2 = alloc_one(&arena).unwrap();
438        let p3 = alloc_one(&arena).unwrap();
439        let p4 = alloc_one(&arena).unwrap();
440        let p5 = alloc_one(&arena).unwrap();
441
442        // p1, p3, p5 are dead (interleaved with live p2, p4)
443        // p5 is at the frontier → rewound
444        // p1 and p3 need to be recorded as holes
445        let mut holes = Vec::new();
446        arena.collect_hole(&mut holes, p5, 8); // frontier rewind
447        arena.collect_hole(&mut holes, p3, 8); // hole
448        arena.collect_hole(&mut holes, p1, 8); // hole
449        // p5 was frontier → no hole; p1 and p3 are holes
450        assert_eq!(holes.len(), 2);
451
452        arena.finish_sweep(&mut holes);
453        assert!(holes.is_empty());
454
455        // Exhaust bump space so allocs hit the free-list.
456        exhaust_bump(&arena);
457
458        // Now allocs should come from free-list (holes)
459        let n1 = alloc_one(&arena).unwrap();
460        // free_head → p1 (lowest addr) → p3 (highest addr) → None
461        // First alloc pops the head → p1
462        assert_eq!(n1, p1, "first free-list alloc should reuse p1");
463
464        let n2 = alloc_one(&arena).unwrap();
465        assert_eq!(n2, p3, "second free-list alloc should reuse p3");
466
467        // Now empty free-list, and bump is full → next alloc should fail
468        assert!(
469            alloc_one(&arena).is_none(),
470            "arena should be full after exhausting bump and free-list"
471        );
472    }
473
474    // ── Large object bypass ────────────────────────────────────────────
475
476    #[test]
477    fn test_large_allocs_return_none() {
478        // Arena of 1024 bytes; an allocation larger than that should fail
479        let arena = GcArena::new(1024).unwrap();
480        let large = arena.alloc(Layout::from_size_align(2048, 16).unwrap());
481        assert!(
482            large.is_none(),
483            "alloc larger than capacity should return None"
484        );
485    }
486
487    // ── Data integrity after arena reuse ───────────────────────────────
488
489    #[test]
490    fn test_data_integrity_after_free_list_reuse() {
491        let arena = GcArena::new(256).unwrap();
492        unsafe {
493            let p1 = alloc_one(&arena).unwrap();
494            write_u64(p1, 0xDEAD);
495            let p2 = alloc_one(&arena).unwrap();
496            write_u64(p2, 0xBEEF);
497
498            assert_eq!(read_u64(p1), 0xDEAD);
499            assert_eq!(read_u64(p2), 0xBEEF);
500
501            // Collect p1
502            let mut holes = Vec::new();
503            arena.collect_hole(&mut holes, p1, 8);
504            arena.finish_sweep(&mut holes);
505
506            // Exhaust bump so the next alloc hits the free-list.
507            exhaust_bump(&arena);
508
509            // Reuse p1's slot — write new data
510            let p_new = alloc_one(&arena).unwrap();
511            assert_eq!(p_new, p1);
512            write_u64(p_new, 0xCAFE);
513
514            // p2 should still have its data
515            assert_eq!(read_u64(p2), 0xBEEF);
516            // New data should be readable
517            assert_eq!(read_u64(p_new), 0xCAFE);
518        }
519    }
520}