seq_core/memory_stats.rs
1//! Cross-thread memory statistics registry
2//!
3//! Provides visibility into arena memory usage across all worker threads.
4//! Each thread registers itself and updates its own slot with minimal overhead.
5//!
6//! # Design
7//!
8//! The challenge: Arena is thread-local, but diagnostics runs on a
9//! separate signal handler thread. We solve this with a global registry where
10//! each thread has an exclusive slot for its stats.
11//!
12//! ```text
13//! ┌─────────────────────────────────────────────────────────┐
14//! │ MemoryStatsRegistry (global) │
15//! ├─────────────────────────────────────────────────────────┤
16//! │ slots: [MemorySlot; MAX_THREADS] │
17//! │ │
18//! │ ┌──────────────────┐ ┌──────────────────┐ │
19//! │ │ Slot 0 (Thread A)│ │ Slot 1 (Thread B)│ ... │
20//! │ │ thread_id: u64 │ │ thread_id: u64 │ │
21//! │ │ arena_bytes: u64 │ │ arena_bytes: u64 │ │
22//! │ └──────────────────┘ └──────────────────┘ │
23//! └─────────────────────────────────────────────────────────┘
24//! ```
25//!
26//! # Performance
27//!
28//! - **Registration**: One-time CAS per thread (on first arena access)
29//! - **Updates**: Single atomic store per operation (~1-2 cycles, no contention)
30//! - **Reads**: Only during diagnostics (SIGQUIT), iterates all slots
31//!
32//! This maintains the "fast path stays fast" principle.
33
34use std::sync::OnceLock;
35use std::sync::atomic::{AtomicU64, Ordering};
36
37/// Maximum number of worker threads we can track
38/// May's default is typically fewer threads, but we allow headroom
39const MAX_THREADS: usize = 64;
40
41/// Statistics for a single thread's memory usage
42#[derive(Debug)]
43pub struct MemorySlot {
44 /// Thread ID (0 = slot is free)
45 pub thread_id: AtomicU64,
46 /// Arena allocated bytes
47 pub arena_bytes: AtomicU64,
48 /// Peak arena allocated bytes (high-water mark)
49 pub peak_arena_bytes: AtomicU64,
50}
51
52impl MemorySlot {
53 const fn new() -> Self {
54 Self {
55 thread_id: AtomicU64::new(0),
56 arena_bytes: AtomicU64::new(0),
57 peak_arena_bytes: AtomicU64::new(0),
58 }
59 }
60}
61
62/// Aggregated memory statistics across all threads
63#[derive(Debug, Clone, Copy)]
64pub struct AggregateMemoryStats {
65 pub active_threads: usize,
66 pub total_arena_bytes: u64,
67 pub total_peak_arena_bytes: u64,
68 pub overflow_count: u64,
69}
70
71/// Global registry for cross-thread memory statistics
72pub struct MemoryStatsRegistry {
73 slots: Box<[MemorySlot]>,
74 /// Count of threads that couldn't get a slot
75 pub overflow_count: AtomicU64,
76}
77
78/// Atomically raise `target` to `value` if `value` is larger (compare-and-max).
79fn atomic_max(target: &AtomicU64, value: u64) {
80 let mut current = target.load(Ordering::Relaxed);
81 while value > current {
82 match target.compare_exchange_weak(current, value, Ordering::Relaxed, Ordering::Relaxed) {
83 Ok(_) => break,
84 Err(c) => current = c,
85 }
86 }
87}
88
89impl MemoryStatsRegistry {
90 /// Create a new registry with the given capacity
91 fn new(capacity: usize) -> Self {
92 let slots: Vec<MemorySlot> = (0..capacity).map(|_| MemorySlot::new()).collect();
93 Self {
94 slots: slots.into_boxed_slice(),
95 overflow_count: AtomicU64::new(0),
96 }
97 }
98
99 /// Register a thread and get its slot index
100 ///
101 /// Returns Some(index) if a slot was claimed, None if registry is full.
102 /// Uses the current thread's ID as the identifier.
103 fn register(&self) -> Option<usize> {
104 let thread_id = current_thread_id();
105
106 // Scan for a free slot
107 for (idx, slot) in self.slots.iter().enumerate() {
108 // Try to claim this slot (CAS from 0 to thread_id)
109 if slot
110 .thread_id
111 .compare_exchange(0, thread_id, Ordering::AcqRel, Ordering::Relaxed)
112 .is_ok()
113 {
114 return Some(idx);
115 }
116 }
117
118 // Registry full
119 self.overflow_count.fetch_add(1, Ordering::Relaxed);
120 None
121 }
122
123 /// Update arena stats for a slot
124 ///
125 /// # Safety
126 /// Caller must own the slot (be the thread that registered it)
127 #[inline]
128 fn update_arena(&self, slot_idx: usize, arena_bytes: usize) {
129 if let Some(slot) = self.slots.get(slot_idx) {
130 let bytes = arena_bytes as u64;
131 slot.arena_bytes.store(bytes, Ordering::Relaxed);
132 // Raise the high-water mark (same compare-and-max as PEAK_STRANDS in scheduler.rs).
133 atomic_max(&slot.peak_arena_bytes, bytes);
134 }
135 }
136
137 /// Get aggregated memory statistics across all threads
138 pub fn aggregate_stats(&self) -> AggregateMemoryStats {
139 let mut total_arena_bytes: u64 = 0;
140 let mut total_peak_arena_bytes: u64 = 0;
141 let mut active_threads: usize = 0;
142
143 for slot in self.slots.iter() {
144 let thread_id = slot.thread_id.load(Ordering::Acquire);
145 if thread_id > 0 {
146 active_threads += 1;
147 total_arena_bytes += slot.arena_bytes.load(Ordering::Relaxed);
148 total_peak_arena_bytes += slot.peak_arena_bytes.load(Ordering::Relaxed);
149 }
150 }
151
152 AggregateMemoryStats {
153 active_threads,
154 total_arena_bytes,
155 total_peak_arena_bytes,
156 overflow_count: self.overflow_count.load(Ordering::Relaxed),
157 }
158 }
159}
160
161/// Global counter for generating unique thread IDs
162/// Starts at 1 because 0 means "empty slot"
163static NEXT_THREAD_ID: AtomicU64 = AtomicU64::new(1);
164
165// Thread-local storage for this thread's unique ID
166thread_local! {
167 static THIS_THREAD_ID: u64 = NEXT_THREAD_ID.fetch_add(1, Ordering::Relaxed);
168}
169
170/// Get a unique ID for the current thread
171///
172/// Uses a global atomic counter to guarantee uniqueness (no hash collisions).
173/// Thread IDs start at 1 and increment monotonically.
174fn current_thread_id() -> u64 {
175 THIS_THREAD_ID.with(|&id| id)
176}
177
178// Global registry instance
179static MEMORY_REGISTRY: OnceLock<MemoryStatsRegistry> = OnceLock::new();
180
181/// Get the global memory stats registry
182pub fn memory_registry() -> &'static MemoryStatsRegistry {
183 MEMORY_REGISTRY.get_or_init(|| MemoryStatsRegistry::new(MAX_THREADS))
184}
185
186// Thread-local slot index (cached after first registration)
187thread_local! {
188 static SLOT_INDEX: std::cell::Cell<Option<usize>> = const { std::cell::Cell::new(None) };
189}
190
191/// Get or register the current thread's slot index
192///
193/// Returns Some(index) if registered (or already was), None if registry is full.
194pub fn get_or_register_slot() -> Option<usize> {
195 SLOT_INDEX.with(|cell| {
196 if let Some(idx) = cell.get() {
197 Some(idx)
198 } else {
199 let idx = memory_registry().register();
200 cell.set(idx);
201 idx
202 }
203 })
204}
205
206/// Update arena stats for the current thread
207///
208/// Call this after arena operations to keep stats current.
209#[inline]
210pub fn update_arena_stats(arena_bytes: usize) {
211 if let Some(idx) = SLOT_INDEX.with(|cell| cell.get()) {
212 memory_registry().update_arena(idx, arena_bytes);
213 }
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219
220 #[test]
221 fn test_registry_basic() {
222 let registry = MemoryStatsRegistry::new(4);
223
224 // Register should succeed
225 let slot = registry.register();
226 assert!(slot.is_some());
227 let idx = slot.unwrap();
228
229 // Update stats
230 registry.update_arena(idx, 1024);
231
232 // Aggregate should reflect our updates
233 let stats = registry.aggregate_stats();
234 assert_eq!(stats.active_threads, 1);
235 assert_eq!(stats.total_arena_bytes, 1024);
236 }
237
238 #[test]
239 fn test_registry_overflow() {
240 let registry = MemoryStatsRegistry::new(2);
241
242 // Fill up the registry from different "threads" (simulated)
243 // Note: In real usage, each thread gets one slot
244 // Here we just test the CAS logic
245 assert!(registry.register().is_some());
246 assert!(registry.register().is_some());
247
248 // Third registration should fail (we're on the same thread, so it won't
249 // actually fail - but if we had 3 threads, the 3rd would fail)
250 // For now, just verify overflow_count is accessible
251 assert_eq!(registry.overflow_count.load(Ordering::Relaxed), 0);
252 }
253
254 #[test]
255 fn test_thread_local_slot() {
256 // First call should register (or return cached if already registered)
257 let slot1 = get_or_register_slot();
258
259 // Second call should return same value (cached)
260 let slot2 = get_or_register_slot();
261 assert_eq!(slot1, slot2);
262
263 // If registration succeeded, slot should be Some
264 // If registry was full, slot is None (acceptable in parallel test execution)
265 // Either way, the caching behavior is correct
266 }
267
268 #[test]
269 fn test_update_helpers() {
270 // Try to register (may fail if registry full from parallel tests)
271 let slot = get_or_register_slot();
272
273 if slot.is_some() {
274 // Update stats
275 update_arena_stats(2048);
276
277 // Verify via aggregate
278 let stats = memory_registry().aggregate_stats();
279 assert!(stats.total_arena_bytes >= 2048); // May have other test data
280 }
281 // If slot is None, registry was full - that's OK for this test
282 }
283
284 #[test]
285 fn test_concurrent_registration() {
286 use std::thread;
287
288 // Spawn multiple threads that each register and update stats
289 let handles: Vec<_> = (0..4)
290 .map(|i| {
291 thread::spawn(move || {
292 let slot = get_or_register_slot();
293 if slot.is_some() {
294 // Each thread sets a unique arena value
295 update_arena_stats(1000 * (i + 1));
296 }
297 slot.is_some()
298 })
299 })
300 .collect();
301
302 // Wait for all threads and count successful registrations
303 let mut registered_count = 0;
304 for h in handles {
305 if h.join().unwrap() {
306 registered_count += 1;
307 }
308 }
309
310 // Verify aggregate stats reflect the registrations
311 let stats = memory_registry().aggregate_stats();
312 // active_threads includes all threads that have registered (including test threads)
313 assert!(stats.active_threads >= registered_count);
314 }
315
316 #[test]
317 fn test_thread_ids_are_unique() {
318 use std::collections::HashSet;
319 use std::sync::{Arc, Mutex};
320 use std::thread;
321
322 let ids = Arc::new(Mutex::new(HashSet::new()));
323
324 let handles: Vec<_> = (0..8)
325 .map(|_| {
326 let ids = Arc::clone(&ids);
327 thread::spawn(move || {
328 let id = current_thread_id();
329 ids.lock().unwrap().insert(id);
330 id
331 })
332 })
333 .collect();
334
335 for h in handles {
336 h.join().unwrap();
337 }
338
339 // All thread IDs should be unique
340 let unique_count = ids.lock().unwrap().len();
341 assert_eq!(unique_count, 8, "Thread IDs should be unique");
342 }
343}