tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ main llimphi-compositor/src/render/retenido.rs Rust · 281 líneas · 12.6 KB Historia Quién tocó qué crudo

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

1use super::*;
2
3// ─────────────────────────────────────────────────────────────────────────
4// Bloque 24 — Layout retenido (mount incremental). El único motivo por el que
5// llimphi no aprovecha el caché de taffy es el `clear()` por frame (ids nuevos =
6// caché fría). `mount_incremental` reusa los `NodeId` del frame anterior EN EL
7// MISMO orden pre-orden cuando la **forma** del árbol no cambió, y sólo dirtea
8// lo que cambió (estilo → set_style dentro de mount_recursive; medida de texto →
9// mark_dirty aquí), dejando limpio el resto para que taffy saltee su re-solve.
10//
11// Correctitud demostrada por el arnés de equivalencia (fuzz retenido==fresco).
12// Fail-safe: si la forma cambió, cae a `clear()`+rebuild = camino histórico.
13// ─────────────────────────────────────────────────────────────────────────
14
15/// Estado retenido entre frames para el layout incremental. Opaco para el
16/// runtime: lo guarda y lo pasa de vuelta. Ver [`mount_incremental`].
17#[derive(Clone)]
18pub struct RetainedLayout {
19    root: NodeId,
20    /// `NodeId` en pre-orden (índice = orden en `Mounted::nodes`).
21    ids: Vec<NodeId>,
22    /// Cantidad de hijos por nodo, en pre-orden — la **firma de forma**.
23    child_counts: Vec<usize>,
24    /// Firma de medida por nodo: `Some(h)` compara por hash; `None` = "siempre
25    /// re-medir" (nodos con `spans`, conservador). Ver [`measure_sig_of`].
26    measure_sig: Vec<Option<u64>>,
27    /// Los dos búferes de trabajo del frame en curso, guardados acá **para no
28    /// pedirlos de nuevo cada cuadro**.
29    ///
30    /// La firma de forma y la de medida se calculan enteras en cada frame para
31    /// compararlas contra las del anterior, y hasta 2026-08-04 eran dos `Vec`
32    /// frescos por frame — a 200k nodos, 1,6 MB y 3,2 MB que iban y venían del
33    /// allocator sin que su contenido sobreviviera al cuadro. Ahora se calculan
34    /// acá dentro y se intercambian con las buenas (`mem::swap`), así el bloque
35    /// que queda libre es el que se reusa la próxima vez. Es el mismo lever del
36    /// `mount_pool` y del `layout_pool`, aplicado donde faltaba.
37    scratch_counts: Vec<usize>,
38    scratch_sig: Vec<Option<u64>>,
39    /// La pila de ids que comparte todo el recorrido del mount — ver
40    /// [`mount_recursive`]. Vive acá por lo mismo que las otras dos: para que su
41    /// bloque sobreviva al frame.
42    scratch_ids: Vec<NodeId>,
43}
44
45impl RetainedLayout {
46    /// `NodeId` raíz del árbol retenido (para `compute`).
47    pub fn root(&self) -> NodeId {
48        self.root
49    }
50
51    /// El envase vacío del primer frame. No es `Default` a propósito: un
52    /// `RetainedLayout` sin árbol detrás no es un estado válido para reusar, y
53    /// el único que puede fabricarlo es el mount, que sabe distinguirlo con su
54    /// `tenia_prev`.
55    fn vacio() -> Self {
56        Self {
57            root: NodeId::new(0),
58            ids: Vec::new(),
59            child_counts: Vec::new(),
60            measure_sig: Vec::new(),
61            scratch_counts: Vec::new(),
62            scratch_sig: Vec::new(),
63            scratch_ids: Vec::new(),
64        }
65    }
66}
67
68/// Firma de medida de un nodo: `Some(0)` = no lo mide taffy (sizing por Style o
69/// texto con `runs`, dimensionado por el caller) → nunca necesita re-medida.
70/// `Some(hash)` = texto uniforme, compara por hash de los campos que afectan el
71/// shaping/line-break (el `color` NO entra). `None` = texto con `spans` →
72
73/// El hasher de las memoizaciones: **determinista y sin sistema operativo**.
74///
75/// `std::collections::hash_map::DefaultHasher` no existe sin `std`, y el
76/// `DefaultHashBuilder` de hashbrown va sembrado al azar por proceso. Ninguno de
77/// los dos sirve acá: estos hashes comparan el MISMO subárbol entre fotogramas,
78/// así que tienen que dar lo mismo siempre. `FixedState` lo garantiza.
79pub(crate) fn hasher_memo() -> impl core::hash::Hasher {
80    use core::hash::BuildHasher;
81    foldhash::fast::FixedState::default().build_hasher()
82}
83
84/// conservador: se re-mide siempre (spans son raros; evita un sig frágil).
85pub(crate) fn measure_sig_of<Msg>(node: &MountedNode<Msg>) -> Option<u64> {
86    let Some(t) = node.text.as_ref() else {
87        return Some(0);
88    };
89    if t.runs.is_some() {
90        return Some(0);
91    }
92    if t.spans.is_some() {
93        return None;
94    }
95    let mut h = hasher_memo();
96    t.content.hash(&mut h);
97    t.size_px.to_bits().hash(&mut h);
98    (t.alignment as u8).hash(&mut h);
99    t.italic.hash(&mut h);
100    t.weight.to_bits().hash(&mut h);
101    t.line_height.to_bits().hash(&mut h);
102    t.max_lines.hash(&mut h);
103    t.ellipsis.hash(&mut h);
104    t.font_family.hash(&mut h);
105    t.letter_spacing.to_bits().hash(&mut h);
106    t.word_spacing.to_bits().hash(&mut h);
107    t.no_wrap.hash(&mut h);
108    t.overflow_wrap.hash(&mut h);
109    t.font_features.hash(&mut h);
110    t.font_variations.hash(&mut h);
111    t.font_width.to_bits().hash(&mut h);
112    Some(h.finish())
113}
114
115/// Firma de forma del `View` (cantidad de hijos por nodo, en pre-orden). Es una
116/// pasada read-only que NO consume el árbol; se compara contra la retenida para
117/// decidir reuso vs rebuild.
118/// Contrapeso del reciclado de buffers: si el árbol se achicó **mucho** (una app
119/// que pasa de una tabla de 200k filas a una pantalla vacía), conservar la
120/// capacidad vieja sería quedarse con cientos de MB tomados para siempre. Se
121/// encoge sólo cuando sobra más de 4× y se deja 2× de margen, así una UI que
122/// oscila alrededor de un tamaño no rebota entre encoger y volver a crecer.
123pub(crate) fn encoger_si_sobra<Msg>(nodes: &mut Vec<MountedNode<Msg>>) {
124    let n = nodes.len().max(1);
125    if nodes.capacity() > 4 * n {
126        nodes.shrink_to(2 * n);
127    }
128}
129
130pub(crate) fn view_child_counts<Msg>(v: &View<Msg>, out: &mut Vec<usize>) {
131    out.push(v.children.len());
132    for c in &v.children {
133        view_child_counts(c, out);
134    }
135}
136
137/// Monta `view` reusando el layout retenido cuando la **forma** no cambió. En
138/// ese caso NO se limpia el árbol taffy: se reusan los `NodeId`, se re-estila
139/// sólo lo cambiado y se marcan dirty los textos cuya medida cambió — así taffy
140/// cachea el resto. Si la forma cambió (o no hay estado previo), cae a
141/// `clear()`+rebuild (idéntico al camino histórico). Devuelve el `Mounted`, el
142/// nuevo `RetainedLayout` (para el próximo frame) y si hubo reuso.
143pub fn mount_incremental<Msg: Clone>(
144    layout: &mut LayoutTree,
145    view: View<Msg>,
146    prev: Option<&RetainedLayout>,
147) -> (Mounted<Msg>, RetainedLayout, bool) {
148    // Clona el retenido porque la variante que recicla lo consume. No es una
149    // regresión: esta fachada ya pagaba tres `Vec` nuevos por llamada (la firma
150    // de forma, la de medida y un `ids.clone()`), que es exactamente lo que
151    // cuesta el clon. Quien quiera el ahorro usa la variante `_reciclando`, que
152    // es lo que hace el runtime.
153    mount_incremental_reciclando(layout, view, prev.cloned(), None)
154}
155
156/// Igual que [`mount_incremental`], pero **reusando los buffers** del `Mounted`
157/// del frame anterior en vez de pedir otros nuevos.
158///
159/// Por qué existe (Bloque 21, baseline de allocs 2026-07-27): el heap por frame
160/// es `nodos × sizeof(MountedNode)` — 432 MB por frame con 200k nodos, en **una
161/// sola** asignación gigante. `clear()` conserva la capacidad, así que el frame
162/// siguiente escribe encima en vez de pedirle al allocator otro bloque de ese
163/// tamaño. No ahorra las escrituras (hay que llenar los 2.160 B por nodo igual),
164/// ahorra el `malloc`/`free`: a ese porte lo sirve `mmap`, y cada frame se
165/// pagaba en fallos de página y zeroing del kernel. De paso baja el **pico** de
166/// memoria, porque antes el buffer viejo seguía vivo mientras se llenaba el
167/// nuevo — dos árboles enteros en RAM a la vez.
168///
169/// El `Mounted` que se pasa queda consumido: sus nodos se dropean acá (el mismo
170/// trabajo que antes hacía el drop del frame viejo, sólo que adelantado). Pasarle
171/// `None` da el comportamiento histórico exacto.
172pub fn mount_incremental_reciclando<Msg: Clone>(
173    layout: &mut LayoutTree,
174    view: View<Msg>,
175    prev: Option<RetainedLayout>,
176    reciclado: Option<Mounted<Msg>>,
177) -> (Mounted<Msg>, RetainedLayout, bool) {
178    // El retenido entra **por valor** para poder escribir encima de sus búferes
179    // en vez de devolver tres `Vec` nuevos por frame. `tenia_prev` conserva la
180    // distinción que antes daba el `Option`: sin estado previo no hay reuso
181    // posible, aunque el default traiga los vectores vacíos.
182    let tenia_prev = prev.is_some();
183    let mut ret = prev.unwrap_or_else(RetainedLayout::vacio);
184    let mut child_counts = core::mem::take(&mut ret.scratch_counts);
185    child_counts.clear();
186    view_child_counts(&view, &mut child_counts);
187    let can_reuse = tenia_prev && ret.child_counts == child_counts;
188
189    let (mut nodes, mut text_measures, mut intrinsic_measures) = match reciclado {
190        Some(m) => {
191            let Mounted { mut nodes, mut text_measures, mut intrinsic_measures, .. } = m;
192            nodes.clear();
193            text_measures.clear();
194            intrinsic_measures.clear();
195            (nodes, text_measures, intrinsic_measures)
196        }
197        None => (Vec::new(), hashbrown::HashMap::new(), hashbrown::HashMap::new()),
198    };
199
200    // La firma de medida del frame en curso, sobre el búfer reusado. Se llena
201    // en los dos caminos y al final se intercambia con la buena, así el bloque
202    // que queda suelto es el que se reusa el frame que viene.
203    let mut measure_sig = core::mem::take(&mut ret.scratch_sig);
204    measure_sig.clear();
205    let mut ids_scratch = core::mem::take(&mut ret.scratch_ids);
206    ids_scratch.clear();
207    if can_reuse {
208        // Reuso: sin clear. `set_style` se hace dentro de mount_recursive donde
209        // el estilo cambió (deja limpio el resto).
210        //
211        // `ret.ids` se presta inmutable acá y NO se reconstruye después: antes
212        // el retenido nuevo lo clonaba entero (`p.ids.clone()`, 1,6 MB por frame
213        // a 200k nodos) para guardar exactamente lo mismo que ya tenía.
214        let root = mount_recursive(
215            layout,
216            view,
217            &mut nodes,
218            &mut text_measures,
219            &mut intrinsic_measures,
220            &MountPlan::Reuse(&ret.ids),
221            &mut ids_scratch,
222        );
223        // Post-pasada de medida: un texto puede cambiar de tamaño sin cambiar su
224        // `Style` (el contenido no está en el Style). Marcamos dirty esos nodos
225        // para que taffy los re-mida; el resto queda cacheado.
226        measure_sig.reserve(nodes.len());
227        for (i, n) in nodes.iter().enumerate() {
228            let sig = measure_sig_of(n);
229            let prev_sig = ret.measure_sig.get(i).copied().flatten();
230            // `None` (spans) ⇒ siempre dirty. `Some(h)` ⇒ dirty si cambió.
231            let dirty = match sig {
232                None => true,
233                Some(h) => prev_sig != Some(h),
234            };
235            if dirty {
236                let _ = layout.inner_mut().mark_dirty(n.id);
237            }
238            measure_sig.push(sig);
239        }
240        ret.root = root;
241        ret.scratch_ids = ids_scratch;
242        intercambiar_retenido(&mut ret, child_counts, measure_sig);
243        encoger_si_sobra(&mut nodes);
244        (Mounted { root, nodes, text_measures, intrinsic_measures }, ret, true)
245    } else {
246        // Rebuild fresco (camino histórico). NodeIds nuevos.
247        layout.clear();
248        let root = mount_recursive(
249            layout,
250            view,
251            &mut nodes,
252            &mut text_measures,
253            &mut intrinsic_measures,
254            &MountPlan::Fresh,
255            &mut ids_scratch,
256        );
257        ret.ids.clear();
258        ret.ids.extend(nodes.iter().map(|n| n.id));
259        measure_sig.extend(nodes.iter().map(measure_sig_of));
260        ret.root = root;
261        ret.scratch_ids = ids_scratch;
262        intercambiar_retenido(&mut ret, child_counts, measure_sig);
263        encoger_si_sobra(&mut nodes);
264        (Mounted { root, nodes, text_measures, intrinsic_measures }, ret, false)
265    }
266}
267
268/// Deja las firmas nuevas como las buenas y las viejas como búferes de trabajo.
269/// Los cuatro `Vec` involucrados son los mismos dos bloques de memoria dando
270/// vueltas frame tras frame — que es todo el punto.
271pub(crate) fn intercambiar_retenido(
272    ret: &mut RetainedLayout,
273    mut counts: Vec<usize>,
274    mut sig: Vec<Option<u64>>,
275) {
276    core::mem::swap(&mut ret.child_counts, &mut counts);
277    core::mem::swap(&mut ret.measure_sig, &mut sig);
278    ret.scratch_counts = counts;
279    ret.scratch_sig = sig;
280}
281