tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 229TOML 101Markdown 82Shell 24052 Definitions

⎇ main llimphi-ui/src/alloc.rs Rust · 228 lines · 9.5 KB History Who touched what raw

chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos b3f12e136 · Sergio ·

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}