# SDD 25 — Las tasas del kernel: qué cobra Linux, cuánto, y por dónde se esquiva Escrito 2026-08-29. Nace de una pregunta del operador: *«¿hay cómo crear módulos para kernel para optimizar cosas que son estándares y por eso no se cambian — por ejemplo una API más cercana del control de procesos sin que sandokan use `/proc`? Con tawasuyu ponerse de acuerdo para pasar por debajo de mecanismos pesados POSIX.»* `/proc` y sandokan eran el ejemplo, no el alcance. Lo que sigue es **la lista entera de tasas que Linux cobra por compatibilidad histórica, medida en esta máquina**, y para cada una: si se esquiva, con qué, y quién paga hoy. El espejo de este documento para el lado tawasuyu es `tawasuyu/HANDOFF-TASAS-KERNEL-DESDE-HAMMER.md` (mismo patrón que ADR 0010 ↔ `HANDOFF-arranque-grafo.md`). ## 0. La respuesta corta 1. **Un módulo cargable no es una opción en hammer y no hace falta.** Las tres recetas de kernel (`linux-generic.toml:43`, `linux-metal.toml:104`, `linux-metal-dual.toml:51`) configuran `-d MODULES`: kernel monolítico. `linux.toml` no lo apaga pero sólo compila `bzImage`, nunca `make modules`. Cualquier código de kernel propio en hammer es **built-in**, o sea un parche a la receta — que además es lo correcto para el proyecto, porque la fase `configure` **es** la identidad del artefacto (SDD 22) y así queda sellado y atestado junto al kernel. 2. **Casi todo lo que duele ya tiene salida en mainline y nadie la está usando.** `pidfd`, `cgroup` v2 agregado, `posix_spawn`, `getdents64`, `close_range`, `openat2`. Cero código de kernel, ganancias de 3× a 17× medidas abajo. 3. **La medición encontró un bug cruzado que no se buscaba**: los kernels de hammer se construían **sin `CONFIG_MEMCG`**, y arje/sandokan escriben `memory.max` ignorando el error. Un límite de memoria pedido por una Card **no se aplicaba y nadie se enteraba**. §4. Confirmado sobre los **11 `.config` sellados**, no deducido del defconfig (§4.bis), vigilado por `hammer kernel contract` (§4.ter) y **PAGADO el 2026-08-30**: los cuatro kernels que hospedan Cards se reconstruyeron con `MEMCG` y `PSI` y el barrido sale **5 de 5 vigentes en verde** (§7-H1). 4. **Sondear `/proc` no es lento, es ciego** (§9.1): de 200 procesos de vida mínima, un sondeo cada 100 ms vio **cero**; el proc connector, los 200. Y **el «copiar en O(1)» existe pero no en ext4**: en xfs/btrfs un reflink de 512 MiB tarda 0,020 ms contra 512,7 ms de `read+write` (§9.2). 5. **La jaula de harkaq cobra por syscall, y cobra poco** (T17/T18, medido 2026-09-01): su filtro seccomp pone +12,4 ns por syscall **sin importar cuántas entradas tenga la denylist** —el bitmap de acción constante del kernel hace que el largo sea gratis; mirar un argumento, no—, y Landlock pone +473 ns por `open`, de los cuales sólo +225 se deben a pasar de 1 regla a las ~17 000 de 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 apagado, y H7 ya no dice sólo que está apagado: **cuesta +14,97% de bzImage** (+2,42 MiB, medido construyendo el 2026-08-31 — 31 veces lo que costó MEMCG+PSI) más `pahole` y `python3` en el lab. Los dos lados del balance están sobre la mesa; la decisión no. ## 1. Método y condiciones — leer antes de citar un número - **Máquina**: `momento`, KVM sobre AMD EPYC-Rome, 4 vCPU, 7,7 GiB RAM, Linux 7.1.4, glibc/Artix. Store en `/dev/sdb` (ext4), árbol en `/dev/sdc` (ext4), `/tmp` tmpfs. - **Mitigaciones activas**: retpolines (`spectre_v2`), retbleed, SSBD por prctl. **Sin PTI** (AMD, `meltdown: Not affected`). En un Intel con PTI el piso de syscall sería peor — cuánto, no se puede averiguar acá: **este invitado tampoco expone PCID**, y sin PCID el PTI forzado mide el peor caso y no el de una máquina real (§9.6). - **La máquina estaba CARGADA**: load 7,3 sobre 4 vCPU, un build de LLVM en vuelo, 773 MiB libres. ⇒ **los absolutos son cotas superiores; la señal está en los cocientes dentro de una misma corrida**, que es como está escrito todo lo de abajo. - Cada medida son 7–9 rondas; se reporta **mínimo** (mejor caso, el estimador robusto al ruido del scheduler) y **mediana**. Las corridas marcadas «pinneado» fijan el proceso a un cpu para sacar de en medio la cola del planificador. - **SEGUNDA CORRIDA, mismo día, con root** (§9): la máquina estaba mucho más suelta —load ~1,3 sobre 4 vCPU y 4,3 GiB libres— y por eso **los absolutos de las dos corridas NO se comparan entre sí**. Cada cociente que se cita abajo sale de una sola corrida, y se dice de cuál. - Fuentes y salidas crudas: `docs/evidencia/tasas-kernel-2026-08-29/`. - **Piso de referencia**: una syscall nula (`getppid`) cuesta **78,7 ns**. El vDSO (`clock_gettime`) cuesta 31,2 ns y por syscall 143,8 ns ⇒ **cruzar al kernel son ~110 ns**. Todo lo de abajo se lee en «syscalls equivalentes». ## 2. La lista ### T1 · `fork()` copia el espacio de direcciones del padre | padre con … | `fork+_exit+wait` | `posix_spawn+wait` | |---|---|---| | 0 MB tocados | 109 µs | 231 µs | | 64 MB | 938 µs | 234 µs | | 256 MB | **3 949 µs** | **231 µs** | ≈ **59 ns por página de 4 KiB del padre**, ida y vuelta. `posix_spawn` (que usa `CLONE_VFORK|CLONE_VM`) es **plano**: no mira el tamaño del padre. A 256 MB, **17× más barato — haciendo estrictamente más trabajo** (además ejecuta un binario). Es la tasa POSIX por excelencia: `fork` no se puede arreglar sin romper la semántica, y la salida está dentro del propio POSIX. ### T2 · `exec` + enlazado dinámico | | µs/exec | |---|---| | musl ESTÁTICO | **77,4** | | glibc ESTÁTICO | 232,7 | | glibc DINÁMICO | 324,3 | `ld.so` cuesta **+92 µs** (+39%). Pero el salto grande es glibc→musl: **4,2× entre el peor y el mejor**, con el mismo `main(){return 0;}`. El corpus de hammer ya está del lado bueno (verificado: los binarios del store son `statically linked`); esto **cuantifica** lo que la de-Alpinización compró: un build que lanza 20 000 procesos se ahorra ~5 s sólo en arranque. ### T3 · `/proc` es una API de TEXTO, y se paga como tal | | ns | = syscalls | |---|---|---| | `getrusage(RUSAGE_SELF)` | 238 | 3 | | `clock_gettime(PROCESS_CPUTIME)` | 214 | 3 | | `pread`+parse `/proc/self/stat` (fd ya abierto) | 1 271 | 16 | | `open`+`read`+parse `/proc/self/stat` | **2 692** | **34** | | `open`+`read`+parse `/proc/self/status` (VmRSS) | **4 643** | **59** | Leer el CPU propio por `/proc` cuesta **11× más** que `getrusage`. Y la mitad del coste es abrir la ruta, no el texto (1 271 vs 2 692 con el fd cacheado). **Barrido completo** (291 procesos vivos): `readdir` solo **106 µs**; `readdir` + `stat` de cada pid **991 µs**, o sea **3,4 µs por proceso**. Ese barrido tiene alternativa medida: un iterador BPF hace el mismo trabajo por **0,80 µs por proceso** contra 5,54 µs del mismo barrido medido en esa corrida (§9.5), pero exige BTF (H7). ### T4 · Contabilidad por unidad: cgroup v2 es O(1) donde `/proc` es O(procesos) Leer `cpu.stat` + `memory.current` + `pids.current` de un cgroup: **4,6 µs**, y **no depende de cuántos procesos tenga la unidad**. Sumar `/proc` para esa misma unidad: 3,4 µs × N. **El cruce está en N = 2.** Para una unidad de 20 procesos, 15× a favor del cgroup. ### T5 · Liveness por `/proc/`: la carrera, no la lentitud | | ns | |---|---| | `access("/proc/")` — lo que se usa hoy | 345 | | `kill(pid, 0)` | 119 | | `pidfd_send_signal(fd, 0)` | 119 | `pidfd` **no es más rápido que `kill(0)`**; es 2,9× más rápido que la ruta `/proc`. Su valor real es otro: **no hay reuso de PID**, y el fd se vuelve legible al morir el proceso ⇒ entra en el `epoll` y el supervisor deja de sondear. ### T6 · Señales: la notificación más cara de POSIX `kill(self,SIGUSR1)` + handler = **1 165 ns = 15 syscalls**. Un `eventfd` en el bucle de eventos hace el mismo trabajo por una fracción. ### T7 · Despertar cuesta µs; sincronizar en usuario cuesta ns | | ns | |---|---| | `pthread_mutex` lock+unlock sin disputa | **8,6** | | RTT `eventfd` (2 hilos, mismo cpu) | 2 507 | | RTT `pipe` | 2 956 | | RTT `socketpair` AF_UNIX | 3 639 | **291× entre sincronizar en espacio de usuario y despertar por el kernel.** El bus de arje (postcard sobre socket Unix) paga ~3,6 µs por ida y vuelta: aceptable por mensaje, ruinoso por campo. Adyacente: `sched_yield` 174 ns; `nanosleep(1 ms)` se pasa **65 µs** bajo carga ⇒ los lazos de control no se construyen sobre `sleep`. ### T8 · `stat()` es tasa del VFS, no del filesystem | ns/fichero | ext4 | tmpfs | |---|---|---| | `stat()` por ruta completa | 648 | 714 | | `fstatat(dirfd, nombre)` | 439 | 489 | | `statx(…, DONT_SYNC)` | 467 | 593 | | `openat`+`close` | 899 | 961 | | **`getdents64` (barrido, sin stat)** | **168** | **59** | **ext4 ≈ tmpfs en `stat`.** ⇒ una tormenta de `stat` **no se arregla cambiando de filesystem**; se arregla no haciéndola: `getdents64` da el barrido **3,9× más barato en ext4 y 12× en tmpfs**, y `fstatat` relativo a un `dirfd` ahorra 32% sobre la ruta completa. **Profundidad de ruta**: 12 componentes extra cuestan +205 ns en ext4 y +203 en tmpfs ⇒ **~17 ns por componente**, idéntico en ambos. Otra vez: el VFS, no el disco. ### T9 · `openat2` con `RESOLVE_BENEATH` es casi gratis ext4: 1 025 vs 899 ns (+14%). tmpfs: 763 vs 961 ns (midió *más rápido*, o sea dentro del ruido). ⇒ **la resolución hermética de rutas no tiene precio apreciable.** Es material directo para harkaq (SDD 16): la garantía «no se sale del árbol» sin demonio auxiliar y sin una syscall extra. ### T10 · Materializar un árbol: hardlink vs copia | 2 000 ficheros de 1 byte | ext4 | tmpfs | |---|---|---| | `link()` | 9,2 µs | 2,9 µs | | `open+read+write+close` | 142,2 µs | 5,0 µs | | **cociente** | **15,4×** | 1,7× | Lo que hace cara la copia es el **journal de ext4**, no el VFS. La estrategia de hardlinks de `.dmerge` está bien elegida — y su contrapartida ya está documentada (la caché retiene lo que se borra del store). ### T11 · `copy_file_range`/`sendfile` no ganan sobre ext4 64 MiB en caché: `read+write` 2 837 MB/s · `sendfile` 3 345 · `copy_file_range` 3 521. ~20% en el mejor caso. **ext4 no tiene reflink**, así que `copy_file_range` cae a una copia dentro del kernel. El «copiar en O(1)» que la gente espera **exige un FS con reflink** (xfs/btrfs) — sin medir acá, §9. ### T12 · io_uring no es una ganancia universal | | ns/op | |---|---| | syscall nula | 78,7 | | **io_uring NOP en lote (piso de operación)** | **47,7** | | `pread` 4K en caché (ext4) | 540 | | io_uring read 4K profundidad 32 (ext4) | 594 | | `pread` 4K (tmpfs) | 602 | | io_uring read 4K (tmpfs) | **1 672** | El piso por operación es **0,61× una syscall**: en lote, io_uring amortiza el cruce. Pero sobre datos ya cacheados **pierde** — 10% en ext4 y **2,8× en tmpfs**. io_uring paga cuando las operaciones son muchas, agrupables y **de verdad bloquean**. Sobre caché es una pesimización. ### T13 · THP puede ser 3× PEOR (y acá lo es) 64 MiB anónimos, ns por página de 4 KiB: `MADV_HUGEPAGE` **3 046** · `MADV_NOHUGEPAGE` **1 013** · `MAP_POPULATE` **752**. La causa está en la máquina, no en el kernel: `/sys/kernel/mm/transparent_hugepage/defrag = madvise` ⇒ una región con `MADV_HUGEPAGE` dispara **compactación síncrona** en el fallo de página, y con 773 MiB libres eso es caro. **THP bajo presión de memoria es un pasivo de latencia, no una optimización.** Reproducido en las dos corridas independientes (128 MiB y 64 MiB). `mmap`+`munmap` de 4K: 1 664 ns. ### T14 · El sandbox por namespaces cuesta ~1 ms por proceso `fork+exec` base 1 649 µs · con `unshare` de 6 namespaces 2 742 µs · con `USER|MNT` 2 050 µs. ⇒ **+1,09 ms** por el juego completo, **+0,40 ms** por USER|MNT. Para hammer es ruido por fase de build (segundos a minutos). Sería caro **sólo si se enjaulara por-exec**: 20 000 execs × 1,09 ms = 22 s. ### T15 · Cerrar descriptores antes del `exec` `dup+close` uno a uno: 0,2 µs/fd ⇒ 512 fds = 102 µs. `close_range(lo,hi)`: por debajo del ruido. Gratis, y ya está en mainline. ### T16 · Hilo vs proceso `pthread_create`+`join` = **11,9 µs**, contra 109 µs de `fork+_exit` con el padre vacío: **9×**, y la distancia crece con el tamaño del padre (T1). ### T17 · La jaula cobra POR SYSCALL — y el filtro largo no es lo que cobra T14 midió el sandbox por **namespaces**, que cobra por proceso. Adentro, harkaq (SDD 16) pone dos cosas más que cobran por **syscall**, que es la unidad en la que un build hace millones: un filtro seccomp y un ruleset Landlock. Medido con `bench_jaula.c` → `jaula.txt` (2026-09-01, mismo `momento`, 9 rondas, mínimos; **los cocientes son todos de esa corrida**). El filtro de `harkaq-exec.c` es una **denylist**: una cadena lineal de `BPF_JEQ` sobre `seccomp_data.nr` con 25 entradas y, al final, `RET ALLOW`. O sea que **toda syscall legítima recorre la cadena entera** antes de que la dejen pasar: la peor posición posible para el caso más frecuente. La intuición dice que eso se paga y que hay que ordenar la lista por frecuencia. | filtro | ns/`getppid` | vs piso | |---|---|---| | sin filtro | **75,0** | — | | denylist de 4 | 86,9 | +11,9 | | **denylist de 25 (la de harkaq)** | **87,4** | **+12,4 (+17%)** | | denylist de 64 | 88,5 | +13,5 | | denylist de 128 | 86,6 | +11,6 | | denylist de 250 | 88,3 | +13,3 | **Plano.** Un filtro 62 veces más largo cuesta lo mismo. La razón es que desde 5.11 el kernel arma un **bitmap de acción constante**: `seccomp_is_const_allow()` lee el programa BPF instrucción por instrucción y, para cada número de syscall cuyo veredicto no dependa de los argumentos, marca un bit — y a partir de ahí **el programa no se ejecuta nunca más**. El filtro de harkaq usa sólo `BPF_LD|W|ABS` sobre `nr`/`arch`, `BPF_JMP|BPF_JEQ|BPF_K` y `BPF_RET|BPF_K`, que es exactamente lo que ese analizador sabe seguir. Los +12 ns que quedan son el bit, no la cadena. **Y no es fe, es un control.** El mismo banco instala un **gemelo** de la misma longitud con una instrucción de más al principio —una carga de `args[0]`— que hace que el analizador se rinda. Mismo largo, misma cadena, sin bitmap: | gemelo que mira `args[0]` | 4 | 25 | 64 | 128 | 250 | |---|---|---|---|---|---| | ns/`getppid` | 111,6 | 104,4 | 111,1 | 121,4 | **139,9** | Ahí sí crece: **0,158 ns por instrucción de la cadena**. Si los dos hubieran salido planos, la medición no discriminaría y no diría nada; salen distintos, así que el bitmap existe y es lo que paga la cuenta. **La regla, que es lo que hay que llevarse**: un filtro seccomp es gratis **mientras no mire un argumento**. El día que alguien quiera filtrar `ioctl` por el número de petición o `clone` por sus flags, ese número de syscall —y sólo ése— se cae del bitmap y pasa a pagar la cadena entera en cada llamada. La longitud de la denylist no es la variable; **mirar argumentos, sí**. **El bitmap se paga al instalar, y se cobra por proceso, no por syscall:** | N | instalar la denylist (arma bitmap) | instalar el gemelo (no lo arma) | |---|---|---| | 4 | 26,5 µs | 14,4 µs | | **25 (harkaq)** | **69,8 µs** | 21,7 µs | | 64 | 140,6 µs | 26,5 µs | | 128 | 252,8 µs | 31,6 µs | | 250 | 395,4 µs | 52,2 µs | ≈ **1,5 µs por entrada de la denylist**, porque el kernel recorre el programa entero una vez por cada número de syscall. A N=25 el bitmap cuesta 48,1 µs de más al instalar y ahorra 17,0 ns por syscall ⇒ **se amortiza en 2 830 syscalls**, que es una invocación y media de gcc (§9.4: 1 803). Y harkaq lo instala **una vez por fase de build**, no por proceso: el filtro y su bitmap se heredan por `fork`/`exec`, así que lo paga el primer proceso de la fase y lo cobran los miles que siguen. **Dos cosas del filtro de harkaq que salieron de mirarlo para medirlo, y que no son de rendimiento:** 1. **`jt`/`jf` de la BPF clásica son `__u8`.** En la forma de `poner_seccomp()` el salto más largo mide N+3, así que la denylist tiene un **techo de ~252 entradas**: pasado eso el desplazamiento da la vuelta y el filtro deja de decir lo que dice su código fuente. Hoy son 25 y no hay problema; el problema es que crecer la lista es justo lo que uno hace sin pensarlo. 2. **`f[k++] = BPF_JUMP(..., I_KILL - k - 1 + 1)` modificaba y leía `k` en la misma expresión** — sin punto de secuencia, o sea UB en C11. Funcionaba porque gcc y clang leen `k` *después* del incremento, y el `+1` final era exactamente la compensación de eso. Si algún compilador leyera antes, el salto caería una instrucción más allá del final: el verificador clásico lo rechazaría y harkaq saldría por su rama de `RECHAZO` — falla ruidoso, no silencioso, pero apoyado en UB. **CERRADO el 2026-09-01**: el índice se fija en `I_ARCH` antes de usarlo. **El código generado es idéntico** —mismo `objdump` del objeto a `-O1` y a `-O2`, la única línea que difiere es el nombre del fichero— y el binario parcheado sigue poniendo la jaula: `ptrace` desde dentro da `EPERM` y un ejecutable fuera de la clausura no arranca. Que el desensamblado no cambie es lo que prueba que era exactamente la misma cuenta escrita sin UB, y no un arreglo que además mueve el salto. ### T18 · Landlock: el precio es ESTAR enjaulado, no cuántas reglas hay La política de harkaq se deriva **fichero a fichero** (D1: adentro, la dep declarada y el rootfs Alpine comparten `/usr`, así que una regla sobre `/usr` concedería las dos y la evidencia dejaría de significar algo). Una dep como boost son 15 829 ficheros ⇒ **~17 000 reglas**. La pregunta obvia es si un ruleset así encarece cada `open`. | ruleset | ns/`open`+`close` | vs piso | |---|---|---| | sin Landlock | **791,7** | — | | 1 regla | 1 264,7 | **+473 (+60%)** | | 16 reglas | 1 278,4 | +487 | | 256 reglas | 1 309,3 | +518 | | 4 096 reglas | 1 365,9 | +574 | | **16 384 reglas (~boost)** | **1 489,5** | **+698 (+88%)** | **Todo el precio está en el primer escalón.** Estar bajo Landlock cuesta +473 ns; multiplicar el ruleset por 16 384 agrega +225 ns más (+18% sobre el `open` ya enjaulado). La forma es la de una búsqueda en árbol, no la de un barrido. **La decisión de diseño discutible de harkaq —enumerar la clausura fichero a fichero en vez de conceder directorios— resulta ser casi gratis**, y eso ahora está medido en vez de supuesto. Dos medidas más, con 16 384 reglas puestas: - **`stat` no paga**: 522,8 ns sin Landlock, 508,4 con — o sea nada, dentro del ruido. Landlock no engancha `getattr`, sólo la apertura y la modificación de rutas. Importa porque `stat` es la syscall que un build hace más (T8), y porque el instinto es suponer que la jaula toca todo. - **Un `open` DENEGADO sale más barato que uno concedido**: 1 081,1 contra 1 489,5. Se midió esperando lo contrario —el peor caso, subir el árbol hasta la raíz sin encontrar regla— y el resultado es que fallar antes de montar el descriptor ahorra más de lo que cuesta buscar. **La denegación no es un riesgo de coste**, que era la duda que justificaba medirla. **Armar el ruleset sí es lineal**: 30,46 ms para 16 384 reglas (**1,86 µs por regla**, un `open` `O_PATH` + un `landlock_add_rule` + un `close` cada una). Es precio **por fase de build**, igual que el filtro: 30,5 ms contra una fase que dura de segundos a minutos. Pero es la misma advertencia de T14 con otro número: **si algún día se enjaulara por-exec**, 20 000 execs × 30,6 ms = **10 minutos** de puro armado de jaula. ### T17+T18 · Lo que la jaula le cuesta a un compilado, en total Con los números de §9.4 (un `gcc` = 1 803 syscalls y 228 `openat` sobre 30,5 ms de compilado): | | cuenta | µs | |---|---|---| | seccomp | 1 803 syscalls × 12,4 ns | 22,4 | | Landlock | 101 `open` que llegan al hook × 473–698 ns | 48–70 | | **total** | | **70–93 µs sobre 30 500 µs = 0,23–0,31%** | Las 127 `openat` que fallan con `ENOENT` no llegan al hook de Landlock —la resolución de ruta falla antes—, así que la cota alta contando las 228 sería 0,43–0,60%. **En cualquiera de las dos lecturas, el grano fino de harkaq gasta menos de la décima parte del presupuesto de <5% de SDD 16**, 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. ### 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: 1. **THP más lento que 4K** (T13) — el manual dice que THP acelera; con `defrag=madvise` y memoria apretada, triplica el coste del fallo. 2. **io_uring más lento que `pread`** sobre tmpfs (T12) — el manual dice que io_uring es el futuro; sobre datos cacheados el futuro pierde 2,8×. 3. **`stat` cuesta lo mismo en tmpfs que en ext4** (T8) — invalida la reacción natural («movamos el árbol a tmpfs y vuela»): el coste está en el VFS. 4. **`taskstats` binario NO es más rápido que parsear `/proc`** (§9.1) — 2 688 vs 2 697 ns por consulta. La intuición «binario le gana a texto» falla porque el ida y vuelta de netlink cuesta lo mismo que `open`+`read`+parse. Su valor es el **contenido** (delay accounting, que `/proc` no da) y la estabilidad bajo carga, no la velocidad. 5. **Sondear `/proc` no es lento: es CIEGO** (§9.1) — de 200 procesos de vida mínima lanzados uno cada 10 ms, un sondeo cada 100 ms vio **cero**. Toda la discusión de «cuánto cuesta el barrido» (T3) presupone que el barrido VE lo que hay, y para procesos cortos no lo ve. 6. **Un filtro seccomp más largo NO cuesta más** (T17) — el consejo de manual es acortar la lista y ordenarla por frecuencia; con el bitmap de acción constante, 4 entradas y 250 valen lo mismo. 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. **Lado hammer.** Las recetas hacen `make ARCH=x86_64 defconfig` y después `scripts/config`. Lo que `arch/x86/configs/x86_64_defconfig` trae de contabilidad: `CONFIG_TASKSTATS=y`, `TASK_DELAY_ACCT=y`, `CGROUPS=y`, `CGROUP_SCHED=y`, `CGROUP_PIDS=y`, `CGROUP_FREEZER=y`, `CONNECTOR=y` ⇒ y `PROC_EVENTS` es `default y` con `depends on CONNECTOR=y`, así que **también entra**. **`CONFIG_MEMCG` no está en el defconfig y no tiene `default y` ⇒ queda en `n` en todos los kernels de hammer.** `CONFIG_PSI`, igual. **Lado tawasuyu.** `sandokan-core::Engine` expone `set_memory_max` / `set_memory_high` (`shared/sandokan/sandokan-core/src/engine.rs:60-72`), `sandokan-local` los delega a `arje_incarnate::cgroup` (`03_ukupacha/sandokan/sandokan-local/src/lib.rs:675-681`), y ahí: ```rust // arje/init/arje-incarnate/src/cgroup.rs:60 let _ = std::fs::write(abs.join("cpu.weight"), format!("{w}\n")); // error DESCARTADO // :79 match std::fs::write(&path, format!("{mem}\n")) { Err(e) => tracing::warn!(..., "memory.max write failed"), // sólo un warn ``` **Consecuencia.** Sobre un sistema hammer, una Card que pide un tope de memoria arranca **sin tope**, y la única huella es una línea de log. Es exactamente la forma de fallo que `CLAUDE.md` §3 nombra: *un ausente falla ruidosamente; un vacío llega hasta el final diciendo que todo fue bien*. La jaula que no está se comporta igual que la jaula que sí está, hasta que hace falta. ### 4.bis · Confirmado sobre los artefactos, no deducido del defconfig Lo de arriba se dedujo leyendo `x86_64_defconfig`. Se comprobó después contra los **`.config` que las recetas instalan** en `boot/config-`, que es la prueba dura porque incluye lo que hizo `olddefconfig`. Los **11 kernels sellados del store** —cinco nombres de artefacto: `linux`, `linux-generic`, `linux-metal`, `linux-metal-dual`, `linux-gioser`— traen literalmente: ``` # CONFIG_MEMCG is not set # CONFIG_PSI is not set ``` y ninguno tiene `CFS_BANDWIDTH` (⇒ `cpu.max`, la cuota dura de CPU, tampoco existe; hoy nadie la pide, pero cuando `pacha` la quiera será otro re-hasheo). Lo que **sí** está en los once: `CGROUP_SCHED`+`FAIR_GROUP_SCHED` (`cpu.weight`), `CGROUP_PIDS` (`pids.max`), `CPUSETS` (`cpuset.cpus`), `BLK_CGROUP`+`BLK_CGROUP_IOCOST` (`io.weight`, con la sintaxis «default N» que arje ya usa), `TASKSTATS`+`CONNECTOR`+`PROC_EVENTS`, `SECCOMP_FILTER`, `SECURITY_LANDLOCK`, `FANOTIFY`, `OVERLAY_FS` con metacopy e `IO_URING`. **Y por qué el bug sobrevivió meses**: el kernel de la máquina de desarrollo (Artix 7.1.4) **sí** trae `MEMCG`, `PSI` y `CFS_BANDWIDTH`. El fallo sólo existe del lado del artefacto sellado, que es el lado que nadie mira con un depurador. ### 4.ter · El guardián: `hammer kernel contract` Un hallazgo escrito en un documento no impide que vuelva a pasar. El contrato vive en `docs/state/kernel-contract.toml` y declara, por capacidad: los símbolos que la construyen, la interfaz que aparece o no aparece (`memory.max`, `pids.max`, `landlock_restrict_self`…), **el consumidor con ruta y símbolo**, y **cómo falla hoy si no está**. Se comprueba contra un `.config` ya producido: ```sh hammer --store ./store kernel contract --sealed # barre los kernels sellados hammer kernel contract --profile anfitrion-cards # el kernel vivo de esta máquina hammer kernel contract --list # qué promete hammer (esto es el W6) ``` Tres decisiones que valen la pena nombrar: 1. **Por perfil, no global** — misma lección que el gate de hardware. `linux.toml` es el kernel de QEMU del selfhost-verify: no hospeda Cards y su hash es load-bearing del baseline `of_tree`. Exigirle contabilidad de memoria sería rechazar una receta sana, así que ahí los límites por unidad son `wants` y no `requires`. 2. **Contra el `.config`, no contra la receta** — entre el `scripts/config -e X` y el `.config` está `olddefconfig`, que puede tragarse el símbolo sin decir nada (es justo lo que `hammer kernel diff-back` ya vigila para los planes). 3. **Distingue apagado de ausente** — un símbolo que el `.config` ni nombra puede ser un renombrado entre versiones. Cuenta igual como capacidad ausente, pero se informa aparte; y un kernel sin perfil declarado queda **SIN COMPROBAR** en vez de contar como aprobado. 4. **Distingue vigente de superado** (agregado al pagar H1) — el store guarda **todos** los sellados, no el último. Mirándolos a todos por igual, arreglar la receta dejaba el gate rojo para siempre por artefactos que nadie va a volver a construir, y **un portón que no puede ponerse verde deja de leerse**. Ahora cada sellado se compara con el `ArtifactHash` que la receta de hoy produce —la misma vigencia de `hammer hash --check`, lab incluido—: el vigente bloquea; el superado sale en su propia sección con lo que le falta, porque sigue siendo cierto que una máquina que arranque ese kernel corre sus Cards con lo que ese kernel traiga. Y dos negativas explícitas: si la vigencia no se puede resolver se comprueba **todo** y se dice por qué, y **cero vigentes con superados a la vista sale ≠0**, no verde — el vacío leído como presencia (CLAUDE.md §3) es justo el fallo que este guardián vino a arreglar. Estado al escribirlo: **2 de 11 configs sellados cumplían su perfil** (los dos `linux`), los 9 de perfil `anfitrion-cards` fallaban por `MEMCG`. **Estado tras pagar H1 (2026-08-30): 5 de 5 vigentes cumplen**, y los 10 sellados que quedaron atrás salen listados como superados. El kernel vivo de esta máquina pasa las 11 exigidas. Lo que **sí** funciona en un kernel hammer: `cpu.weight` (CGROUP_SCHED), `pids.max` (CGROUP_PIDS), `cpu.stat` (rstat del core) y `cgroup.freeze` — este último porque el freezer v2 es `obj-y` en `kernel/cgroup/Makefile`, no el `CONFIG_CGROUP_FREEZER` de v1. ## 5. Qué se esquiva SIN escribir kernel | Tasa | Reemplazo mainline | Ganancia medida | |---|---|---| | T1 `fork` de padre grande | `posix_spawn` / `CLONE_VFORK` | 17× a 256 MB, y plano | | T2 `ld.so` | binario estático musl | 4,2× | | T3 `/proc` texto | `getrusage`, `taskstats` | 11× | | T4 sumar `/proc` por unidad | agregado de cgroup v2 | O(1) vs O(N), cruce en N=2 | | T5 liveness por `/proc` | `pidfd` (+ sin carrera de PID) | 2,9× y correctitud | | T3 enumerar procesos | proc connector (netlink): eventos push | **0 vs 200 procesos vistos** (§9.1) | | T6 señales | `eventfd`/`signalfd`/`pidfd` | 15 syscalls → 1 | | T8 tormenta de `stat` | `getdents64`, `fstatat(dirfd,…)` | 3,9×–12× | | T9 hermetismo de rutas | `openat2 RESOLVE_BENEATH` | ~gratis | | T10 materializar árbol | `link()` | 15,4× en ext4 | | T11 copiar un fichero entero | `FICLONE`/reflink **en xfs o btrfs** | **25 600× a 512 MiB** (§9.2) | | T15 cerrar fds | `close_range` | 102 µs → ~0 | **Ninguna de estas exige un módulo, un parche ni un kernel propio.** Es la primera conclusión del documento y la que ordena todo lo demás. ## 6. Qué exigiría tocar el kernel — y su precio en hammer Después de agotar §5 quedan tres deseos legítimos que mainline no da: 1. **Una syscall que devuelva un lote de estadísticas por proceso en binario.** Lo más cercano es `taskstats` (por tarea, netlink) y el agregado de cgroup. No existe el «dame los 291 en una llamada». 2. **Eventos de estado con más contexto que el proc connector.** 3. **Un plano de control de unidades que no pase por el filesystem.** Su precio, concreto: - **Es built-in, no `.ko`** (`-d MODULES`). ⇒ parche a la receta ⇒ entra en `hash_inputs` ⇒ kernel nuevo, artefacto nuevo, y rebase manual en cada versión. - **Nunca un número de syscall nuevo**: choca con upstream para siempre. Lo compatible es un char device, un fichero propio en sysfs, o una kfunc BPF. - **Con eBPF (que es la forma sin código out-of-tree) el precio es `DEBUG_INFO_BTF`**, hoy apagado en las cuatro recetas con motivo escrito (`linux.toml:32`: *«pahole (BTF), ausente del toolchain»*). Deuda ya medio pagada: dwarves construye (frente libdw/musl cerrado). - **El bloqueo de diseño, que no es técnico**: `SDD-SERVIDOR-AJENO.md` promete *«cero invasión — la máquina sigue siendo su Ubuntu de siempre»*. Un sandokan que dependa de un kernel hammer deja de correr ahí. ⇒ **el núcleo se queda portable siempre; lo rápido es un backend opcional detectado en runtime, con `/proc` como respaldo que no se borra.** ## 7. Propuestas — lado hammer - **H1 · `-e MEMCG -e PSI` en los kernels que hospedan Cards. PAGADO 2026-08-30.** Cierra §4 y habilita `memory.current` (T4) y las señales de presión. Se hizo en **una sola tanda** —el precio era el re-hasheo de todos los artefactos de kernel— sobre `linux-generic`, `linux-metal`, `linux-metal-dual` y la derivada `linux-gioser`, que hubo que **regenerar**: una receta derivada inlinea la fase `configure` de su base y sin regenerarla se queda con la vieja. En `linux.toml` (perfil `qemu-serial`) NO corre: su hash es el baseline del selfhost-verify y ahí no hay Cards. Tres cosas que valen para la próxima: 1. **El `x86_64_defconfig` de 7.1 ya no trae `MEMCG=y`** — de ahí que hiciera falta pedirlo. Y `MEMCG` no tiene un solo `depends on` (sólo `select`s), así que el `olddefconfig` no se lo podía tragar; se comprobó igual **contra el `.config` producido**, que es la regla. 2. **Precio en bytes, medido**: bzImage `linux-generic` 16 856 064 → 16 937 984 (**+80 KiB, +0,49%**), `linux-metal` +84 KiB (+0,57%), `linux-metal-dual` +88 KiB (+0,53%), `linux-gioser` +80 KiB (+0,71%). Contabilidad por unidad y presión, por medio punto porcentual de kernel. 3. **La prueba de que se pagó es el mismo comando**: `hammer kernel contract --sealed` sale 0 y dice `5 de 5 config(s) VIGENTES cumplen su perfil`. La salida cruda quedó en `docs/evidencia/tasas-kernel-2026-08-29/contrato-h1-pagado.txt`, junto con el `diff-back` del plan de gioser sobre el config nuevo (35 cumplidos, 0 incumplidos). - **H2 · `MODULES=n` se queda.** Si algún día hace falta código de kernel, es built-in y parcheado en la receta. Queda escrito para no rediscutirlo. - **H3 · Contención de rutas: el sitio no era harkaq, era la hidratación.** La propuesta original decía «que harkaq use `openat2 RESOLVE_BENEATH` donde hoy valida rutas a mano (T9, ~gratis)». Buscando ese sitio (2026-08-30) resultó que **harkaq no valida rutas a mano**: delega la contención en Landlock, que es más fuerte que cualquier chequeo de ruta. Donde no se validaba **nada** era en `hammer-build/hydrate.rs`. **El fallo, reproducido**: hidratar es `target_fhs.join(rel)` y escribir por ruta ⇒ **un symlink de directorio ya presente en el FHS se sigue**. Un artefacto que trae `usr/share/pkg → /algún/lado` deja ese symlink puesto —los symlinks se replican literales, y así debe ser— y la hidratación siguiente escribía `usr/share/pkg/archivo` **fuera del root, devolviendo `Ok(1)`**. Con `hammer hydrate --into` sobre una imagen, eso es escribir en el sistema anfitrión diciendo que todo fue bien: el fallo de CLAUDE.md §3, otra vez. **Cuánto se estaba disparando: cero.** Del store: **160 symlinks absolutos, ninguno a un directorio**; **~42 000 relativos, ninguno que salga de su artefacto**. Era una mina desactivada, no un incendio — y por eso nadie lo iba a encontrar mirando logs. **HECHO**: `no_escapa()` comprueba la contención **por directorio** (no por fichero: un `canonicalize` por entrada costaría un realpath por fichero, T8/§9.4) y falla nombrando el symlink y adónde lleva. Semántica **`RESOLVE_IN_ROOT`, no `RESOLVE_NO_SYMLINKS`**: un symlink que se queda dentro tiene que seguir andando (usr-merge `/lib → usr/lib`, los `22@2x → 22` de los temas de iconos), y hay un test para cada dirección en `crates/hammer-build/tests/hidratacion_no_escapa.rs`. **Y el mismo patrón estaba en el OTRO proyector**: `hammer-upgrade::project_plan` es `target_root.join(rel)` + escribir por ruta, igual que hydrate. Con `--root /` no cambia nada (todo empieza por `/`), pero `hammer upgrade apply --root /mnt/imagen` es el caso real de armar una imagen: reproducido —la generación 2 escribía fuera del root con `ApplyReport` en verde— y tapado con la misma regla (se mide el **directorio padre**, porque un `Replaced` sobre un symlink que apunta afuera es legítimo: `rename` pisa el symlink, no escribe a través de él). Dos tests, uno por dirección, en `crates/hammer-upgrade/src/lib.rs`. **Lo que queda de la propuesta original**: `canonicalize`+escribir es comprobar-y-después-usar, o sea que un adversario **concurrente** podría cambiar un componente entre las dos. Para el fallo real —un symlink persistente puesto por otro artefacto— alcanza y es determinista. La versión sin carrera es mover las escrituras a `openat2(RESOLVE_IN_ROOT)` + `linkat`/`renameat`, y arrastra el camino de `patchelf` (que necesita una ruta, no un fd). Queda escrito acá con su precio, que es más de lo que se sabía cuando H3 se llamaba «~gratis». - **H4 · Ningún `Command` de hammer pone `pre_exec`** — Rust apaga el camino `posix_spawn` en cuanto hay uno, y cae a `fork+exec` (T1: 17× a 256 MB tocados). `hammer-build/sandbox.rs` lanza `bwrap` sin `pre_exec`, o sea que estaba bien **por suerte**: nadie lo comprobaba. **HECHO**: el test `crates/hammer-build/tests/sin_pre_exec.rs` barre las fuentes del workspace y falla si aparece uno (comprobado en los dos sentidos: inyectando un `pre_exec` el test se pone rojo, y un barrido que no encuentra fuentes también, para que no apruebe por vacío). Si algún día hace falta de verdad, la salida es escribir en el test por qué ese caso paga los 17×, no borrarlo. - **H5 · La materialización de dependencias se queda en hardlink** (T10) y el experimento de reflink (§9) alimenta SDD 18 (wawafs), donde el store como FS vivo cambiaría el cociente. - **H6 · No `madvise(MADV_HUGEPAGE)` en el camino de build** mientras `defrag=madvise` y la granja corra apretada de RAM (T13). - **H7 · `DEBUG_INFO_BTF` sigue apagado — pero ya no por falta de número.** §9.5 midió qué compra: un iterador `task` saca la foto de procesos **cerca de 7× más barato** que el barrido de `/proc` (6,9-8,0× en cuatro corridas y dos órdenes), y —lo que de verdad decide— **al precio de listar el directorio**: 1,75× el piso de `readdir`, contra 12,1× de `/proc`. Leer los campos deja de costar; sólo se paga enumerar. El precio, en cambio, es más grande de lo que decía esta línea («pahole en el lab y un re-hasheo»). Son **cinco** cosas —la quinta salió construyendo, el 2026-08-31— y ninguna es un rediseño: 1. **Los kernels no compilan con información de depuración**: `CONFIG_DEBUG_INFO_NONE=y` en los `.config` sellados. BTF exige DWARF, así que primero hay que encender `DEBUG_INFO_DWARF5`. Eso encarece el build, **no el `bzImage`**: el DWARF vive en `vmlinux`, que la receta no empaqueta — lo único que entra a la imagen es la sección `.BTF`. 2. **pahole está en el catálogo, pero el kernel no lo declara.** `recipes/dwarves.toml` (1.30) se selló al cerrarse el frente libdw/musl, y su propia cabecera lo dice: «El kernel NO la declara todavía». 3. **Ese pahole era DINÁMICO pese a `link = "static"` — PAGADO el 2026-08-31.** El artefacto de entonces (`8057bcd…`) traía `INTERP` y cinco `NEEDED` (`libelf`, `libz`, `liblzma`, `libbz2`, `libc`), y corre **dentro del sandbox del build del kernel**: sin esas librerías ahí no arranca, y el fallo habría salido como «pahole no está» estando. Arreglado al reabrirse el frente de `link = static` (ver el `git log` de `recipes/dwarves.toml`); el artefacto vigente es `v1.30` y estáticamente enlazado. **Y quedó probado de la única manera que vale: el build de medición de abajo lo encontró y lo ejecutó dentro del sandbox**, no en el host. 4. **BTF mete a pahole en el juego de la bit-repro.** El `.BTF` lo emite pahole ⇒ su versión pasa a ser parte de la identidad del artefacto, y el lab **no está en `hash_inputs`**: dos labs con pahole distinto sellarían bytes distintos en la misma dirección sin que el store lo note. Es el agujero conocido, con una herramienta más adentro. 5. **Y una quinta, que no está en ninguna documentación de BTF: hace falta `python3`.** Encender BTF hace que kbuild descienda a `tools/bpf/resolve_btfids`, que compila un **libbpf vendorizado** cuyo Makefile genera `bpf_helper_defs.h` con un script de Python ⇒ `env: 'python3': No such file or directory`, `Error 127`. Salió construyendo, no leyendo. **Y ya hay dos consumidores nombrados, no cero.** El iterador de §9.5, que H8 aceptaría porque trae ruta y símbolo (`bpf_iter_create`, `attach_btf_id` resuelto contra el BTF del vmlinux); y **sched-ext**, que es por lo que existe la receta de dwarves — `SCHED_CLASS_EXT` depende de `DEBUG_INFO_BTF`. El primero está medido; el segundo todavía no tiene su símbolo. **El precio en bytes, MEDIDO el 2026-08-31** (`docs/evidencia/tasas-kernel-2026-08-29/btf-precio.txt`). Se construyó una copia derivada de `linux-generic` con una sola diferencia en el `.config` —`DEBUG_INFO_DWARF5` + `DEBUG_INFO_BTF`— y todo lo demás byte a byte igual: | | bzImage | delta | |---|---|---| | `linux-generic` sellado (sin BTF) | 16 937 984 B | — | | el mismo con DWARF5 + BTF | 19 473 408 B | **+2 535 424 B = +2,42 MiB = +14,97%** | La sección `.BTF` del `vmlinux` son **7,52 MiB** sin comprimir (`.BTF_ids`, 2 344 B más) y entran al `bzImage` comprimidos 3,11×. **Contra la única vara comparable que tenemos: H1 costó +80 KiB (+0,49%) del mismo bzImage ⇒ BTF cuesta 31 veces lo que costó H1.** Eso no lo decide, pero lo saca del terreno de «un re-hasheo y ya»: un +15% de imagen es una discusión de producto, no de toolchain, y hay que tenerla contra los dos consumidores nombrados. **Sobre `linux-generic` y no sobre `linux`, que es más barato de construir**: BTF tiene `depends on BPF_SYSCALL` y el `.config` sellado de `linux` lo trae **apagado**, así que medir ahí habría obligado a encender las dos cosas a la vez y el delta mezclaría dos precios. **El número de Artix no servía — y falla en la dirección contraria a la esperable.** Esta sección decía que sus 6,4 MB «no dicen nada de uno monolítico y pelado». Cierto, pero el nuestro sale **más grande**: 7,5 MiB. Artix es **modular** y el `.BTF` de su `vmlinux` cubre sólo el core built-in; el nuestro es monolítico (i915 + nouveau + radeon + iwlwifi + todo lo demás son `=y`) y los tipos de todos esos drivers entran. **Un kernel con menos drivers cargables tiene MÁS BTF que uno enorme lleno de módulos.** **Lo que cuesta construirlo no es lo que se paga al arrancar**: el `vmlinux` con DWARF pesa 561 MiB —396 de ellos `.debug_info`— y el árbol de build llegó a 7,9 G; el build tardó 48 min con `-j2`. Nada de eso entra en la imagen: la receta empaqueta `bzImage`, no `vmlinux`. Es precio de build (tiempo, disco y la RAM de pahole), no de arranque. **Y una trampa del kconfig que hay que dejar escrita, porque es CLAUDE.md §3 dentro de `make`:** `scripts/pahole-version.sh` imprime **`0`** si pahole no está en el PATH, `depends on PAHOLE_VERSION >= 122` deja de cumplirse, y **`olddefconfig` borra la línea en silencio**. El build sale **OK** y sella un kernel **sin BTF** diciendo que todo fue bien. Por eso la fase `configure` de la receta de medición comprueba el `.config` **producido** y sale 1 si no está — la misma regla de §4.bis, aplicada un escalón antes. Salió `OK: CONFIG_DEBUG_INFO_BTF=y`, que es lo que paga la parte (3): ese pahole estático **se encuentra y se ejecuta dentro del sandbox**. El artefacto de medición **se borró a propósito** tras anotar los números: `hammer kernel contract --sealed` lo listaba como SIN COMPROBAR —no declara `[[target]]`, que es justo lo que exige H8— o sea un ⚠ permanente en `docs/state/kernel-contract.txt`, que el cron commitea cada 30 min. Un aviso fijo que no corresponde a ningún problema es exactamente cómo se deja de leer un vigía. La receta derivada queda junto a la evidencia, con las instrucciones para reconstruirla. - **H10 · El filtro de harkaq se queda como está — y ahora se sabe por qué.** T17/T18 midieron el grano fino de la jaula y la conclusión es que **no hay nada que optimizar**: 0,23–0,31% de un compilado, contra el ~1,09 ms por proceso de los namespaces (T14) que es diez veces más. Tres cosas que sí quedan escritas para que no se rompan por accidente: 1. **No reordenar la denylist «por frecuencia» ni recortarla «para que sea rápida».** Es plana en su longitud, por el bitmap de acción constante. Es trabajo cero con riesgo de romper la política. 2. **Un filtro que mire argumentos deja de ser gratis, y sólo para las syscalls que mire.** Si algún día hace falta (filtrar `ioctl` por petición, `clone` por flags), el precio es la cadena entera en cada llamada de esa syscall — 0,158 ns por instrucción, medido. Se decide con ese número delante, no con la intuición de que «un filtro es un filtro». 3. **La denylist tiene un techo de ~252 entradas** por el `__u8` de los saltos de la BPF clásica. Hoy son 25. **HECHO**: un `_Static_assert` en `harkaq-exec.c` lo cierra, para que crecer la lista no rompa el filtro en silencio — la misma forma de fallo que CLAUDE.md §3. Y **el UB del punto (2) de T17 también está cerrado**, con el desensamblado como prueba de que no cambia nada más. Y la política **fichero a fichero** de D1 queda confirmada por el lado del coste: cambiarla por reglas de directorio ahorraría 225 ns por `open` de los 698, o sea el 32% de lo barato, a cambio de que la evidencia deje de significar algo. No se toca. - **H8 · El contrato de capacidades es parte del release del kernel** (§4.ter). Toda receta de kernel nueva declara su `[[target]]` con perfil; un kernel sin perfil sale **SIN COMPROBAR**, no aprobado. Y toda capacidad nueva entra con consumidor —ruta y símbolo— o no entra: «estaría bueno tener» no es un contrato. - **H9 · El experimento de reflink cambia el cálculo de SDD 18, no el de hoy.** Con `.dmerge` sobre ext4 el hardlink sigue siendo la elección correcta (T10). Lo que §9.2 agrega es que un store sobre **xfs o btrfs** compraría semántica de copia —mutación privada, sin la contrapartida de 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 > **✅ LAS SEIS CERRADAS (2026-09-02/03).** Detalle y evidencia en el handoff espejo del otro > repo (`tawasuyu/HANDOFF-TASAS-KERNEL-DESDE-HAMMER.md` §3). Lo que hay que saber desde acá: > > - **W4 dejó tres pendientes y se cerraron como W4.bis (2026-09-03).** El grande no era lo que > este documento suponía: en arje el límite **no se descartaba, no se pedía**. El camino > `plain` —el de casi todas las Cards, con los namespaces en `false`— no creaba cgroup, y > `apply_rlimits_to_cgroup` la llamaba sólo shuma, así que arje nunca escribió `memory.max` > ni `pids.max`. Ahora hay una sola puerta (`arje-incarnate::cgroup::preparar`) y el hijo del > camino `plain` entra al cgroup entre `fork` y `execve`. > - **`docs/state/kernel-contract.toml` quedó al día el 2026-09-03**: alta de > `proceso-por-descriptor` (pidfd, que se usa desde W1 y no estaba declarada), `consumer` por > nombre de función sin número de línea, y los `silent` de los cinco cgroups reescritos — > describían un bug ya arreglado, que es la peor forma que tiene un guardián de mentir. > - **Lo que sigue sin evidencia**: el OOM-kill del kernel sobre un Ente de arje. Sólo se puede > ver en una máquina con delegación de cgroups; el clon de desarrollo corre con uid 1001 sobre > la jerarquía de root. En una línea cada una, como se propusieron: - **W1 · `pidfd` para liveness y señales** en `sandokan-local` (hoy `access("/proc/")` y `waitpid(WNOHANG)` en sondeo). Se gana correctitud (carrera de PID) antes que velocidad. - **W2 · Telemetría por unidad desde el cgroup**, no sumando `/proc` (T4). Con **sonda de capacidad**: si `memory.current` no existe (kernel sin MEMCG), decirlo, no callarlo. Del lado hammer esa sonda ya tiene con qué contrastarse: `hammer kernel contract --list --json` publica qué promete cada perfil de kernel (§4.ter), que es el W6 en formato de dato. - **W3 · `arje-incarnate` con `posix_spawn`** donde el `pre_exec` no sea imprescindible (T1), y `close_range` en el que quede. - **W4 · Los errores de cgroup dejan de descartarse.** `let _ = write(...)` y `warn!` sobre un límite no aplicado son el bug de §4, no un detalle de estilo. - **W5 · `/proc` no se borra nunca**: es el respaldo portable que hace posible SDD-SERVIDOR-AJENO. - **W6 · El contrato entre repos es el trait `Engine`, no una ABI de kernel.** hammer publica la lista de símbolos garantizados (verificable con `hammer kernel probe`); tawasuyu elige backend en runtime. ## 9. Los experimentos nombrados — cinco medidos, uno abierto La primera corrida los dejó nombrados y sin medir, todos por privilegios o por herramienta ausente. **Cuatro cayeron el mismo día, con root**, y el quinto —el iterador BPF— el 2026-08-31. Condiciones distintas a la corrida de arriba (load ~1,3 sobre 4 vCPU, 4,3 GiB libres) ⇒ los números de acá **no se cruzan** con los de §2. ### 9.1 · proc connector y `taskstats` — MEDIDO (`bench_connector.c`, `connector.txt`) **a) Latencia.** Esperar el evento `PROC_EVENT_EXIT` del connector **no cuesta más** que el `waitpid` que el padre ya hace: 116,0 µs contra 114,8 µs por `fork+_exit` (mínimo de 7 rondas de 200), es decir dentro del ruido, con **0 eventos perdidos de 1 400**. Con una advertencia dura que salió del propio experimento: el multicast del connector **no reintenta**. Si el buffer del socket se llena, el evento se pierde y quien lo espera se cuelga para siempre — la primera versión del programa se colgó exactamente así. Un supervisor sobre connector necesita `SO_RCVBUF` grande **y un plazo**, no una espera infinita. **b) Cobertura — el resultado que importa.** 200 procesos de vida mínima, uno cada 10 ms: | observador | vistos | CPU | |---|---|---| | sondeo de `/proc` cada 100 ms (23 barridos) | **0 de 200** | 10,5 ms | | proc connector (eventos) | **200 de 200** | 95,4 ms | El sondeo no es caro: es **ciego**. Y la CPU del connector de esa tabla es cota superior — el programa lo lee con espera activa de 200 µs, que no es el modo de un supervisor real (sería `epoll`, donde el coste es despertar por evento). **c) `taskstats`.** La familia genetlink existe y responde. Por consulta: **2 688 ns** contra **2 697 ns** de `open`+`read`+parse de `/proc/self/stat`. **No gana en velocidad** (sorpresa §3.4). Lo que da y `/proc` no: contabilidad de retrasos (delay accounting) y una estructura binaria estable en vez de un texto que hay que parsear posicionalmente. ### 9.2 · Copia con reflink — MEDIDO (`bench_reflink.c`, `reflink.txt`) `mkfs` sobre un loop, xfs con `reflink=1` y btrfs por defecto. Copiar un fichero **entero**: | | ext4 | xfs (loop) | btrfs (loop) | |---|---|---|---| | `FICLONE` (reflink) | **no soportado** | 0,010 ms | 0,006 ms | | `copy_file_range` | 13,66 ms | 0,010 ms | 0,006 ms | | `read+write` (1 MiB) | 14,46 ms | 10,96 ms | 31,32 ms | Y la prueba de que es O(1) y no «rápido»: **con el fichero ocho veces más grande (512 MiB), el reflink pasa de 0,010 a 0,020 ms** —el doble, no ocho veces— mientras `read+write` pasa de 10,96 a 512,7 ms. **25 600× a 512 MiB en xfs.** En btrfs, 0,006 → 0,015 ms. Confirma T11 por el lado contrario: en ext4 `copy_file_range` no gana nada (13,66 contra 14,46 ms, un 6%) **porque ext4 no tiene reflink**; el «copiar en O(1)» que la gente espera de esa syscall es una propiedad del filesystem, no de la syscall. Material directo para SDD 18 (wawafs): con reflink se obtiene semántica de **copia** al precio del hardlink, y sin la contrapartida del hardlink —que `.dmerge` ya paga: retener en la caché lo que se borró del store. ### 9.3 · io_uring con `SQPOLL` — MEDIDO (`bench_sqpoll.c`, `sqpoll.txt`) 4K aleatorios en caché, profundidad 32, fd registrado: | ns/op | ext4 | tmpfs | |---|---|---| | `pread` | **554** | **749** | | io_uring sin SQPOLL | 618 | 2 030 | | io_uring con SQPOLL | 868 | 1 409 | **No cambia el veredicto de T12.** Sobre datos cacheados `SQPOLL` sigue perdiendo contra la syscall (1,6× peor en ext4), y aunque en tmpfs mejora al io_uring normal (1,4×), sigue estando 1,9× por detrás de `pread`. Encima **se come un core** con el hilo del kernel, que en una máquina de 4 vCPU es el 25% de la granja. `SQPOLL` es para operaciones que **de verdad bloquean**, no para amortizar cruces sobre caché. ### 9.4 · Coste en syscalls de un compilado — MEDIDO (`syscalls-compilador.txt`) La corrida original decía «sin `strace`/`perf` en esta máquina no se puede atribuir». **El `strace` estaba en el propio corpus** (`store/3c1fd8c0…-strace`, 6.19): la distro se paga a sí misma la herramienta de diagnóstico. Un `-c` de un `main()` trivial con stdio/stdlib/string: | | syscalls | con error | el grueso | |---|---|---|---| | `cc -O2 -c` (gcc 16) | 1 803 | **1 095 (61%)** | **919 de 923 `readlink` FALLAN** | | `zig cc -O2 -c` (zig 0.16) | 3 608 | 214 (6%) | 1 104 `futex` (70% del tiempo en syscall) | Dos lecturas: - **La tormenta de rutas de T8 existe y es de `readlink`, no de `stat`** — canonicalización de rutas de gcc, que T8 no medía. Pero **al piso de 78,7 ns, 1 803 cruces son 0,14 ms sobre 30,5 ms de compilado: el 0,5%.** La respuesta a la pregunta original («¿vale segundos o minutos por receta?») es **milisegundos por invocación**: segundos por millar de compilados. No es ahí donde está el tiempo de una tanda. - **`zig cc` tiene otro perfil**: casi no falla rutas, pero coordina hilos. El tiempo que `strace -c` le atribuye es espera **bloqueada**, no CPU — y por eso `strace -c` no sirve para comparar compiladores, sólo para contar llamadas. ### 9.5 · Iterador BPF contra `/proc` — MEDIDO (`bench_bpf_iter.c` + `bpf_iter_task.bpf.c` → `bpf-iter.txt`) Medido el **2026-08-31**, sobre el kernel de desarrollo (Artix 7.1.4, que sí trae BTF) — **no sobre un kernel de hammer, que es justamente lo que está en discusión**. Tercera corrida, condiciones propias: **sus números no se cruzan con los de §2 ni con los de §9.1-9.4.** Los dos caminos hacen **el mismo trabajo**: sacar `{pid, ppid, estado, utime, stime, comm}` de todos los procesos vivos, que es la foto que sandokan necesita de la máquina. Uno lo hace con `readdir` + `open`+`read`+parse de `/proc//stat`; el otro con un `iter/task` que escribe **registros binarios de tamaño fijo** con `bpf_seq_write`, leídos de un solo fd. | 245 procesos vivos, 33 rondas, mínimo | total | por proceso | × sobre el piso | |---|---|---|---| | A) `/proc` readdir+`stat`+parse | 1 356,1 µs | 5,54 µs | 12,1× | | B) iterador BPF (binario) | **196,1 µs** | **0,80 µs** | **1,75×** | | C) piso: `readdir(/proc)` a secas | 112,1 µs | 0,46 µs | 1× | **6,9× a favor del iterador** — pero el número que decide no es ése: **el iterador cuesta casi lo mismo que listar el directorio y no abrir nada** (1,75× el piso, contra 12,1× de `/proc`). Leer los campos deja de tener precio; lo que se paga es enumerar. Siete corridas, dos órdenes (A→B y B→A, para descartar que el primero de cada ronda pague el enfriamiento de las dentries de `/proc`): **6,9-8,0× en las cuatro de 33 rondas**, en las dos direcciones. La de 15 rondas dio 4,50× y es de la que hay que desconfiar, no de la mayoría: con pocas rondas el mínimo no converge y la máquina estaba cargada. **Amortización**: cargar y verificar el programa cuesta **13-17 ms una vez**; cada barrido ahorra más de 1 ms ⇒ **se paga solo en 12-40 barridos** según la corrida, 15 en la citada. Para un sandokan que mire la máquina una vez por segundo, medio minuto. **Lo que la ganancia cuesta, comprobado y no supuesto** — son dos barreras, no una: 1. **BTF es un portón, no una optimización.** El programa cargado informa `prog_type=26 (BPF_PROG_TYPE_TRACING)`, `attach_btf_id=80137`, `attach_btf_obj_id=1`: el iterador **se ata por id de tipo BTF del vmlinux**. Un kernel sin `DEBUG_INFO_BTF` no publica `/sys/kernel/btf/vmlinux` y no hay a qué atarse. **No hay versión de esto que esquive H7.** 2. **Privilegio**: `CAP_BPF`+`CAP_PERFMON` o root. Sin él, `bpf_object__load` sale `Operation not permitted`. Una Card no lo tiene ni debe tenerlo ⇒ **el consumidor tiene que ser el proceso privilegiado**. Y del lado tawasuyu eso **no es un cambio en el sitio**: el barrido de `/proc` de hoy vive en `sandokan-local`, que corre **sin privilegio** (se mete en un user namespace a propósito), mientras que el único proceso privilegiado de la máquina es `sandokan-daemon` —lo dice su propio `cortafuegos.rs`—. O sea que W2 por esta vía **no es un reemplazo en el lugar: mueve la telemetría al otro lado de la frontera de privilegio**, y ése es un coste de diseño que no aparece en el cociente de 7×. **Y los dos caminos NO dicen exactamente lo mismo.** De 245 procesos, **24 `comm` difieren**, y son **todos kworkers**: `/proc` **sintetiza** el nombre pegándole la workqueue que el hilo está sirviendo (`kworker/0:0H-kblockd`), mientras `task->comm` trae el nombre pelado (`kworker/0:0H`). Ningún proceso de usuario difiere. Para las Cards da igual; para cualquiera que muestre nombres de kworker, no. **Un reemplazo que se vende como "la misma información más rápido" tenía una diferencia de contenido, y sólo apareció cotejando registro contra registro** — no medir es lo que la habría dejado pasar. **Nota de método**: el bench aborta con exit 2 si el iterador devuelve **cero registros**. Un barrido vacío es infinitamente rápido y no mide nada: es CLAUDE.md §3 aplicado a un benchmark. ### 9.6 · Lo que sigue abierto 1. **Un Intel con PTI — y el experimento no es el que decía esta línea.** Decía: «en el laptop (TigerLake) las mismas medidas darían peor». Eso compara **dos máquinas**, así que mezcla PTI con microarquitectura, frecuencia y hipervisor: cualquier diferencia se le podría atribuir a cualquiera de las cuatro. El experimento que aísla la tasa es **una sola máquina con `pti=on` y con `pti=off`** —el kernel acepta `pti=on` aunque el CPU no sea vulnerable, así que forzarlo es posible—, midiendo el piso de syscall **en función del working set tocado**: sin PTI es plano, y con PTI crece, porque lo que PTI cobra es el cambio de CR3 y la TLB que se lleva puesta. **Y `momento` no sirve ni forzándolo, por una razón que salió mirando**: este invitado **no tiene PCID** (`grep pcid /proc/cpuinfo` no devuelve nada; 88 flags en total). Sin PCID cada entrada y salida del kernel con PTI es un vaciado completo de TLB, o sea el régimen **peor caso**, que no es el que pagaría un TigerLake —que sí trae `pcid`+`invpcid`—. Medirlo acá daría una cota superior verdadera y una respuesta engañosa a la pregunta que importa. ⇒ **el experimento necesita el laptop**, y ahí necesita las dos corridas, no una. ## 10. Evidencia `docs/evidencia/tasas-kernel-2026-08-29/` — **primera corrida**: cuatro programas en C (`bench_proc.c`, `bench_io.c`, `bench_extra.c`, `bench_pin.c`) y sus cinco salidas crudas (`proc.txt`, `io-ext4.txt`, `io-tmpfs.txt`, `extra.txt`, `pin.txt`). Se compilan con `cc -O2` (`bench_io` pide `-luring`, `bench_proc`/`bench_pin` piden `-lpthread`) y se corren sin argumentos salvo `bench_io ` y `bench_pin `. **Nota de unidad**: en `pin.txt` la sección de fallos de página imprime ns/página × 1000 por un factor de escala equivocado en el programa; los valores citados en T13 son los de esa salida divididos por 1000, y coinciden con `io-ext4.txt`, que sí está en la unidad correcta. **Segunda corrida (§9, con root)**: `bench_connector.c` → `connector.txt`; `bench_reflink.c` → `reflink.txt` (xfs y btrfs sobre loop, montados y desmontados por el propio guion); `bench_sqpoll.c` → `sqpoll.txt`; y `syscalls-compilador.txt`, hecho con el `strace` **del store**. Se compilan igual que los otros (`bench_sqpoll` pide `-luring`) y los tres primeros piden root: `bench_connector` por `CAP_NET_ADMIN`, `bench_reflink` por `mkfs`+`mount`, `bench_sqpoll` por `CAP_SYS_NICE`. **Contrato de capacidades**: `docs/state/kernel-contract.toml` (el dato) y `hammer kernel contract --list` (la vista). Los ficheros de esta carpeta no cambian; el contrato sí —cada vez que un consumidor nuevo usa el kernel por nombre. **Tercera corrida (§9.5, iterador BPF, 2026-08-31)**: `bpf_iter_task.bpf.c` (el programa BPF) + `bench_bpf_iter.c` (el de usuario) → `bpf-iter.txt`. Se compilan con `clang -O2 -g -target bpf -D__TARGET_ARCH_x86 -c bpf_iter_task.bpf.c -o bpf_iter_task.bpf.o` y `cc -O2 -o bench_bpf_iter bench_bpf_iter.c -lbpf`; se corre `./bench_bpf_iter [rondas] [orden]` como root, con el `.bpf.o` al lado. **No usa `vmlinux.h`** —haría falta bpftool, que no está en la máquina ni en el store— sino los tipos mínimos declarados a mano con `preserve_access_index`, que es lo que deja a CO-RE reubicar los offsets contra el BTF del kernel en carga. **Cuarta corrida (§7-H7, precio de BTF)**: `btf-precio.txt` —el delta de bzImage, la sección `.BTF`, las condiciones y las dos deps que hubo que añadir— junto con `linux-generic-btf.toml` (la receta derivada, con las instrucciones para reconstruirla) y `config-7.1.2-generic-btf` (el `.config` producido, que es donde se comprueba que BTF sobrevivió al `olddefconfig`). **No es un microbenchmark: es un kernel construido**, así que sus números no se cruzan con los de §2 ni §9. **Quinta corrida (T17/T18, la jaula por syscall, 2026-09-01)**: `bench_jaula.c` → `jaula.txt`. Se compila con `cc -O2 -o bench_jaula bench_jaula.c` y se corre `./bench_jaula [rondas] [dir-temporal]` **sin root** —ni seccomp con `no_new_privs` ni Landlock lo piden—. Deja un árbol de 16 384 ficheros en el directorio temporal que hay que borrar a mano. Cada configuración vive en un hijo propio, porque ni el filtro ni el ruleset se pueden quitar una vez puestos. **Y aborta con exit 2 si la jaula no quedó puesta**: comprueba que un número de la denylist da `EPERM` y que un fichero testigo 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 ` 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: las demás son mediciones de un momento, ésta es un veredicto de hoy.