// alloc.rs — contador de asignaciones de heap, para el perfilador del Bloque 21 // (`PLAN-NUCLEO-RETENIDO.md`). Responde la pregunta que el contador de µs no // puede responder: *cuántas veces por frame tocamos el allocator, y cuántos // bytes pedimos*. Es la línea base honesta antes de discutir arenas, pools o // reuso de buffers — sin este número, "bajamos las asignaciones" es fe. // // Cómo se enciende (dos caminos, ninguno cuesta nada apagado): // // 1. **Feature `perf-alloc`** de este crate: instala `ContadorAlloc` como // `#[global_allocator]` del programa entero. No hay que tocar la app. // // cargo run -p llimphi-ui --example stress_grid --release \ // --features perf-alloc // // 2. **A mano**, si la app ya elige su allocator (jemalloc, mimalloc…) o si // preferís no meter una feature del runtime en el binario: // // #[global_allocator] // static A: llimphi_ui::alloc::ContadorAlloc = llimphi_ui::alloc::ContadorAlloc; // // Sin ninguno de los dos, los contadores quedan en cero y `LLIMPHI_PERF` // reporta `alloc n/a` en vez de mentir con ceros. // // Hay dos lecturas y contestan preguntas distintas: [`snapshot`] es del // PROCESO (lo que quiere el perfilador de un frame) y [`snapshot_hilo`] es del // HILO que la pide — la que hace falta para medir un tramo con otros hilos // asignando al lado, como en una suite de tests en paralelo. // // Costo cuando SÍ está instalado: dos `fetch_add` relajados y dos escrituras a // TLS por asignación. No es gratis — por eso va detrás de feature y no en el // camino por defecto. use std::alloc::{GlobalAlloc, Layout, System}; use std::sync::atomic::{AtomicU64, Ordering::Relaxed}; static ASIGNACIONES: AtomicU64 = AtomicU64::new(0); static BYTES: AtomicU64 = AtomicU64::new(0); static LIBERACIONES: AtomicU64 = AtomicU64::new(0); // Los mismos tres contadores, pero **por hilo**. Existen porque los globales no // pueden medir un tramo mientras otro hilo asigna: un test que mida el delta de // `snapshot()` con el resto de la suite corriendo en paralelo lee el ruido de // los demás, que es exactamente el jitter del que se huye al contar en vez de // cronometrar. Con éstos, la medición es del hilo que la pide y sólo de él. // // `const { Cell::new(0) }` no es cosmético: un `thread_local!` con // inicialización perezosa **asigna** la primera vez que se toca, y esto corre // DENTRO del allocator. Con init constante y un tipo sin `Drop` no hay ni // asignación ni registro de destructor, así que no hay recursión posible. thread_local! { static HILO_ASIGNACIONES: std::cell::Cell = const { std::cell::Cell::new(0) }; static HILO_BYTES: std::cell::Cell = const { std::cell::Cell::new(0) }; static HILO_LIBERACIONES: std::cell::Cell = const { std::cell::Cell::new(0) }; } // `try_with` y no `with`: durante el desmonte de un hilo el acceso puede fallar, // y un panic ahí sería dentro del allocator. Con un tipo sin `Drop` no debería // pasar nunca, pero perder una cuenta es infinitamente preferible a abortar. #[inline] fn sumar_hilo(cuentas: &'static std::thread::LocalKey>, n: u64) { let _ = cuentas.try_with(|c| c.set(c.get().wrapping_add(n))); } #[inline] fn leer_hilo(cuentas: &'static std::thread::LocalKey>) -> u64 { cuentas.try_with(|c| c.get()).unwrap_or(0) } /// Allocator que cuenta y delega en el del sistema. Ver el encabezado del /// módulo para las dos formas de instalarlo. pub struct ContadorAlloc; unsafe impl GlobalAlloc for ContadorAlloc { unsafe fn alloc(&self, l: Layout) -> *mut u8 { ASIGNACIONES.fetch_add(1, Relaxed); BYTES.fetch_add(l.size() as u64, Relaxed); sumar_hilo(&HILO_ASIGNACIONES, 1); sumar_hilo(&HILO_BYTES, l.size() as u64); unsafe { System.alloc(l) } } unsafe fn alloc_zeroed(&self, l: Layout) -> *mut u8 { ASIGNACIONES.fetch_add(1, Relaxed); BYTES.fetch_add(l.size() as u64, Relaxed); sumar_hilo(&HILO_ASIGNACIONES, 1); sumar_hilo(&HILO_BYTES, l.size() as u64); unsafe { System.alloc_zeroed(l) } } unsafe fn dealloc(&self, p: *mut u8, l: Layout) { LIBERACIONES.fetch_add(1, Relaxed); sumar_hilo(&HILO_LIBERACIONES, 1); unsafe { System.dealloc(p, l) } } unsafe fn realloc(&self, p: *mut u8, l: Layout, nuevo: usize) -> *mut u8 { // Un `realloc` es un viaje al allocator igual que un `alloc` (el // `Vec::push` que duplica capacidad pasa por acá), así que cuenta como // asignación. En bytes contamos sólo el CRECIMIENTO: si no, un `Vec` // que se duplica N veces inflaría el total con memoria que ya teníamos. ASIGNACIONES.fetch_add(1, Relaxed); BYTES.fetch_add(nuevo.saturating_sub(l.size()) as u64, Relaxed); sumar_hilo(&HILO_ASIGNACIONES, 1); sumar_hilo(&HILO_BYTES, nuevo.saturating_sub(l.size()) as u64); unsafe { System.realloc(p, l, nuevo) } } } #[cfg(feature = "perf-alloc")] #[global_allocator] static CONTADOR_GLOBAL: ContadorAlloc = ContadorAlloc; /// Lectura acumulada del proceso: `(asignaciones, bytes pedidos, liberaciones)`. /// El perfilador toma dos y resta; el delta es lo que costó el tramo. #[inline] pub fn snapshot() -> (u64, u64, u64) { ( ASIGNACIONES.load(Relaxed), BYTES.load(Relaxed), LIBERACIONES.load(Relaxed), ) } /// Lo mismo, pero **sólo de este hilo**: `(asignaciones, bytes, liberaciones)`. /// /// Es la lectura que sirve para medir un tramo con la suite corriendo en /// paralelo — el delta de [`snapshot`] incluiría lo que asignen los demás /// hilos, y entonces el número volvería a tener el jitter que se quería evitar. #[inline] pub fn snapshot_hilo() -> (u64, u64, u64) { ( leer_hilo(&HILO_ASIGNACIONES), leer_hilo(&HILO_BYTES), leer_hilo(&HILO_LIBERACIONES), ) } /// `true` si el contador está realmente instalado como allocator global. Se /// deduce de que haya contado aunque sea una asignación: para cuando corre el /// primer frame ya van miles. Sin esto no podríamos distinguir "cero /// asignaciones" (imposible hoy) de "nadie está contando". #[inline] pub fn instalado() -> bool { ASIGNACIONES.load(Relaxed) > 0 } // Los tests usan el contador COMO objeto (no como allocator global) y miden // DELTAS. Los deltas se toman de [`snapshot_hilo`] y no de [`snapshot`]: los // contadores globales son estáticos del proceso, así que dos tests en paralelo // se pisan —de hecho se pisaban, y por eso este módulo tenía un solo `#[test]`—. // Con los contadores por hilo cada test mide lo suyo y pueden convivir. // // Se saltan igual cuando la feature `perf-alloc` está encendida: ahí el // allocator es global y el runtime de libtest asigna en este mismo hilo entre // las dos lecturas. #[cfg(all(test, not(feature = "perf-alloc")))] mod tests { use super::*; #[test] fn cuenta_alloc_dealloc_y_realloc() { let l = Layout::from_size_align(4096, 8).unwrap(); let (a0, b0, d0) = snapshot_hilo(); unsafe { let p = ContadorAlloc.alloc(l); assert!(!p.is_null()); ContadorAlloc.dealloc(p, l); } let (a1, b1, d1) = snapshot_hilo(); assert_eq!(a1 - a0, 1, "una asignación"); assert_eq!(d1 - d0, 1, "una liberación"); assert_eq!(b1 - b0, 4096, "bytes pedidos"); let l = Layout::from_size_align(1024, 8).unwrap(); unsafe { let p = ContadorAlloc.alloc(l); let (a0, b0, _) = snapshot_hilo(); let p = ContadorAlloc.realloc(p, l, 3072); let (a1, b1, _) = snapshot_hilo(); assert_eq!(a1 - a0, 1, "el realloc es un viaje al allocator"); assert_eq!(b1 - b0, 2048, "sólo el crecimiento, no los 3072"); // Encoger no debe restar ni desbordar el contador de bytes. let l3 = Layout::from_size_align(3072, 8).unwrap(); let p = ContadorAlloc.realloc(p, l3, 512); let (_, b2, _) = snapshot_hilo(); assert_eq!(b2, b1, "encoger no suma bytes"); ContadorAlloc.dealloc(p, Layout::from_size_align(512, 8).unwrap()); } } /// Lo que hace útil al contador por hilo: **lo que asigna otro hilo no /// entra**. Sin esta propiedad, medir un tramo con la suite en paralelo /// devuelve el ruido de los demás, que es justo lo que se quería evitar /// contando en vez de cronometrando. /// /// Con brazo de control: primero se afirma que el contador de este hilo SÍ /// ve lo propio. Sin eso, un cero podría significar «aislado» o «roto». #[test] fn el_contador_por_hilo_no_ve_lo_que_asigna_otro() { let l = Layout::from_size_align(4096, 8).unwrap(); // Brazo de control: lo propio se cuenta. let (a0, b0, _) = snapshot_hilo(); unsafe { let p = ContadorAlloc.alloc(l); ContadorAlloc.dealloc(p, l); } let (a1, b1, _) = snapshot_hilo(); assert_eq!(a1 - a0, 1, "el hilo tiene que verse a sí mismo"); assert_eq!(b1 - b0, 4096); // Y lo ajeno no. let antes = snapshot_hilo(); std::thread::spawn(move || unsafe { for _ in 0..50 { let p = ContadorAlloc.alloc(l); ContadorAlloc.dealloc(p, l); } }) .join() .unwrap(); assert_eq!( snapshot_hilo(), antes, "las asignaciones del otro hilo se colaron en la cuenta de éste" ); } }