gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1574Shell 204Rust 111Markdown 993763 Definiciones

tasas: T19 — el sandbox apila una capa overlay por dep, y lo caro no es lo que parece

Nadie había medido qué cuesta el apilado de capas de `bwrap_args`. El 95% de las
recetas (1 108 de 1 171) construye con las deps APILADAS, hasta ~27.

Un lookup que FALLA cuesta +1 925 ns por capa (a 64 capas, 124 µs = 1 580 syscalls
nulas), pero se paga UNA VEZ POR RUTA: la dentry fusionada queda cacheada y el
montaje dura toda la fase. Un `cc -O2 -c` toca 381 rutas distintas que fallan ⇒
2,9 ms por fase a 24 capas, la misma décima de por ciento que la jaula de T17/T18.

⇒ `merge_deps_layer` no es una optimización de velocidad: sólo se pagaría sola a
partir de ~35 200 primeros fallos en una misma fase. Es el rodeo de un muro, y el
muro quedó bisecado al byte: `mount(2)` monta 41 capas (4 059 B de opciones) y
falla en 42 con un `ENOENT` que miente. El `mount(8)` de util-linux se rinde entre
8 y 12, así que bisecarlo desde la shell da un muro FALSO.

Consecuencias anotadas en sandbox.rs: no bajar el umbral «para que vaya más
rápido», y podar `.dmerge` es gratis en rendimiento. Y hay puerta medida: la API
de montaje nueva (`fsconfig lowerdir+`) monta las 64 capas que `mount(2)` rechaza.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACUcwo9mZsE5ocYVE9npih

1df77985fadaad61c171dabb3508104dd4c93922 · por Sergio · (hace 1 mes) · padre b4a7235c5 · ver el árbol en este commit

Delta semántico · 30

docs/evidencia/tasas-kernel-2026-08-29/bench_overlay.c

docs/evidencia/tasas-kernel-2026-08-29/mnt2.c

  • nueva def main pub

5 archivos · +888 −1

diff --git a/crates/hammer-build/src/sandbox.rs b/crates/hammer-build/src/sandbox.rs
index b9bca334..60a551fa 100644
--- a/crates/hammer-build/src/sandbox.rs
+++ b/crates/hammer-build/src/sandbox.rs
@@ -356,6 +356,23 @@ impl Sandbox {
         // La única forma de escalar a muchas deps es FUNDIRLAS en UN solo directorio real (hardlinks,
         // `cp -al`, last-wins = misma semántica que el apilado overlay) y pasar esa ÚNICA capa ⇒
         // lowerdir = rootfs + 1 merge, constante sin importar cuántas deps haya.
+        //
+        // MEDIDO (SDD 25 T19, `docs/evidencia/tasas-kernel-2026-08-29/overlay.txt`), y conviene
+        // leerlo antes de tocar este umbral:
+        //   · El muro es de UNA PÁGINA EXACTA: `mount(2)` acepta 41 capas del store (4 059 B de
+        //     opciones) y falla en 42 (4 158 B) con `ENOENT` — el kernel no dice «demasiado largo».
+        //     (El `mount(8)` de util-linux se rinde mucho antes; bisecarlo desde la shell miente.)
+        //   · Este umbral dispara a las ~27 deps ⇒ factor 1,5 de margen, y es estructural: el muro
+        //     va como ~4002/(L+1) y el umbral como 3000/(L+16), cociente ≥1,33 para cualquier L.
+        //     No hay que recalibrarlo si el store se muda.
+        //   · Fundir NO es una optimización de velocidad. El overlay cobra por PRIMER toque de cada
+        //     ruta (+1 925 ns por capa en un lookup que falla; ~660 ns planos si la ruta ya se
+        //     tocó), y el montaje dura toda la fase: un compilado toca 381 rutas distintas que
+        //     fallan ⇒ 2,9 ms por fase a 24 capas. Fundir 24 deps reales cuesta 264 ms, o sea que
+        //     sólo se pagaría sola a partir de ~35 200 primeros fallos en una misma fase.
+        //   ⇒ NO bajar el umbral «para que vaya más rápido»: no gana nada y engorda `.dmerge`, que
+        //     retiene por hardlink los artefactos que borres del store. Y por lo mismo, PODAR
+        //     `.dmerge` es gratis en rendimiento.
         let total_len: usize = self.deps.iter().map(|d| d.as_os_str().len() + 16).sum();
         if total_len > 3000 && self.deps.len() > 1 {
             if let Some(merged) = self.merge_deps_layer() {
diff --git a/docs/25-tasas-del-kernel.md b/docs/25-tasas-del-kernel.md
index 1570b685..94091ead 100644
--- a/docs/25-tasas-del-kernel.md
+++ b/docs/25-tasas-del-kernel.md
@@ -43,6 +43,15 @@ El espejo de este documento para el lado tawasuyu es
    una clausura fichero a fichero. Sobre un compilado entero son **0,23–0,31%**: una décima parte
    del presupuesto de <5% de SDD 16. Lo caro de enjaular sigue siendo el ~1,09 ms por proceso de
    los namespaces (T14).
+7. **El sandbox apila una capa overlay por dep, y eso NO es lo caro** (T19, medido 2026-09-01):
+   un lookup que FALLA cuesta +1 925 ns por capa —a 64 capas, 124 µs, mil quinientas syscalls nulas—
+   pero **se paga una sola vez por ruta**: la dentry fusionada queda cacheada y el montaje dura toda
+   la fase. Un compilado toca 381 rutas distintas que fallan ⇒ **2,9 ms por fase**, la misma décima
+   de por ciento que la jaula. ⇒ **fundir las deps en una capa (`.dmerge`) no es una optimización,
+   es el rodeo de un muro**: `mount(2)` monta 41 capas (4 059 B de opciones) y falla en 42, con un
+   `ENOENT` que miente. La API de montaje nueva (`fsconfig lowerdir+`) monta las 64 y **quitaría
+   `.dmerge` de en medio** (§7-H11).
+
 6. **Cuando `/proc` sí se sondea entero, hay algo cerca de 7× más barato** (§9.5, medido
    2026-08-31): un iterador BPF `task` saca la misma foto **al precio de listar el directorio**
    —1,75× el piso de `readdir` contra 12,1× de `/proc`—. Su portón es `DEBUG_INFO_BTF`, que sigue
@@ -377,7 +386,113 @@ lecturas, el grano fino de harkaq gasta menos de la décima parte del presupuest
 y lo caro de enjaular sigue siendo lo que ya decía T14: el ~1,09 ms por proceso de los namespaces,
 que es diez veces todo esto junto.
 
-## 3. Las seis sorpresas
+### T19 · El sandbox apila una capa overlay por dep — y lo caro no es lo que parece
+
+T17/T18 midieron lo que harkaq pone DENTRO del sandbox. Esto es lo que pone el sandbox mismo:
+`crates/hammer-build/src/sandbox.rs` monta el rootfs Alpine como capa overlay de arriba y **una capa
+más por cada dependencia de build** debajo. Sólo cuando la suma de las rutas se acerca al tope las
+funde en una sola capa de hardlinks (`merge_deps_layer`, el `.dmerge` del store). Con el store en
+`/mnt/vvv/hammer/store` ese umbral cae en las **~27 deps**, así que **1 108 de las 1 171 recetas del
+corpus (95%) construyen con las capas APILADAS**, hasta 27 de ellas. Nadie había medido qué cuesta
+esa N. Medido con `bench_overlay.c` → `overlay.txt` (2026-09-01, mismo `momento`, 7 rondas,
+mínimos; los cocientes son todos de una misma corrida).
+
+El banco monta y desmonta el overlay en **cada ronda**, así que las dentries del overlay están
+frías en cada medida y las del filesystem de abajo, calientes: lo que se aísla es el paseo del
+overlay por sus capas.
+
+| N capas | acierto en la capa 0 | acierto en la capa N-1 | **FALLO** | montar |
+|---|---|---|---|---|
+| 1 | 4 068 ns | — | 3 200 ns | 152 µs |
+| 2 | 4 723 | 4 630 | 4 919 | 199 µs |
+| 8 | 4 205 | 6 209 | 15 867 | 195 µs |
+| 16 | 4 970 | 9 330 | 30 205 | 219 µs |
+| **24** | 4 056 | **10 893** | **44 716** | 245 µs |
+| 64 | 4 717 | 22 939 | **124 448** | 365 µs |
+
+**Tres leyes salen de ahí:**
+
+1. **El acierto en la capa de arriba es plano** (~4,2 µs sin importar N): se encuentra a la primera
+   y no se mira nada más.
+2. **El acierto en la capa del fondo crece +295 ns por capa** — el overlay va preguntando hacia
+   abajo hasta encontrarlo.
+3. **El que paga es el FALLO: +1 925 ns por capa**, porque para decir «no está» hay que preguntar
+   a **todas**. A 64 capas un `stat` que falla cuesta **124 µs**, o sea **1 580 syscalls nulas**.
+
+**Y la ley que lo desactiva todo: se paga UNA VEZ POR RUTA, no por acceso.**
+
+| | ns |
+|---|---|
+| la misma ruta repetida (dentry ya creada) | **664–693, plano en N** |
+| `open` de una ruta recién `stat`-eada | 1 727–2 394, plano en N |
+
+El kernel cachea la dentry fusionada del overlay: el segundo acceso a la misma ruta ya no pasea
+nada. El montaje dura **toda la fase de build**, así que el impuesto de una fase es
+«rutas DISTINTAS tocadas × capas», no «lookups × capas».
+
+**La alternativa —fundirlas en una capa de hardlinks— sale plana en todo**, que es lo esperable
+(vuelve a ser un solo directorio):
+
+| N | acierto | FALLO | fundirlas |
+|---|---|---|---|
+| 8 | 4 061 ns | 3 835 ns | 55,8 ms |
+| 24 | 4 442 | 4 118 | 128,5 ms |
+| 64 | 3 983 | 3 673 | 376,4 ms |
+
+≈ **23 µs por fichero hardlinkeado** (20 µs con artefactos reales del store). Como piso, el mismo
+árbol sin overlay: acierto 878 ns, fallo 2 588 ns.
+
+**Con artefactos REALES del store** (64 con `usr/include`, de 100 a 3 087 ficheros) la forma se
+repite y las constantes bajan —árboles más profundos, dentries de abajo más calientes—: el fallo
+sube **+738 ns por capa** con nombres nunca vistos y **+363 ns** cuando el nombre ya se buscó antes
+(la dentry negativa de ext4 está puesta y sólo se paga el paseo del overlay). Fundir 24 deps reales
+= 16 112 ficheros = **264 ms**.
+
+**La cuenta que decide, y sale al revés de la intuición.** Un `cc -O2 -c` de 12 KB de fuente toca
+**381 rutas distintas que fallan** (352 bajo `/usr`, o sea dentro del overlay) y 260 que aciertan
+—`strace` del propio store, §5 de `overlay.txt`—. A 24 capas reales, cada primer fallo cuesta
+**+7,5 µs** contra la capa fundida ⇒ 381 × 7,5 µs = **2,9 ms**, y se paga **una vez por fase**, no
+por unidad de traducción: la segunda unidad que busque las mismas cabeceras ya las encuentra
+cacheadas. Contra una fase de compilado de segundos a minutos, es **la misma décima de por ciento
+que la jaula de T17/T18**.
+
+Al revés: para que fundir las capas se pagara sola en lookups ahorrados harían falta
+**264 ms / 7,5 µs ≈ 35 200 primeros fallos** en una sola fase — dos órdenes de magnitud más de lo
+que gasta un compilado. ⇒ **`merge_deps_layer` NO es una optimización de velocidad. Es un rodeo de
+un muro**, y conviene que quede escrito porque la caché que deja (`.dmerge`) tiene un precio
+conocido en disco: hardlinkea los artefactos y **retiene lo que borrás del store**.
+
+**El muro, medido exactamente.** El comentario de `sandbox.rs` decía «~1 página (4096 B)» y acierta
+al byte. Bisecado con `mount(2)` directo (`mnt2.c`; **el `mount(8)` de util-linux se rinde mucho
+antes —entre 8 y 12 capas acá— así que bisecarlo desde la shell da un muro FALSO**):
+
+| capas | opciones | |
+|---|---|---|
+| 41 | 4 059 B | monta |
+| **42** | **4 158 B** | **falla, `ENOENT`** |
+
+Una página exacta, y el error es el engañoso «No such file or directory» que ya estaba anotado:
+**el kernel no dice «demasiado largo», dice «no existe»**. Con eso el umbral de `sandbox.rs` queda
+calibrado y no por casualidad: dispara a las ~27 deps y el muro está en 41, o sea **un factor 1,5
+de margen**. Y el margen es estructural, no del store de esta máquina: con rutas de longitud L el
+muro está en ~4002/(L+1) capas y el umbral en 3000/(L+16), cuyo cociente es **≥1,33 sea cual sea
+L**. No hay que recalibrarlo si el store se muda.
+
+**Y el muro tiene puerta, medida.** El límite es del `mount(2)` clásico, que pasa todas las opciones
+en un único buffer de una página. La API de montaje nueva las pasa de a una y overlayfs acepta
+`lowerdir+` repetido; el mismo banco monta así **las 64 capas reales que `mount(2)` no puede**, con
+el mismo coste por capa y montando en 406 µs:
+
+| N (API nueva) | acierto capa N-1 | FALLO | montar |
+|---|---|---|---|
+| 24 | 6 996 ns | 23 070 ns | 302 µs |
+| 48 | 25 388 | 39 832 | 362 µs |
+| **64** | 37 536 | 52 042 | **406 µs** |
+
+Sin muro, `merge_deps_layer` —y con él `.dmerge` entero— dejaría de hacer falta. El precio está en
+§7-H11: **bwrap no usa esa API**, y el overlay se monta dentro de su namespace.
+
+## 3. Las siete sorpresas
 
 Las anoto aparte porque contradicen el consejo de manual:
 
@@ -399,6 +514,12 @@ Las anoto aparte porque contradicen el consejo de manual:
    Lo que sí cuesta es **mirar un argumento**: eso saca a esa syscall del bitmap y le devuelve la
    cadena entera. La variable no es el largo, es si el veredicto depende de los args.
 
+7. **Apilar 24 capas overlay no encarece el build** (T19) — el consejo de manual (y el propio
+   comentario de `sandbox.rs`) invita a leer la fusión de deps como una optimización de velocidad;
+   medida, no lo es: el overlay cobra por PRIMER toque de cada ruta, no por acceso, y fundir 24
+   deps sólo se pagaría sola a partir de 35 200 primeros fallos en una misma fase. Lo que sí
+   justifica fundirlas es el muro de una página del `mount(2)`, que está en 42 capas.
+
 ## 4. El hallazgo cruzado: los kernels de hammer no tienen `MEMCG`
 
 No lo buscaba; salió de verificar la recomendación antes de escribirla.
@@ -724,6 +845,26 @@ Su precio, concreto:
   retener lo borrado— al mismo precio. Es un argumento para wawafs, medido; no un cambio de
   filesystem decidido acá.
 
+- **H11 · El umbral de fusión de capas se queda, y ahora se sabe qué es.** T19 lo midió: no
+  compra velocidad (2,9 ms por fase de compilado a 24 capas), compra **no chocar contra el muro de
+  una página de `mount(2)`**, que está en 42 capas / 4 158 B de opciones. Tres consecuencias:
+
+  1. **No bajar el umbral «para que vaya más rápido».** No hay nada que ganar y sí que perder: cada
+     fusión cuesta ~20 µs por fichero (264 ms para 24 deps reales) y deja un árbol de hardlinks en
+     `.dmerge` que **retiene los artefactos que borres del store**. Es la deuda de disco conocida.
+  2. **Y por lo mismo, podar `.dmerge` es GRATIS en rendimiento.** Lo único que se pierde al
+     borrarla es rehacer el `cp -al` de la próxima receta grande: cientos de milisegundos. Eso
+     cambia el cálculo de la poda, que hasta ahora se hacía sin saber qué se estaba tirando.
+  3. **El umbral no hay que recalibrarlo si el store se muda**: el muro va como ~4002/(L+1) capas y
+     el umbral como 3000/(L+16), así que el margen es ≥1,33 para cualquier longitud de ruta L.
+
+  **Lo que queda abierto, con su precio:** montar el overlay con la API nueva (`fsopen`+`fsconfig`
+  con `lowerdir+` repetido) borra el muro —el banco monta así las 64 capas reales que `mount(2)`
+  rechaza— y con él la necesidad de `.dmerge`. El precio no es el código de hammer: **es bwrap**,
+  que monta el overlay dentro de su namespace con la API vieja. O se parchea bwrap, o el overlay se
+  monta fuera y bwrap lo entra con `--bind`, lo que mueve el montaje al lado privilegiado. No se
+  decide acá; se deja medido que la puerta existe.
+
 ## 8. Propuestas — lado tawasuyu
 
 Detalle en el handoff espejo. En una línea cada una:
@@ -953,6 +1094,19 @@ jaula no quedó puesta**: comprueba que un número de la denylist da `EPERM` y q
 fuera del ruleset da `EACCES`, porque medir una jaula apagada da números preciosos y falsos
 (CLAUDE.md §3, la misma regla que el exit 2 de §9.5).
 
+**Sexta corrida (T19, el overlay por capa, 2026-09-01)**: `bench_overlay.c` → `overlay.txt`. Se
+compila con `cc -O2 -o bench_overlay bench_overlay.c` y **pide root** (hace `mount(2)` de overlay).
+Tres modos: sin argumentos extra construye su propio árbol sintético (secciones A/B/C);
+`./bench_overlay <rondas> <dir-base> <fichero-con-capas>` apila las rutas de ese fichero, una por
+línea, la primera arriba (D1/D2); y con un cuarto argumento cualquiera usa la API de montaje nueva
+(E). **El merge de hardlinks cae en el filesystem de las capas, no en el del banco** —el store de
+`momento` es un bind de `/dev/sdb` y el árbol del banco vive en `/dev/sdc`, así que `cp -al` sería
+cross-device: el mismo cálculo que hace `merge_deps_layer`, y el canario lo destapó—. Deja árboles
+grandes (hasta 16 384 ficheros sintéticos y un merge de los artefactos reales) que hay que borrar a
+mano. **Aborta con exit 2 si la medición no está puesta**: comprueba que todos los ficheros de la
+capa más profunda se ven a través del montaje y que un inexistente no. `overlay.txt` trae además la
+bisección del muro con `mount(2)` directo y el recuento de rutas distintas de un compilado.
+
 **El pago de H1** (2026-08-30): `contrato-h1-pagado.txt` — el barrido entero con los cuatro kernels
 reconstruidos (5 de 5 vigentes en verde, 10 superados listados) y el `diff-back` del plan de gioser
 contra su `.config` nuevo. Es la única salida de esta carpeta que sí cambia si se vuelve a correr:
diff --git a/docs/evidencia/tasas-kernel-2026-08-29/bench_overlay.c b/docs/evidencia/tasas-kernel-2026-08-29/bench_overlay.c
new file mode 100644
index 00000000..bbf251a2
--- /dev/null
+++ b/docs/evidencia/tasas-kernel-2026-08-29/bench_overlay.c
@@ -0,0 +1,570 @@
+// bench_overlay.c — SDD 25 / T19: qué cobra overlayfs POR CAPA en un lookup.
+//
+// hammer apila una capa overlay por dependencia bajo el rootfs (crates/hammer-build/src/sandbox.rs,
+// `bwrap_args`) y sólo las funde en una sola capa de hardlinks cuando la suma de rutas pasa de
+// 3000 B. Con el store en /mnt/vvv/hammer/store eso son ~26 deps ⇒ el 95% del corpus construye con
+// N capas apiladas. Este banco mide qué cuesta esa N.
+//
+// Compilar: cc -O2 -o bench_overlay bench_overlay.c
+// Correr  : sudo ./bench_overlay [rondas] [dir-base]     (root: hace mount(2) de overlay)
+//
+// Método (mismo que el resto de la carpeta): 7 rondas por defecto, se reporta MÍNIMO y mediana.
+// Las capas se construyen UNA vez; el overlay se monta y desmonta en CADA ronda, así que las
+// dentries del overlay están frías en cada medida y las del filesystem de abajo, calientes: lo que
+// se aísla es el paseo del overlay por sus capas, no el coste de ext4. Los FALLOS usan nombres
+// nuevos en cada ronda (`nope_r<ronda>_<i>`) para que tampoco haya dentry negativa cacheada abajo.
+//
+// MODO D (tercer argumento): `sudo ./bench_overlay <rondas> <dir-base> <fichero-con-capas-reales>`
+// — en vez del árbol sintético, apila DEPS REALES del store de hammer (una ruta por línea, la
+// primera es la de más arriba, como el rootfs en `bwrap_args`). Es la comprobación de que la ley
+// que sale de A/B no depende de que las capas sean de juguete: los artefactos reales tienen
+// árboles profundos y miles de ficheros.
+//
+// Sale con 2 si la jaula de la medición no está puesta: que el overlay esté montado y que un
+// fichero de la capa MÁS PROFUNDA se vea a través del punto de montaje. Medir un overlay que no
+// montó da números preciosos y falsos (CLAUDE.md §3).
+
+#include <sys/mount.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <sys/wait.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <unistd.h>
+
+#define K_FILES 256            // ficheros por capa
+static const int NS[] = {1, 2, 4, 8, 16, 24, 32, 48, 64};
+#define N_NS ((int)(sizeof(NS) / sizeof(NS[0])))
+#define MAXN 64
+
+static char BASE[512];
+static double g_mount_ns, g_umount_ns;   // lo que cuesta ARMAR la jaula de capas, por fase de build
+
+static double now_ns(void) {
+    struct timespec t;
+    clock_gettime(CLOCK_MONOTONIC, &t);
+    return (double)t.tv_sec * 1e9 + (double)t.tv_nsec;
+}
+
+static int cmp_d(const void *a, const void *b) {
+    double x = *(const double *)a, y = *(const double *)b;
+    return x < y ? -1 : x > y ? 1 : 0;
+}
+
+static void sh(const char *fmt, ...) {
+    char cmd[4096];
+    va_list ap;
+    va_start(ap, fmt);
+    vsnprintf(cmd, sizeof cmd, fmt, ap);
+    va_end(ap);
+    int r = system(cmd);
+    (void)r;
+}
+
+static void mkpath(const char *p) {
+    char buf[1024];
+    snprintf(buf, sizeof buf, "%s", p);
+    for (char *q = buf + 1; *q; q++) {
+        if (*q == '/') { *q = 0; mkdir(buf, 0755); *q = '/'; }
+    }
+    mkdir(buf, 0755);
+}
+
+// ── construcción del árbol ────────────────────────────────────────────────────────────────────
+// Cada capa i trae `usr/include/` (compartido: fuerza la fusión de directorios, que es lo que hace
+// un overlay real) y K ficheros PROPIOS `usr/include/l<i>_<j>.h`. Un fichero de la capa i sólo
+// existe ahí ⇒ encontrarlo cuesta exactamente i+1 lookups de capa.
+static void construir_capas(int nmax) {
+    char p[1024];
+    for (int i = 0; i < nmax; i++) {
+        snprintf(p, sizeof p, "%s/lower/%d/usr/include", BASE, i);
+        mkpath(p);
+        for (int j = 0; j < K_FILES; j++) {
+            char f[1200];
+            snprintf(f, sizeof f, "%s/l%d_%d.h", p, i, j);
+            int fd = open(f, O_CREAT | O_WRONLY | O_TRUNC, 0644);
+            if (fd >= 0) { (void)!write(fd, "/* x */\n", 8); close(fd); }
+        }
+    }
+    snprintf(p, sizeof p, "%s/upper", BASE); mkpath(p);
+    snprintf(p, sizeof p, "%s/work", BASE);  mkpath(p);
+    snprintf(p, sizeof p, "%s/mnt", BASE);   mkpath(p);
+}
+
+// lowerdir=l0:l1:...:l(n-1)  — el PRIMERO es el de arriba (mayor prioridad), igual que bwrap apila
+// el rootfs primero y las deps debajo.
+static int montar(int n, const char *lower_override) {
+    char opts[8192];
+    int off = snprintf(opts, sizeof opts, "lowerdir=");
+    if (lower_override) {
+        off += snprintf(opts + off, sizeof opts - off, "%s", lower_override);
+    } else {
+        for (int i = 0; i < n; i++)
+            off += snprintf(opts + off, sizeof opts - off, "%s%s/lower/%d", i ? ":" : "", BASE, i);
+    }
+    snprintf(opts + off, sizeof opts - off, ",upperdir=%s/upper,workdir=%s/work", BASE, BASE);
+    char mnt[600];
+    snprintf(mnt, sizeof mnt, "%s/mnt", BASE);
+    double t0 = now_ns();
+    int r = mount("overlay", mnt, "overlay", 0, opts);
+    g_mount_ns = now_ns() - t0;
+    if (r != 0) {
+        fprintf(stderr, "mount overlay (n=%d): %m\n  opts=%s\n", n, opts);
+        return -1;
+    }
+    return 0;
+}
+
+static void desmontar(void) {
+    char mnt[600];
+    snprintf(mnt, sizeof mnt, "%s/mnt", BASE);
+    double t0 = now_ns();
+    if (umount(mnt) != 0) fprintf(stderr, "umount: %m\n");
+    g_umount_ns = now_ns() - t0;
+    // El upper es descartable (como el --tmp-overlay de bwrap): vaciarlo entre montajes.
+    sh("rm -rf '%s/upper' '%s/work' && mkdir -p '%s/upper' '%s/work'", BASE, BASE, BASE, BASE);
+}
+
+// ── medidas ──────────────────────────────────────────────────────────────────────────────────
+// stat de K rutas DISTINTAS, cada una por primera vez en este montaje ⇒ cada una paga el paseo.
+static double stat_frio(int capa, int esperado_ok) {
+    char f[1200];
+    struct stat st;
+    int ok = 0;
+    double t0 = now_ns();
+    for (int j = 0; j < K_FILES; j++) {
+        snprintf(f, sizeof f, "%s/mnt/usr/include/l%d_%d.h", BASE, capa, j);
+        if (stat(f, &st) == 0) ok++;
+    }
+    double dt = now_ns() - t0;
+    if (esperado_ok && ok != K_FILES) {
+        fprintf(stderr, "[canario] capa %d: %d de %d ficheros VISIBLES — la medida no vale\n",
+                capa, ok, K_FILES);
+        exit(2);
+    }
+    return dt / K_FILES;
+}
+
+static double stat_fallo(int ronda) {
+    char f[1200];
+    struct stat st;
+    int ok = 0;
+    double t0 = now_ns();
+    for (int j = 0; j < K_FILES; j++) {
+        snprintf(f, sizeof f, "%s/mnt/usr/include/nope_r%d_%d.h", BASE, ronda, j);
+        if (stat(f, &st) == 0) ok++;
+    }
+    double dt = now_ns() - t0;
+    if (ok != 0) { fprintf(stderr, "[canario] un fichero inexistente EXISTE — la medida no vale\n"); exit(2); }
+    return dt / K_FILES;
+}
+
+static double open_frio(int capa) {
+    char f[1200];
+    int ok = 0;
+    double t0 = now_ns();
+    for (int j = 0; j < K_FILES; j++) {
+        snprintf(f, sizeof f, "%s/mnt/usr/include/l%d_%d.h", BASE, capa, j);
+        int fd = open(f, O_RDONLY);
+        if (fd >= 0) { ok++; close(fd); }
+    }
+    double dt = now_ns() - t0;
+    if (ok != K_FILES) { fprintf(stderr, "[canario] open: %d de %d\n", ok, K_FILES); exit(2); }
+    return dt / K_FILES;
+}
+
+// La MISMA ruta muchas veces: la dentry del overlay ya está, no se pasea nada.
+static double stat_caliente(int capa) {
+    char f[1200];
+    struct stat st;
+    snprintf(f, sizeof f, "%s/mnt/usr/include/l%d_0.h", BASE, capa);
+    if (stat(f, &st) != 0) { fprintf(stderr, "[canario] caliente: %m\n"); exit(2); }
+    const int R = 20000;
+    double t0 = now_ns();
+    for (int j = 0; j < R; j++) stat(f, &st);
+    return (now_ns() - t0) / R;
+}
+
+// ── la alternativa real: fundir las N capas en UNA por hardlinks (lo que hace merge_deps_layer) ──
+static double construir_merge(int n) {
+    char m[600];
+    snprintf(m, sizeof m, "%s/merged", BASE);
+    sh("rm -rf '%s'", m);
+    mkpath(m);
+    double t0 = now_ns();
+    for (int i = 0; i < n; i++)
+        sh("cp -alf '%s/lower/%d/.' '%s/' 2>/dev/null", BASE, i, m);
+    return now_ns() - t0;   // ns totales
+}
+
+
+// ── MODO D · capas REALES del store ──────────────────────────────────────────────────────────
+#define MAXR 64
+static char CAPA[MAXR][512];
+static char REL[K_FILES][512];   // rutas relativas de ficheros que EXISTEN en la capa más profunda
+
+static int leer_capas(const char *lista) {
+    FILE *fp = fopen(lista, "r");
+    if (!fp) { fprintf(stderr, "no puedo abrir %s: %m\n", lista); exit(2); }
+    int n = 0;
+    char ln[512];
+    while (n < MAXR && fgets(ln, sizeof ln, fp)) {
+        size_t l = strlen(ln);
+        while (l && (ln[l - 1] == '\n' || ln[l - 1] == ' ')) ln[--l] = 0;
+        if (!l || ln[0] == '#') continue;
+        snprintf(CAPA[n++], 512, "%s", ln);
+    }
+    fclose(fp);
+    return n;
+}
+
+// Ficheros reales de la capa `dir`, en rutas relativas, para statearlos a través del montaje.
+static int recolectar(const char *dir, int *nrel) {
+    char cmd[1200];
+    snprintf(cmd, sizeof cmd,
+             "cd '%s' 2>/dev/null && find . -type f -printf '%%P\\n' 2>/dev/null | head -%d", dir, K_FILES);
+    FILE *fp = popen(cmd, "r");
+    if (!fp) return -1;
+    int k = 0;
+    char ln[512];
+    while (k < K_FILES && fgets(ln, sizeof ln, fp)) {
+        size_t l = strlen(ln);
+        while (l && ln[l - 1] == '\n') ln[--l] = 0;
+        if (l) snprintf(REL[k++], 512, "%s", ln);
+    }
+    pclose(fp);
+    *nrel = k;
+    return k;
+}
+
+static double stat_reales(int esperado_ok, int nrel) {
+    char f[1200]; struct stat st; int ok = 0;
+    double t0 = now_ns();
+    for (int j = 0; j < nrel; j++) {
+        snprintf(f, sizeof f, "%s/mnt/%s", BASE, REL[j]);
+        if (stat(f, &st) == 0) ok++;
+    }
+    double dt = now_ns() - t0;
+    if (esperado_ok && ok != nrel) {
+        fprintf(stderr, "[canario] sólo %d de %d ficheros REALES visibles — la medida no vale\n", ok, nrel);
+        exit(2);
+    }
+    return dt / nrel;
+}
+
+static int montar_lista(int n) {
+    char opts[8192];
+    int off = snprintf(opts, sizeof opts, "lowerdir=");
+    for (int i = 0; i < n; i++)
+        off += snprintf(opts + off, sizeof opts - off, "%s%s", i ? ":" : "", CAPA[i]);
+    if (off > 3800) fprintf(stderr, "# AVISO: lowerdir de %d B, cerca del límite de página\n", off);
+    snprintf(opts + off, sizeof opts - off, ",upperdir=%s/upper,workdir=%s/work", BASE, BASE);
+    char mnt[600]; snprintf(mnt, sizeof mnt, "%s/mnt", BASE);
+    double t0 = now_ns();
+    int rc = mount("overlay", mnt, "overlay", 0, opts);
+    g_mount_ns = now_ns() - t0;
+    if (rc != 0) {
+        fprintf(stderr, "mount overlay reales (n=%d): %m\n", n); return -1;
+    }
+    return 0;
+}
+
+static int modo_real(int rondas, const char *lista) {
+    int ncapas = leer_capas(lista);
+    printf("# MODO D — %d capas REALES del store, la primera es la de arriba\n", ncapas);
+    char p[1024];
+    snprintf(p, sizeof p, "%s/upper", BASE); mkpath(p);
+    snprintf(p, sizeof p, "%s/work", BASE);  mkpath(p);
+    snprintf(p, sizeof p, "%s/mnt", BASE);   mkpath(p);
+
+    int ns[] = {1, 2, 4, 8, 16, 24, 32, 48, 64};
+    printf("\n## D1 · apiladas: ns/ruta, lookup frío\n");
+    printf("%4s %8s %16s %16s %16s %10s\n", "N", "ficheros", "hit capa N-1", "FALLO",
+           "open capa N-1", "mount(us)");
+    for (int a = 0; a < 9; a++) {
+        int n = ns[a];
+        if (n > ncapas) break;
+        int nrel = 0;
+        if (recolectar(CAPA[n - 1], &nrel) <= 0 || nrel == 0) { printf("%4d  (capa vacía)\n", n); continue; }
+        double t_hit[64], t_miss[64], t_open[64], t_mnt[64];
+        for (int r = 0; r < rondas; r++) {
+            if (montar_lista(n) != 0) {
+                // El lowerdir de mount(2) cabe en UNA página: con rutas reales del store
+                // (~97 B cada una) el apilado se acaba antes de N=64. Que se acabe ES el dato.
+                printf("%4d %8d   — el lowerdir no cabe en una página (ver sandbox.rs)\n", n, nrel);
+                fflush(stdout);
+                goto fin_d1;
+            }
+            t_mnt[r] = g_mount_ns / 1000.0;
+            t_hit[r]  = stat_reales(1, nrel);
+            t_miss[r] = stat_fallo(r + 7000 + a * 1000);
+            { // open de los mismos reales
+                char f[1200]; int ok = 0; double t0 = now_ns();
+                for (int j = 0; j < nrel; j++) {
+                    snprintf(f, sizeof f, "%s/mnt/%s", BASE, REL[j]);
+                    int fd = open(f, O_RDONLY);
+                    if (fd >= 0) { ok++; close(fd); }
+                }
+                t_open[r] = (now_ns() - t0) / nrel;
+                if (ok != nrel) { fprintf(stderr, "[canario] open reales %d/%d\n", ok, nrel); return 2; }
+            }
+            desmontar();
+        }
+        qsort(t_hit, rondas, sizeof(double), cmp_d);
+        qsort(t_miss, rondas, sizeof(double), cmp_d);
+        qsort(t_open, rondas, sizeof(double), cmp_d);
+        qsort(t_mnt, rondas, sizeof(double), cmp_d);
+        printf("%4d %8d %8.1f/%6.1f %8.1f/%6.1f %8.1f/%6.1f %10.1f\n", n, nrel,
+               t_hit[0], t_hit[rondas / 2], t_miss[0], t_miss[rondas / 2],
+               t_open[0], t_open[rondas / 2], t_mnt[0]);
+        fflush(stdout);
+        continue;
+fin_d1:
+        break;
+    }
+
+    printf("\n## D2 · las mismas, FUNDIDAS en una capa de hardlinks\n");
+    printf("%4s %8s %16s %16s %16s %10s %10s\n", "N", "ficheros", "hit", "FALLO", "open", "fundir(ms)", "ficheros");
+    for (int a = 0; a < 9; a++) {
+        int n = ns[a];
+        if (n > ncapas) break;
+        int nrel = 0;
+        if (recolectar(CAPA[n - 1], &nrel) <= 0 || nrel == 0) continue;
+        // El merge por hardlinks TIENE que caer en el filesystem de las capas: el store de esta
+        // máquina es un bind de /dev/sdb y BASE está en /dev/sdc ⇒ `cp -al` cross-device falla.
+        // Es el mismo cálculo que hace `merge_deps_layer` en sandbox.rs, y por la misma razón.
+        char m[600];
+        { char dir[512]; snprintf(dir, sizeof dir, "%s", CAPA[0]);
+          char *sl = strrchr(dir, '/'); if (sl) *sl = 0;
+          snprintf(m, sizeof m, "%s/.bench-merge", dir); }
+        sh("rm -rf '%s'", m); mkpath(m);
+        double t0 = now_ns();
+        for (int i = 0; i < n; i++) sh("cp -alf '%s/.' '%s/' 2>/dev/null", CAPA[i], m);
+        double ms = (now_ns() - t0) / 1e6;
+        long nf = 0;
+        { char c[900]; snprintf(c, sizeof c, "find '%s' -type f 2>/dev/null | wc -l", m);
+          FILE *fp = popen(c, "r"); if (fp) { if (fscanf(fp, "%ld", &nf) != 1) nf = -1; pclose(fp); } }
+        snprintf(CAPA[MAXR - 1], 512, "%s", m);
+        char guardado[512]; snprintf(guardado, sizeof guardado, "%s", CAPA[0]);
+        snprintf(CAPA[0], 512, "%s", m);
+        double t_hit[64], t_miss[64], t_open[64];
+        for (int r = 0; r < rondas; r++) {
+            if (montar_lista(1) != 0) return 2;
+            t_hit[r]  = stat_reales(1, nrel);
+            t_miss[r] = stat_fallo(r + 9000 + a * 1000);
+            { char f[1200]; int ok = 0; double tt = now_ns();
+              for (int j = 0; j < nrel; j++) {
+                  snprintf(f, sizeof f, "%s/mnt/%s", BASE, REL[j]);
+                  int fd = open(f, O_RDONLY); if (fd >= 0) { ok++; close(fd); } }
+              t_open[r] = (now_ns() - tt) / nrel; }
+            desmontar();
+        }
+        snprintf(CAPA[0], 512, "%s", guardado);
+        qsort(t_hit, rondas, sizeof(double), cmp_d);
+        qsort(t_miss, rondas, sizeof(double), cmp_d);
+        qsort(t_open, rondas, sizeof(double), cmp_d);
+        printf("%4d %8d %8.1f/%6.1f %8.1f/%6.1f %8.1f/%6.1f %10.1f %10ld\n", n, nrel,
+               t_hit[0], t_hit[rondas / 2], t_miss[0], t_miss[rondas / 2],
+               t_open[0], t_open[rondas / 2], ms, nf);
+        fflush(stdout);
+    }
+    printf("\n# el merge queda en %s/merged-real — borrar a mano\n", BASE);
+    return 0;
+}
+
+
+// ── MODO E · la API de montaje NUEVA (fsopen/fsconfig) ────────────────────────────────────────
+// El muro de D1 —el lowerdir no cabe en una página— es del `mount(2)` clásico, que pasa TODAS las
+// opciones en un único buffer de una página. La API nueva pasa cada opción por separado y overlayfs
+// acepta `lowerdir+` repetido (una capa por llamada). Si eso monta 64 capas reales, el merge de
+// hardlinks de `merge_deps_layer` deja de ser obligatorio — y con él la retención de `.dmerge`.
+#include <sys/syscall.h>
+#ifndef FSCONFIG_SET_STRING
+#define FSCONFIG_SET_STRING 1
+#define FSCONFIG_CMD_CREATE 6
+#endif
+#ifndef MOVE_MOUNT_F_EMPTY_PATH
+#define MOVE_MOUNT_F_EMPTY_PATH 0x00000004
+#endif
+static int fsopen_(const char *fs, unsigned f) { return (int)syscall(__NR_fsopen, fs, f); }
+static int fsconfig_(int fd, unsigned cmd, const char *k, const void *v, int aux) {
+    return (int)syscall(__NR_fsconfig, fd, cmd, k, v, aux);
+}
+static int fsmount_(int fd, unsigned f, unsigned ms) { return (int)syscall(__NR_fsmount, fd, f, ms); }
+static int move_mount_(int ffd, const char *fp, int tfd, const char *tp, unsigned f) {
+    return (int)syscall(__NR_move_mount, ffd, fp, tfd, tp, f);
+}
+
+static int modo_api_nueva(int rondas, const char *lista) {
+    int ncapas = leer_capas(lista);
+    char p[1024];
+    snprintf(p, sizeof p, "%s/upper", BASE); mkpath(p);
+    snprintf(p, sizeof p, "%s/work", BASE);  mkpath(p);
+    snprintf(p, sizeof p, "%s/mnt", BASE);   mkpath(p);
+    char mnt[600]; snprintf(mnt, sizeof mnt, "%s/mnt", BASE);
+    char up[600], wk[600];
+    snprintf(up, sizeof up, "%s/upper", BASE);
+    snprintf(wk, sizeof wk, "%s/work", BASE);
+
+    printf("# MODO E — fsopen/fsconfig con `lowerdir+`, %d capas reales disponibles\n", ncapas);
+    printf("%4s %8s %16s %16s %12s\n", "N", "ficheros", "hit capa N-1", "FALLO", "mount(us)");
+    int ns[] = {8, 16, 24, 32, 48, 64};
+    for (int a = 0; a < 6; a++) {
+        int n = ns[a];
+        if (n > ncapas) break;
+        int nrel = 0;
+        if (recolectar(CAPA[n - 1], &nrel) <= 0) continue;
+        double t_hit[64], t_miss[64], t_mnt[64];
+        for (int r = 0; r < rondas; r++) {
+            double t0 = now_ns();
+            int fd = fsopen_("overlay", 0);
+            if (fd < 0) { fprintf(stderr, "fsopen: %m\n"); return 2; }
+            int mal = 0;
+            // Orden: la PRIMERA `lowerdir+` es la de más arriba, igual que en la lista separada por ':'.
+            for (int i = 0; i < n; i++)
+                if (fsconfig_(fd, FSCONFIG_SET_STRING, "lowerdir+", CAPA[i], 0) != 0) {
+                    fprintf(stderr, "fsconfig lowerdir+ (capa %d): %m\n", i); mal = 1; break;
+                }
+            if (mal) { close(fd); return 2; }
+            if (fsconfig_(fd, FSCONFIG_SET_STRING, "upperdir", up, 0) != 0) { fprintf(stderr, "upperdir: %m\n"); return 2; }
+            if (fsconfig_(fd, FSCONFIG_SET_STRING, "workdir", wk, 0) != 0) { fprintf(stderr, "workdir: %m\n"); return 2; }
+            if (fsconfig_(fd, FSCONFIG_CMD_CREATE, NULL, NULL, 0) != 0) { fprintf(stderr, "create (n=%d): %m\n", n); return 2; }
+            int mfd = fsmount_(fd, 0, 0);
+            if (mfd < 0) { fprintf(stderr, "fsmount: %m\n"); return 2; }
+            if (move_mount_(mfd, "", AT_FDCWD, mnt, MOVE_MOUNT_F_EMPTY_PATH) != 0) { fprintf(stderr, "move_mount: %m\n"); return 2; }
+            t_mnt[r] = (now_ns() - t0) / 1000.0;
+            close(mfd); close(fd);
+            t_hit[r]  = stat_reales(1, nrel);
+            t_miss[r] = stat_fallo(r + 20000 + a * 1000);
+            desmontar();
+        }
+        qsort(t_hit, rondas, sizeof(double), cmp_d);
+        qsort(t_miss, rondas, sizeof(double), cmp_d);
+        qsort(t_mnt, rondas, sizeof(double), cmp_d);
+        printf("%4d %8d %8.1f/%6.1f %8.1f/%6.1f %12.1f\n", n, nrel,
+               t_hit[0], t_hit[rondas / 2], t_miss[0], t_miss[rondas / 2], t_mnt[0]);
+        fflush(stdout);
+    }
+    return 0;
+}
+
+int main(int argc, char **argv) {
+    int rondas = argc > 1 ? atoi(argv[1]) : 7;
+    snprintf(BASE, sizeof BASE, "%s", argc > 2 ? argv[2] : "/mnt/vvv/.bench-overlay");
+
+    if (geteuid() != 0) { fprintf(stderr, "hace falta root (mount de overlay)\n"); return 2; }
+
+    if (argc > 4) {   // 4º argumento cualquiera ⇒ modo E (API de montaje nueva)
+        snprintf(BASE, sizeof BASE, "%s", argv[2]);
+        printf("# bench_overlay — SDD 25 T19 (modo E: fsconfig lowerdir+)\n");
+        sh("rm -rf '%s/mnt' '%s/upper' '%s/work'", BASE, BASE, BASE);
+        mkpath(BASE);
+        return modo_api_nueva(rondas, argv[3]);
+    }
+    if (argc > 3) {
+        snprintf(BASE, sizeof BASE, "%s", argv[2]);
+        printf("# bench_overlay — SDD 25 T19 (modo D: capas reales)\n");
+        printf("# base=%s rondas=%d\n", BASE, rondas);
+        sh("rm -rf '%s/mnt' '%s/upper' '%s/work'", BASE, BASE, BASE);
+        mkpath(BASE);
+        return modo_real(rondas, argv[3]);
+    }
+
+    printf("# bench_overlay — SDD 25 T19\n");
+    printf("# base=%s rondas=%d ficheros/capa=%d\n", BASE, rondas, K_FILES);
+    fflush(stdout);
+
+    sh("rm -rf '%s'", BASE);
+    mkpath(BASE);
+    construir_capas(MAXN);
+
+    printf("\n## A · lookup FRÍO por primera vez, en ns/ruta (capas apiladas)\n");
+    printf("%4s %14s %14s %14s %14s %12s %10s %9s\n", "N", "hit capa 0", "hit capa N-1", "FALLO",
+           "open capa N-1", "hit caliente", "mount(us)", "umount");
+    for (int a = 0; a < N_NS; a++) {
+        int n = NS[a];
+        double t_top[64], t_bot[64], t_miss[64], t_open[64], t_warm[64], t_mnt[64], t_umnt[64];
+        for (int r = 0; r < rondas; r++) {
+            if (montar(n, NULL) != 0) return 2;
+            t_mnt[r] = g_mount_ns / 1000.0;
+            t_top[r]  = stat_frio(0, 1);
+            t_bot[r]  = stat_frio(n - 1, 1);
+            t_miss[r] = stat_fallo(r + a * 1000);
+            t_open[r] = open_frio(n - 1);
+            t_warm[r] = stat_caliente(n - 1);
+            desmontar();
+            t_umnt[r] = g_umount_ns / 1000.0;
+        }
+        qsort(t_mnt, rondas, sizeof(double), cmp_d);
+        qsort(t_umnt, rondas, sizeof(double), cmp_d);
+        qsort(t_top, rondas, sizeof(double), cmp_d);
+        qsort(t_bot, rondas, sizeof(double), cmp_d);
+        qsort(t_miss, rondas, sizeof(double), cmp_d);
+        qsort(t_open, rondas, sizeof(double), cmp_d);
+        qsort(t_warm, rondas, sizeof(double), cmp_d);
+        printf("%4d %7.1f/%6.1f %7.1f/%6.1f %7.1f/%6.1f %7.1f/%6.1f %6.1f/%5.1f %10.1f %9.1f\n",
+               n, t_top[0], t_top[rondas / 2], t_bot[0], t_bot[rondas / 2],
+               t_miss[0], t_miss[rondas / 2], t_open[0], t_open[rondas / 2],
+               t_warm[0], t_warm[rondas / 2], t_mnt[0], t_umnt[0]);
+        fflush(stdout);
+    }
+    printf("# (mínimo/mediana de %d rondas)\n", rondas);
+
+    printf("\n## B · las MISMAS N capas fundidas en UNA sola (hardlinks, como merge_deps_layer)\n");
+    printf("%4s %14s %14s %14s %12s %14s\n", "N", "hit", "FALLO", "open", "caliente", "fundirlas(ms)");
+    for (int a = 0; a < N_NS; a++) {
+        int n = NS[a];
+        double ms_merge = construir_merge(n) / 1e6;
+        char lower[600];
+        snprintf(lower, sizeof lower, "%s/merged", BASE);
+        double t_hit[64], t_miss[64], t_open[64], t_warm[64];
+        for (int r = 0; r < rondas; r++) {
+            if (montar(1, lower) != 0) return 2;
+            t_hit[r]  = stat_frio(n - 1, 1);      // el fichero que en A estaba en el FONDO
+            t_miss[r] = stat_fallo(r + 500 + a * 1000);
+            t_open[r] = open_frio(n - 1);
+            t_warm[r] = stat_caliente(n - 1);
+            desmontar();
+        }
+        qsort(t_hit, rondas, sizeof(double), cmp_d);
+        qsort(t_miss, rondas, sizeof(double), cmp_d);
+        qsort(t_open, rondas, sizeof(double), cmp_d);
+        qsort(t_warm, rondas, sizeof(double), cmp_d);
+        printf("%4d %7.1f/%6.1f %7.1f/%6.1f %7.1f/%6.1f %6.1f/%5.1f %14.1f\n",
+               n, t_hit[0], t_hit[rondas / 2], t_miss[0], t_miss[rondas / 2],
+               t_open[0], t_open[rondas / 2], t_warm[0], t_warm[rondas / 2], ms_merge);
+        fflush(stdout);
+    }
+
+    printf("\n## C · piso: el mismo árbol SIN overlay (ext4 directo)\n");
+    {
+        double t_hit[64], t_miss[64];
+        char f[1200]; struct stat st;
+        for (int r = 0; r < rondas; r++) {
+            double t0 = now_ns();
+            for (int j = 0; j < K_FILES; j++) {
+                snprintf(f, sizeof f, "%s/lower/%d/usr/include/l%d_%d.h", BASE, MAXN - 1, MAXN - 1, j);
+                stat(f, &st);
+            }
+            t_hit[r] = (now_ns() - t0) / K_FILES;
+            t0 = now_ns();
+            for (int j = 0; j < K_FILES; j++) {
+                snprintf(f, sizeof f, "%s/lower/%d/usr/include/nope_c%d_%d.h", BASE, MAXN - 1, r, j);
+                stat(f, &st);
+            }
+            t_miss[r] = (now_ns() - t0) / K_FILES;
+        }
+        qsort(t_hit, rondas, sizeof(double), cmp_d);
+        qsort(t_miss, rondas, sizeof(double), cmp_d);
+        printf("stat ext4  hit %.1f/%.1f    FALLO %.1f/%.1f  ns\n",
+               t_hit[0], t_hit[rondas / 2], t_miss[0], t_miss[rondas / 2]);
+    }
+
+    printf("\n# el árbol queda en %s — borrar a mano\n", BASE);
+    return 0;
+}
diff --git a/docs/evidencia/tasas-kernel-2026-08-29/mnt2.c b/docs/evidencia/tasas-kernel-2026-08-29/mnt2.c
new file mode 100644
index 00000000..b8b46a92
--- /dev/null
+++ b/docs/evidencia/tasas-kernel-2026-08-29/mnt2.c
@@ -0,0 +1,31 @@
+// mnt2.c — SDD 25 / T19: dónde está el muro de capas del `mount(2)` clásico.
+//
+// Existe porque bisecarlo con el `mount(8)` de util-linux da un muro FALSO: acá se rendía entre 8
+// y 12 capas (1 247 B de opciones) donde el kernel monta 41. bwrap usa `mount(2)`, así que el muro
+// que importa es éste.
+//
+// cc -O2 -o mnt2 mnt2.c -include errno.h
+// sudo ./mnt2 <n> <fichero-con-capas> <dir-base>     (dir-base necesita mnt/, upper/ y work/)
+#define _GNU_SOURCE
+#include <sys/mount.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+int main(int argc,char**argv){
+  int n=atoi(argv[1]); const char*lista=argv[2]; const char*base=argv[3];
+  char capa[128][512]; int c=0; char ln[512];
+  FILE*fp=fopen(lista,"r");
+  while(c<128&&fgets(ln,sizeof ln,fp)){size_t l=strlen(ln);while(l&&ln[l-1]=='\n')ln[--l]=0;if(l)snprintf(capa[c++],512,"%s",ln);}
+  fclose(fp);
+  if(n>c){printf("sólo hay %d capas\n",c);return 1;}
+  char o[16384]; int off=snprintf(o,sizeof o,"lowerdir=");
+  for(int i=0;i<n;i++) off+=snprintf(o+off,sizeof o-off,"%s%s",i?":":"",capa[i]);
+  int lowlen=off-9;
+  off+=snprintf(o+off,sizeof o-off,",upperdir=%s/upper,workdir=%s/work",base,base);
+  char m[600]; snprintf(m,sizeof m,"%s/mnt",base);
+  int r=mount("overlay",m,"overlay",0,o);
+  printf("n=%-3d lowerdir=%-5d B  opciones=%-5d B  -> %s%s\n",n,lowlen,off,r==0?"OK":"FALLA: ",r==0?"":strerror(errno));
+  if(r==0) umount(m);
+  return r==0?0:1;
+}
diff --git a/docs/evidencia/tasas-kernel-2026-08-29/overlay.txt b/docs/evidencia/tasas-kernel-2026-08-29/overlay.txt
new file mode 100644
index 00000000..16ec19cc
--- /dev/null
+++ b/docs/evidencia/tasas-kernel-2026-08-29/overlay.txt
@@ -0,0 +1,115 @@
+# T19 · qué cobra overlayfs POR CAPA — SDD 25
+# Medido el 2026-09-01 en `momento` (KVM/EPYC-Rome, 4 vCPU, 7,7 GiB, Linux 7.1.4-artix1-1)
+# con bench_overlay.c. Máquina CARGADA (load ~3,6 sobre 4 vCPU, cosecha de la granja en vuelo)
+# => los absolutos son cotas superiores; la señal está en los COCIENTES de una misma corrida.
+# 7 rondas, minimo/mediana. El overlay se monta y desmonta en CADA ronda: las dentries del
+# overlay estan frias en cada medida y las de abajo, calientes. Los FALLOS usan nombres nuevos.
+# Capas sinteticas en /dev/sdc (ext4); capas reales = artefactos del store en /dev/sdb (bind).
+
+===============================================================================
+1 · CAPAS SINTETICAS (256 ficheros por capa, todas en el mismo filesystem)
+===============================================================================
+# bench_overlay — SDD 25 T19
+# base=/mnt/vvv/.bench-overlay rondas=7 ficheros/capa=256
+
+## A · lookup FRÍO por primera vez, en ns/ruta (capas apiladas)
+   N     hit capa 0   hit capa N-1          FALLO  open capa N-1 hit caliente  mount(us)    umount
+   1  4067.8/4885.0  1065.2/1490.5  3199.9/3730.0  1727.5/2328.7  664.4/747.2      152.4   10637.3
+   2  4723.2/5255.7  4629.8/5658.9  4918.5/5954.3  2226.6/2397.7  693.6/877.9      198.5   11269.4
+   4  4082.9/4585.8  5147.0/6053.4  8630.6/9255.2  1804.7/2483.9  673.2/702.4      177.9   11851.6
+   8  4204.9/5321.2  6208.8/8277.5 15866.8/20021.8  2114.3/2531.4  679.1/801.9      195.1   11825.4
+  16  4969.9/5528.2  9330.1/10328.9 30205.1/38274.9  2302.5/2627.9  668.5/755.1      219.1   10975.4
+  24  4055.5/4377.4 10893.2/11599.4 44716.2/45480.2  2072.6/2206.4  673.5/719.3      244.8    9252.3
+  32  4353.0/5354.5 13938.4/14776.4 60869.2/67052.7  2315.6/2602.8  677.6/709.0      309.3   10052.3
+  48  4543.7/5042.9 17377.3/19947.8 93970.6/102367.7  2317.1/2464.8  676.9/747.5      291.1    9166.2
+  64  4716.9/5151.4 22938.9/24143.3 124448.1/128839.6  2393.9/2479.6  682.5/701.6      364.9    9735.5
+# (mínimo/mediana de 7 rondas)
+
+## B · las MISMAS N capas fundidas en UNA sola (hardlinks, como merge_deps_layer)
+   N            hit          FALLO           open     caliente  fundirlas(ms)
+   1  4370.9/5488.2  3387.8/4069.1  1815.2/2282.8  710.7/743.7            4.7
+   2  3948.5/4865.0  3655.1/4402.2  1657.8/2237.5  692.0/752.5           11.0
+   4  4207.6/4578.2  3578.0/3797.2  1927.8/2244.3  687.2/726.3           21.7
+   8  4060.6/6188.4  3834.6/5120.7  2069.1/2766.4  690.0/717.4           55.8
+  16  3851.7/4656.6  3390.6/4320.5  2001.1/2535.5  674.3/777.1           97.5
+  24  4441.6/4756.3  4117.7/4676.9  2118.3/2265.7  681.0/795.1          128.5
+  32  3617.2/6131.1  3398.0/4581.0  1722.4/2897.3  672.9/908.5          199.6
+  48  4250.3/5046.1  3750.6/4600.1  1771.0/2519.3  702.3/851.0          282.4
+  64  3982.9/4449.0  3673.2/4122.2  1861.3/2117.6  687.3/702.0          376.4
+
+## C · piso: el mismo árbol SIN overlay (ext4 directo)
+stat ext4  hit 877.9/1226.2    FALLO 2587.5/3165.6  ns
+
+# el árbol queda en /mnt/vvv/.bench-overlay — borrar a mano
+
+===============================================================================
+2 · CAPAS REALES DEL STORE (64 artefactos con usr/include, 100-3087 ficheros)
+===============================================================================
+# bench_overlay — SDD 25 T19 (modo D: capas reales)
+# base=/mnt/vvv/.bench-overlay rondas=7
+# MODO D — 64 capas REALES del store, la primera es la de arriba
+
+## D1 · apiladas: ns/ruta, lookup frío
+   N ficheros     hit capa N-1            FALLO    open capa N-1  mount(us)
+   1      256   4828.5/5468.6   1783.6/2276.5   2237.5/3140.5      172.8
+   2      256   5564.4/6697.8   2187.5/3126.2   2762.6/3724.4      173.7
+   4      256   5481.4/5786.9   3356.8/3574.1   2439.5/2928.4      176.4
+   8      256   4573.8/5585.9   4620.9/5342.9   2391.4/2935.1      215.6
+  16      256   7239.4/8065.6   7896.4/8407.2   2923.6/3609.0      236.9
+  24      256   5988.1/7217.6   9757.8/11375.6   3114.7/3255.1      252.8
+  32      256  13520.6/15253.3  13039.9/14217.9   2643.8/3650.2      325.7
+# AVISO: lowerdir de 4663 B, cerca del límite de página
+mount overlay reales (n=48): No such file or directory
+  48      193   — el lowerdir no cabe en una página (ver sandbox.rs)
+
+## D2 · las mismas, FUNDIDAS en una capa de hardlinks
+   N ficheros              hit            FALLO             open fundir(ms)   ficheros
+   1      256   4626.9/5653.3   2294.5/2588.2   2063.5/2652.2       26.4       3087
+   2      256   4206.8/5242.6   2247.2/2720.0   2209.8/2738.8       63.9       5508
+   4      256   4959.6/5381.2   2223.1/2698.8   2515.5/3080.7       96.3       8674
+   8      256   4640.7/5223.2   2634.9/2753.6   2186.6/2880.0      152.3      11378
+  16      256   5341.5/6281.3   2540.1/2745.6   2584.2/3145.5      223.9      15354
+  24      256   4415.5/5190.4   2233.8/2494.2   2070.2/2606.1      264.4      16112
+  32      256   5923.7/7066.7   2480.6/3148.8   2481.5/3154.2      339.7      18228
+  48      193   7073.0/7829.2   3166.6/4154.0   2095.4/2707.1      387.2      21232
+  64      256   4407.3/4953.1   3549.5/3909.4   2135.3/2673.9      482.5      23803
+
+# el merge queda en /mnt/vvv/.bench-overlay/merged-real — borrar a mano
+
+===============================================================================
+3 · LA API DE MONTAJE NUEVA (fsopen/fsconfig con 'lowerdir+' repetido)
+   Monta lo que mount(2) no puede: 64 capas reales, sin muro de pagina.
+===============================================================================
+# bench_overlay — SDD 25 T19 (modo E: fsconfig lowerdir+)
+# MODO E — fsopen/fsconfig con `lowerdir+`, 64 capas reales disponibles
+   N ficheros     hit capa N-1            FALLO    mount(us)
+   8      256   5475.8/5918.1   7089.7/9219.7        209.0
+  16      256   7341.7/8460.4  12109.4/15078.8        267.5
+  24      256   6996.2/10100.3  23070.0/29872.2        302.0
+  32      256  12512.3/15712.0  25547.0/29229.4        268.3
+  48      193  25388.3/33127.3  39832.3/46457.3        362.1
+  64      153  37535.7/43370.4  52041.6/54458.3        406.2
+
+===============================================================================
+4 · DONDE ESTA EL MURO — bisecado con mount(2) DIRECTO, no con util-linux
+   (el mount(8) de util-linux se rinde mucho antes -entre 8 y 12 capas aca-, asi
+    que bisecar desde la shell da un muro FALSO; bwrap usa mount(2))
+===============================================================================
+n=8   lowerdir=776   B  opciones=861   B  -> OK
+n=32  lowerdir=3104  B  opciones=3189  B  -> OK
+n=40  lowerdir=3875  B  opciones=3960  B  -> OK
+n=41  lowerdir=3974  B  opciones=4059  B  -> OK
+n=42  lowerdir=4073  B  opciones=4158  B  -> FALLA: No such file or directory
+n=44  lowerdir=4266  B  opciones=4351  B  -> FALLA: No such file or directory
+n=48  lowerdir=4654  B  opciones=4739  B  -> FALLA: No such file or directory
+n=64  lowerdir=6223  B  opciones=6308  B  -> FALLA: No such file or directory
+
+===============================================================================
+5 · CUANTAS RUTAS DISTINTAS FALLA UN COMPILADO (strace del propio store)
+===============================================================================
+$ strace -f -e trace=file cc -O2 -c bench_connector.c   (gcc 16 del host, 12 KB de fuente)
+llamadas de fichero trazadas : 2997
+llamadas que fallan ENOENT   : 672
+RUTAS DISTINTAS que fallan   : 381
+  de ellas bajo /usr /lib /opt: 352
+rutas distintas con acierto  : 260