tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos
1// alloc.rs — contador de asignaciones de heap, para el perfilador del Bloque 21
2// (`PLAN-NUCLEO-RETENIDO.md`). Responde la pregunta que el contador de µs no
3// puede responder: *cuántas veces por frame tocamos el allocator, y cuántos
4// bytes pedimos*. Es la línea base honesta antes de discutir arenas, pools o
5// reuso de buffers — sin este número, "bajamos las asignaciones" es fe.
6//
7// Cómo se enciende (dos caminos, ninguno cuesta nada apagado):
8//
9// 1. **Feature `perf-alloc`** de este crate: instala `ContadorAlloc` como
10// `#[global_allocator]` del programa entero. No hay que tocar la app.
11//
12// cargo run -p llimphi-ui --example stress_grid --release \
13// --features perf-alloc
14//
15// 2. **A mano**, si la app ya elige su allocator (jemalloc, mimalloc…) o si
16// preferís no meter una feature del runtime en el binario:
17//
18// #[global_allocator]
19// static A: llimphi_ui::alloc::ContadorAlloc = llimphi_ui::alloc::ContadorAlloc;
20//
21// Sin ninguno de los dos, los contadores quedan en cero y `LLIMPHI_PERF`
22// reporta `alloc n/a` en vez de mentir con ceros.
23//
24// Hay dos lecturas y contestan preguntas distintas: [`snapshot`] es del
25// PROCESO (lo que quiere el perfilador de un frame) y [`snapshot_hilo`] es del
26// HILO que la pide — la que hace falta para medir un tramo con otros hilos
27// asignando al lado, como en una suite de tests en paralelo.
28//
29// Costo cuando SÍ está instalado: dos `fetch_add` relajados y dos escrituras a
30// TLS por asignación. No es gratis — por eso va detrás de feature y no en el
31// camino por defecto.
32
33use std::alloc::{GlobalAlloc, Layout, System};
34use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
35
36static ASIGNACIONES: AtomicU64 = AtomicU64::new(0);
37static BYTES: AtomicU64 = AtomicU64::new(0);
38static LIBERACIONES: AtomicU64 = AtomicU64::new(0);
39
40// Los mismos tres contadores, pero **por hilo**. Existen porque los globales no
41// pueden medir un tramo mientras otro hilo asigna: un test que mida el delta de
42// `snapshot()` con el resto de la suite corriendo en paralelo lee el ruido de
43// los demás, que es exactamente el jitter del que se huye al contar en vez de
44// cronometrar. Con éstos, la medición es del hilo que la pide y sólo de él.
45//
46// `const { Cell::new(0) }` no es cosmético: un `thread_local!` con
47// inicialización perezosa **asigna** la primera vez que se toca, y esto corre
48// DENTRO del allocator. Con init constante y un tipo sin `Drop` no hay ni
49// asignación ni registro de destructor, así que no hay recursión posible.
50thread_local! {
51 static HILO_ASIGNACIONES: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
52 static HILO_BYTES: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
53 static HILO_LIBERACIONES: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
54}
55
56// `try_with` y no `with`: durante el desmonte de un hilo el acceso puede fallar,
57// y un panic ahí sería dentro del allocator. Con un tipo sin `Drop` no debería
58// pasar nunca, pero perder una cuenta es infinitamente preferible a abortar.
59#[inline]
60fn sumar_hilo(cuentas: &'static std::thread::LocalKey<std::cell::Cell<u64>>, n: u64) {
61 let _ = cuentas.try_with(|c| c.set(c.get().wrapping_add(n)));
62}
63
64#[inline]
65fn leer_hilo(cuentas: &'static std::thread::LocalKey<std::cell::Cell<u64>>) -> u64 {
66 cuentas.try_with(|c| c.get()).unwrap_or(0)
67}
68
69/// Allocator que cuenta y delega en el del sistema. Ver el encabezado del
70/// módulo para las dos formas de instalarlo.
71pub struct ContadorAlloc;
72
73unsafe impl GlobalAlloc for ContadorAlloc {
74 unsafe fn alloc(&self, l: Layout) -> *mut u8 {
75 ASIGNACIONES.fetch_add(1, Relaxed);
76 BYTES.fetch_add(l.size() as u64, Relaxed);
77 sumar_hilo(&HILO_ASIGNACIONES, 1);
78 sumar_hilo(&HILO_BYTES, l.size() as u64);
79 unsafe { System.alloc(l) }
80 }
81
82 unsafe fn alloc_zeroed(&self, l: Layout) -> *mut u8 {
83 ASIGNACIONES.fetch_add(1, Relaxed);
84 BYTES.fetch_add(l.size() as u64, Relaxed);
85 sumar_hilo(&HILO_ASIGNACIONES, 1);
86 sumar_hilo(&HILO_BYTES, l.size() as u64);
87 unsafe { System.alloc_zeroed(l) }
88 }
89
90 unsafe fn dealloc(&self, p: *mut u8, l: Layout) {
91 LIBERACIONES.fetch_add(1, Relaxed);
92 sumar_hilo(&HILO_LIBERACIONES, 1);
93 unsafe { System.dealloc(p, l) }
94 }
95
96 unsafe fn realloc(&self, p: *mut u8, l: Layout, nuevo: usize) -> *mut u8 {
97 // Un `realloc` es un viaje al allocator igual que un `alloc` (el
98 // `Vec::push` que duplica capacidad pasa por acá), así que cuenta como
99 // asignación. En bytes contamos sólo el CRECIMIENTO: si no, un `Vec`
100 // que se duplica N veces inflaría el total con memoria que ya teníamos.
101 ASIGNACIONES.fetch_add(1, Relaxed);
102 BYTES.fetch_add(nuevo.saturating_sub(l.size()) as u64, Relaxed);
103 sumar_hilo(&HILO_ASIGNACIONES, 1);
104 sumar_hilo(&HILO_BYTES, nuevo.saturating_sub(l.size()) as u64);
105 unsafe { System.realloc(p, l, nuevo) }
106 }
107}
108
109#[cfg(feature = "perf-alloc")]
110#[global_allocator]
111static CONTADOR_GLOBAL: ContadorAlloc = ContadorAlloc;
112
113/// Lectura acumulada del proceso: `(asignaciones, bytes pedidos, liberaciones)`.
114/// El perfilador toma dos y resta; el delta es lo que costó el tramo.
115#[inline]
116pub fn snapshot() -> (u64, u64, u64) {
117 (
118 ASIGNACIONES.load(Relaxed),
119 BYTES.load(Relaxed),
120 LIBERACIONES.load(Relaxed),
121 )
122}
123
124/// Lo mismo, pero **sólo de este hilo**: `(asignaciones, bytes, liberaciones)`.
125///
126/// Es la lectura que sirve para medir un tramo con la suite corriendo en
127/// paralelo — el delta de [`snapshot`] incluiría lo que asignen los demás
128/// hilos, y entonces el número volvería a tener el jitter que se quería evitar.
129#[inline]
130pub fn snapshot_hilo() -> (u64, u64, u64) {
131 (
132 leer_hilo(&HILO_ASIGNACIONES),
133 leer_hilo(&HILO_BYTES),
134 leer_hilo(&HILO_LIBERACIONES),
135 )
136}
137
138/// `true` si el contador está realmente instalado como allocator global. Se
139/// deduce de que haya contado aunque sea una asignación: para cuando corre el
140/// primer frame ya van miles. Sin esto no podríamos distinguir "cero
141/// asignaciones" (imposible hoy) de "nadie está contando".
142#[inline]
143pub fn instalado() -> bool {
144 ASIGNACIONES.load(Relaxed) > 0
145}
146
147// Los tests usan el contador COMO objeto (no como allocator global) y miden
148// DELTAS. Los deltas se toman de [`snapshot_hilo`] y no de [`snapshot`]: los
149// contadores globales son estáticos del proceso, así que dos tests en paralelo
150// se pisan —de hecho se pisaban, y por eso este módulo tenía un solo `#[test]`—.
151// Con los contadores por hilo cada test mide lo suyo y pueden convivir.
152//
153// Se saltan igual cuando la feature `perf-alloc` está encendida: ahí el
154// allocator es global y el runtime de libtest asigna en este mismo hilo entre
155// las dos lecturas.
156#[cfg(all(test, not(feature = "perf-alloc")))]
157mod tests {
158 use super::*;
159
160 #[test]
161 fn cuenta_alloc_dealloc_y_realloc() {
162 let l = Layout::from_size_align(4096, 8).unwrap();
163 let (a0, b0, d0) = snapshot_hilo();
164 unsafe {
165 let p = ContadorAlloc.alloc(l);
166 assert!(!p.is_null());
167 ContadorAlloc.dealloc(p, l);
168 }
169 let (a1, b1, d1) = snapshot_hilo();
170 assert_eq!(a1 - a0, 1, "una asignación");
171 assert_eq!(d1 - d0, 1, "una liberación");
172 assert_eq!(b1 - b0, 4096, "bytes pedidos");
173
174 let l = Layout::from_size_align(1024, 8).unwrap();
175 unsafe {
176 let p = ContadorAlloc.alloc(l);
177 let (a0, b0, _) = snapshot_hilo();
178 let p = ContadorAlloc.realloc(p, l, 3072);
179 let (a1, b1, _) = snapshot_hilo();
180 assert_eq!(a1 - a0, 1, "el realloc es un viaje al allocator");
181 assert_eq!(b1 - b0, 2048, "sólo el crecimiento, no los 3072");
182 // Encoger no debe restar ni desbordar el contador de bytes.
183 let l3 = Layout::from_size_align(3072, 8).unwrap();
184 let p = ContadorAlloc.realloc(p, l3, 512);
185 let (_, b2, _) = snapshot_hilo();
186 assert_eq!(b2, b1, "encoger no suma bytes");
187 ContadorAlloc.dealloc(p, Layout::from_size_align(512, 8).unwrap());
188 }
189 }
190
191 /// Lo que hace útil al contador por hilo: **lo que asigna otro hilo no
192 /// entra**. Sin esta propiedad, medir un tramo con la suite en paralelo
193 /// devuelve el ruido de los demás, que es justo lo que se quería evitar
194 /// contando en vez de cronometrando.
195 ///
196 /// Con brazo de control: primero se afirma que el contador de este hilo SÍ
197 /// ve lo propio. Sin eso, un cero podría significar «aislado» o «roto».
198 #[test]
199 fn el_contador_por_hilo_no_ve_lo_que_asigna_otro() {
200 let l = Layout::from_size_align(4096, 8).unwrap();
201
202 // Brazo de control: lo propio se cuenta.
203 let (a0, b0, _) = snapshot_hilo();
204 unsafe {
205 let p = ContadorAlloc.alloc(l);
206 ContadorAlloc.dealloc(p, l);
207 }
208 let (a1, b1, _) = snapshot_hilo();
209 assert_eq!(a1 - a0, 1, "el hilo tiene que verse a sí mismo");
210 assert_eq!(b1 - b0, 4096);
211
212 // Y lo ajeno no.
213 let antes = snapshot_hilo();
214 std::thread::spawn(move || unsafe {
215 for _ in 0..50 {
216 let p = ContadorAlloc.alloc(l);
217 ContadorAlloc.dealloc(p, l);
218 }
219 })
220 .join()
221 .unwrap();
222 assert_eq!(
223 snapshot_hilo(),
224 antes,
225 "las asignaciones del otro hilo se colaron en la cuenta de éste"
226 );
227 }
228}