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}