Skip to main content

ocas_atom/
workspace.rs

1//! Thread-local arena pool for high-churn computations.
2//!
3//! Normalization, rewriting, and other temporary-heavy computations
4//! repeatedly build and discard expression arenas. [`WorkspaceArena`]
5//! recycles [`Arena`] allocations through a thread-local pool (the
6//! RecycledAtom/Workspace pattern from Symbolica's `state.rs`), so
7//! steady-state workloads allocate no new memory between generations.
8//!
9//! # Usage
10//!
11//! ```
12//! use ocas_atom::workspace::WorkspaceArena;
13//! use ocas_atom::AtomArena;
14//!
15//! let ws = WorkspaceArena::acquire();
16//! let ctx = AtomArena::new(ws.arena());
17//! let x = ctx.var("x");
18//! let _ = ctx.add(&[x, ctx.num(1)]);
19//! // `ctx` must be dropped before `ws`; on drop, `ws` returns the arena
20//! // to the thread-local pool for the next computation.
21//! ```
22
23use std::cell::RefCell;
24
25use ocas_core::arena::Arena;
26
27/// Maximum number of arenas retained in each thread's pool. Excess
28/// arenas are dropped to bound peak memory.
29const MAX_POOLED: usize = 4;
30
31thread_local! {
32    #[allow(clippy::missing_const_for_thread_local)]
33    static ARENA_POOL: RefCell<Vec<Arena>> = RefCell::new(Vec::new());
34}
35
36/// A handle to an [`Arena`] borrowed from the thread-local pool.
37///
38/// On drop, the arena is reset (all allocations invalidated) and
39/// returned to the pool. Because the pool is thread-local, this type
40/// is `!Send`.
41pub struct WorkspaceArena {
42    arena: Option<Arena>,
43    // Make the handle !Send/!Sync: the pool is thread-local, so moving
44    // the handle across threads would return the arena to the wrong pool.
45    _not_send: std::marker::PhantomData<*const ()>,
46}
47
48impl WorkspaceArena {
49    /// Acquire an arena from the thread-local pool, or create a fresh
50    /// one if the pool is empty.
51    pub fn acquire() -> Self {
52        let arena = ARENA_POOL
53            .with(|pool| pool.borrow_mut().pop())
54            .unwrap_or_default();
55        Self {
56            arena: Some(arena),
57            _not_send: std::marker::PhantomData,
58        }
59    }
60
61    /// Access the pooled arena.
62    pub fn arena(&self) -> &Arena {
63        self.arena.as_ref().expect("arena present until drop")
64    }
65
66    /// Reset the arena in place, invalidating all values allocated so
67    /// far. References handed out before the reset must not be used
68    /// afterwards.
69    pub fn reset(&mut self) {
70        self.arena
71            .as_ref()
72            .expect("arena present until drop")
73            .reset();
74    }
75}
76
77impl Drop for WorkspaceArena {
78    fn drop(&mut self) {
79        if let Some(arena) = self.arena.take() {
80            // Return the arena to the pool in a clean state for the next
81            // computation.
82            arena.reset();
83            ARENA_POOL.with(|pool| {
84                let mut pool = pool.borrow_mut();
85                if pool.len() < MAX_POOLED {
86                    pool.push(arena);
87                }
88            });
89        }
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use crate::AtomArena;
97
98    #[test]
99    fn acquire_and_use() {
100        let ws = WorkspaceArena::acquire();
101        let ctx = AtomArena::new(ws.arena());
102        let x = ctx.var("x");
103        let expr = ctx.add(&[x, ctx.num(1)]);
104        assert_eq!(expr.to_string(), "x + 1");
105    }
106
107    #[test]
108    fn pool_reuses_arena_memory() {
109        // First generation: allocate and drop, returning the arena to the pool.
110        {
111            let ws = WorkspaceArena::acquire();
112            let ctx = AtomArena::new(ws.arena());
113            let x = ctx.var("x");
114            let _ = ctx.add(&[x, ctx.num(1)]);
115            drop(ctx);
116            // ws drops here, returning the arena to the pool.
117        }
118        // Second generation: an arena comes back from the pool (not a fresh
119        // allocation). We verify reuse semantically: the pool is non-empty
120        // after the first drop, and the second acquire pops from it.
121        ARENA_POOL.with(|pool| assert!(!pool.borrow().is_empty()));
122        let ws2 = WorkspaceArena::acquire();
123        let ctx2 = AtomArena::new(ws2.arena());
124        let y = ctx2.var("y");
125        let expr = ctx2.add(&[y, ctx2.num(2)]);
126        assert_eq!(expr.to_string(), "y + 2");
127    }
128
129    #[test]
130    fn reset_generations_are_independent() {
131        let mut ws = WorkspaceArena::acquire();
132        for round in 0..100 {
133            {
134                let ctx = AtomArena::new(ws.arena());
135                let x = ctx.var("x");
136                let expr = ctx.add(&[x, ctx.num(round)]);
137                assert_eq!(expr.to_string(), format!("x + {round}"));
138            }
139            ws.reset();
140        }
141    }
142
143    #[test]
144    fn pool_is_bounded() {
145        // Dropping more handles than MAX_POOLED must not grow the pool.
146        let handles: Vec<WorkspaceArena> = (0..16).map(|_| WorkspaceArena::acquire()).collect();
147        drop(handles);
148        ARENA_POOL.with(|pool| assert!(pool.borrow().len() <= MAX_POOLED));
149    }
150
151    #[test]
152    fn stress_many_generations() {
153        // 10k acquire/build/drop cycles; pool keeps memory bounded.
154        for i in 0..10_000u64 {
155            let ws = WorkspaceArena::acquire();
156            {
157                let ctx = AtomArena::new(ws.arena());
158                let x = ctx.var("x");
159                let mut acc = ctx.num(0);
160                for j in 0..(i % 50) {
161                    acc = ctx.add(&[acc, ctx.mul(&[x, ctx.num(j as i64)])]);
162                }
163                let _ = acc.to_string();
164            }
165        }
166        ARENA_POOL.with(|pool| assert!(pool.borrow().len() <= MAX_POOLED));
167    }
168}