1# SDD 25 — Las tasas del kernel: qué cobra Linux, cuánto, y por dónde se esquiva
2
3Escrito 2026-08-29. Nace de una pregunta del operador: *«¿hay cómo crear módulos para
4kernel para optimizar cosas que son estándares y por eso no se cambian — por ejemplo una API
5más cercana del control de procesos sin que sandokan use `/proc`? Con tawasuyu ponerse de
6acuerdo para pasar por debajo de mecanismos pesados POSIX.»*
7
8`/proc` y sandokan eran el ejemplo, no el alcance. Lo que sigue es **la lista entera de tasas
9que Linux cobra por compatibilidad histórica, medida en esta máquina**, y para cada una: si
10se esquiva, con qué, y quién paga hoy.
11
12El espejo de este documento para el lado tawasuyu es
13`tawasuyu/HANDOFF-TASAS-KERNEL-DESDE-HAMMER.md` (mismo patrón que ADR 0010 ↔
14`HANDOFF-arranque-grafo.md`).
15
16## 0. La respuesta corta
17
181. **Un módulo cargable no es una opción en hammer y no hace falta.** Las tres recetas de
19 kernel (`linux-generic.toml:43`, `linux-metal.toml:104`, `linux-metal-dual.toml:51`)
20 configuran `-d MODULES`: kernel monolítico. `linux.toml` no lo apaga pero sólo compila
21 `bzImage`, nunca `make modules`. Cualquier código de kernel propio en hammer es
22 **built-in**, o sea un parche a la receta — que además es lo correcto para el proyecto,
23 porque la fase `configure` **es** la identidad del artefacto (SDD 22) y así queda sellado
24 y atestado junto al kernel.
252. **Casi todo lo que duele ya tiene salida en mainline y nadie la está usando.** `pidfd`,
26 `cgroup` v2 agregado, `posix_spawn`, `getdents64`, `close_range`, `openat2`. Cero código
27 de kernel, ganancias de 3× a 17× medidas abajo.
283. **La medición encontró un bug cruzado que no se buscaba**: los kernels de hammer se
29 construían **sin `CONFIG_MEMCG`**, y arje/sandokan escriben `memory.max` ignorando el
30 error. Un límite de memoria pedido por una Card **no se aplicaba y nadie se enteraba**. §4.
31 Confirmado sobre los **11 `.config` sellados**, no deducido del defconfig (§4.bis), vigilado
32 por `hammer kernel contract` (§4.ter) y **PAGADO el 2026-08-30**: los cuatro kernels que
33 hospedan Cards se reconstruyeron con `MEMCG` y `PSI` y el barrido sale **5 de 5 vigentes en
34 verde** (§7-H1).
354. **Sondear `/proc` no es lento, es ciego** (§9.1): de 200 procesos de vida mínima, un sondeo
36 cada 100 ms vio **cero**; el proc connector, los 200. Y **el «copiar en O(1)» existe pero no en
37 ext4**: en xfs/btrfs un reflink de 512 MiB tarda 0,020 ms contra 512,7 ms de `read+write`
38 (§9.2).
395. **La jaula de harkaq cobra por syscall, y cobra poco** (T17/T18, medido 2026-09-01): su filtro
40 seccomp pone +12,4 ns por syscall **sin importar cuántas entradas tenga la denylist** —el bitmap
41 de acción constante del kernel hace que el largo sea gratis; mirar un argumento, no—, y Landlock
42 pone +473 ns por `open`, de los cuales sólo +225 se deben a pasar de 1 regla a las ~17 000 de
43 una clausura fichero a fichero. Sobre un compilado entero son **0,23–0,31%**: una décima parte
44 del presupuesto de <5% de SDD 16. Lo caro de enjaular sigue siendo el ~1,09 ms por proceso de
45 los namespaces (T14).
467. **El sandbox apila una capa overlay por dep, y eso NO es lo caro** (T19, medido 2026-09-01):
47 un lookup que FALLA cuesta +1 925 ns por capa —a 64 capas, 124 µs, mil quinientas syscalls nulas—
48 pero **se paga una sola vez por ruta**: la dentry fusionada queda cacheada y el montaje dura toda
49 la fase. Un compilado toca 381 rutas distintas que fallan ⇒ **2,9 ms por fase**, la misma décima
50 de por ciento que la jaula. ⇒ **fundir las deps en una capa (`.dmerge`) no es una optimización,
51 es el rodeo de un muro**: `mount(2)` monta 41 capas (4 059 B de opciones) y falla en 42, con un
52 `ENOENT` que miente. La API de montaje nueva (`fsconfig lowerdir+`) monta las 64 y **quitaría
53 `.dmerge` de en medio** (§7-H11).
54
556. **Cuando `/proc` sí se sondea entero, hay algo cerca de 7× más barato** (§9.5, medido
56 2026-08-31): un iterador BPF `task` saca la misma foto **al precio de listar el directorio**
57 —1,75× el piso de `readdir` contra 12,1× de `/proc`—. Su portón es `DEBUG_INFO_BTF`, que sigue
58 apagado, y H7 ya no dice sólo que está apagado: **cuesta +14,97% de bzImage** (+2,42 MiB,
59 medido construyendo el 2026-08-31 — 31 veces lo que costó MEMCG+PSI) más `pahole` y `python3` en
60 el lab. Los dos lados del balance están sobre la mesa; la decisión no.
61
62## 1. Método y condiciones — leer antes de citar un número
63
64- **Máquina**: `momento`, KVM sobre AMD EPYC-Rome, 4 vCPU, 7,7 GiB RAM, Linux 7.1.4,
65 glibc/Artix. Store en `/dev/sdb` (ext4), árbol en `/dev/sdc` (ext4), `/tmp` tmpfs.
66- **Mitigaciones activas**: retpolines (`spectre_v2`), retbleed, SSBD por prctl. **Sin PTI**
67 (AMD, `meltdown: Not affected`). En un Intel con PTI el piso de syscall sería peor — cuánto, no
68 se puede averiguar acá: **este invitado tampoco expone PCID**, y sin PCID el PTI forzado mide el
69 peor caso y no el de una máquina real (§9.6).
70- **La máquina estaba CARGADA**: load 7,3 sobre 4 vCPU, un build de LLVM en vuelo, 773 MiB
71 libres. ⇒ **los absolutos son cotas superiores; la señal está en los cocientes dentro de
72 una misma corrida**, que es como está escrito todo lo de abajo.
73- Cada medida son 7–9 rondas; se reporta **mínimo** (mejor caso, el estimador robusto al
74 ruido del scheduler) y **mediana**. Las corridas marcadas «pinneado» fijan el proceso a un
75 cpu para sacar de en medio la cola del planificador.
76- **SEGUNDA CORRIDA, mismo día, con root** (§9): la máquina estaba mucho más suelta —load ~1,3
77 sobre 4 vCPU y 4,3 GiB libres— y por eso **los absolutos de las dos corridas NO se comparan
78 entre sí**. Cada cociente que se cita abajo sale de una sola corrida, y se dice de cuál.
79- Fuentes y salidas crudas: `docs/evidencia/tasas-kernel-2026-08-29/`.
80- **Piso de referencia**: una syscall nula (`getppid`) cuesta **78,7 ns**. El vDSO
81 (`clock_gettime`) cuesta 31,2 ns y por syscall 143,8 ns ⇒ **cruzar al kernel son ~110 ns**.
82 Todo lo de abajo se lee en «syscalls equivalentes».
83
84## 2. La lista
85
86### T1 · `fork()` copia el espacio de direcciones del padre
87
88| padre con … | `fork+_exit+wait` | `posix_spawn+wait` |
89|---|---|---|
90| 0 MB tocados | 109 µs | 231 µs |
91| 64 MB | 938 µs | 234 µs |
92| 256 MB | **3 949 µs** | **231 µs** |
93
94≈ **59 ns por página de 4 KiB del padre**, ida y vuelta. `posix_spawn` (que usa
95`CLONE_VFORK|CLONE_VM`) es **plano**: no mira el tamaño del padre. A 256 MB, **17× más
96barato — haciendo estrictamente más trabajo** (además ejecuta un binario).
97
98Es la tasa POSIX por excelencia: `fork` no se puede arreglar sin romper la semántica, y la
99salida está dentro del propio POSIX.
100
101### T2 · `exec` + enlazado dinámico
102
103| | µs/exec |
104|---|---|
105| musl ESTÁTICO | **77,4** |
106| glibc ESTÁTICO | 232,7 |
107| glibc DINÁMICO | 324,3 |
108
109`ld.so` cuesta **+92 µs** (+39%). Pero el salto grande es glibc→musl: **4,2× entre el peor y
110el mejor**, con el mismo `main(){return 0;}`. El corpus de hammer ya está del lado bueno
111(verificado: los binarios del store son `statically linked`); esto **cuantifica** lo que la
112de-Alpinización compró: un build que lanza 20 000 procesos se ahorra ~5 s sólo en arranque.
113
114### T3 · `/proc` es una API de TEXTO, y se paga como tal
115
116| | ns | = syscalls |
117|---|---|---|
118| `getrusage(RUSAGE_SELF)` | 238 | 3 |
119| `clock_gettime(PROCESS_CPUTIME)` | 214 | 3 |
120| `pread`+parse `/proc/self/stat` (fd ya abierto) | 1 271 | 16 |
121| `open`+`read`+parse `/proc/self/stat` | **2 692** | **34** |
122| `open`+`read`+parse `/proc/self/status` (VmRSS) | **4 643** | **59** |
123
124Leer el CPU propio por `/proc` cuesta **11× más** que `getrusage`. Y la mitad del coste es
125abrir la ruta, no el texto (1 271 vs 2 692 con el fd cacheado).
126
127**Barrido completo** (291 procesos vivos): `readdir` solo **106 µs**; `readdir` + `stat` de
128cada pid **991 µs**, o sea **3,4 µs por proceso**. Ese barrido tiene alternativa medida: un
129iterador BPF hace el mismo trabajo por **0,80 µs por proceso** contra 5,54 µs del mismo barrido
130medido en esa corrida (§9.5), pero exige BTF (H7).
131
132### T4 · Contabilidad por unidad: cgroup v2 es O(1) donde `/proc` es O(procesos)
133
134Leer `cpu.stat` + `memory.current` + `pids.current` de un cgroup: **4,6 µs**, y **no depende
135de cuántos procesos tenga la unidad**. Sumar `/proc` para esa misma unidad: 3,4 µs × N.
136**El cruce está en N = 2.** Para una unidad de 20 procesos, 15× a favor del cgroup.
137
138### T5 · Liveness por `/proc/<pid>`: la carrera, no la lentitud
139
140| | ns |
141|---|---|
142| `access("/proc/<pid>")` — lo que se usa hoy | 345 |
143| `kill(pid, 0)` | 119 |
144| `pidfd_send_signal(fd, 0)` | 119 |
145
146`pidfd` **no es más rápido que `kill(0)`**; es 2,9× más rápido que la ruta `/proc`. Su valor
147real es otro: **no hay reuso de PID**, y el fd se vuelve legible al morir el proceso ⇒ entra
148en el `epoll` y el supervisor deja de sondear.
149
150### T6 · Señales: la notificación más cara de POSIX
151
152`kill(self,SIGUSR1)` + handler = **1 165 ns = 15 syscalls**. Un `eventfd` en el bucle de
153eventos hace el mismo trabajo por una fracción.
154
155### T7 · Despertar cuesta µs; sincronizar en usuario cuesta ns
156
157| | ns |
158|---|---|
159| `pthread_mutex` lock+unlock sin disputa | **8,6** |
160| RTT `eventfd` (2 hilos, mismo cpu) | 2 507 |
161| RTT `pipe` | 2 956 |
162| RTT `socketpair` AF_UNIX | 3 639 |
163
164**291× entre sincronizar en espacio de usuario y despertar por el kernel.** El bus de arje
165(postcard sobre socket Unix) paga ~3,6 µs por ida y vuelta: aceptable por mensaje, ruinoso
166por campo.
167
168Adyacente: `sched_yield` 174 ns; `nanosleep(1 ms)` se pasa **65 µs** bajo carga ⇒ los lazos
169de control no se construyen sobre `sleep`.
170
171### T8 · `stat()` es tasa del VFS, no del filesystem
172
173| ns/fichero | ext4 | tmpfs |
174|---|---|---|
175| `stat()` por ruta completa | 648 | 714 |
176| `fstatat(dirfd, nombre)` | 439 | 489 |
177| `statx(…, DONT_SYNC)` | 467 | 593 |
178| `openat`+`close` | 899 | 961 |
179| **`getdents64` (barrido, sin stat)** | **168** | **59** |
180
181**ext4 ≈ tmpfs en `stat`.** ⇒ una tormenta de `stat` **no se arregla cambiando de
182filesystem**; se arregla no haciéndola: `getdents64` da el barrido **3,9× más barato en ext4
183y 12× en tmpfs**, y `fstatat` relativo a un `dirfd` ahorra 32% sobre la ruta completa.
184
185**Profundidad de ruta**: 12 componentes extra cuestan +205 ns en ext4 y +203 en tmpfs ⇒
186**~17 ns por componente**, idéntico en ambos. Otra vez: el VFS, no el disco.
187
188### T9 · `openat2` con `RESOLVE_BENEATH` es casi gratis
189
190ext4: 1 025 vs 899 ns (+14%). tmpfs: 763 vs 961 ns (midió *más rápido*, o sea dentro del
191ruido). ⇒ **la resolución hermética de rutas no tiene precio apreciable.** Es material
192directo para harkaq (SDD 16): la garantía «no se sale del árbol» sin demonio auxiliar y sin
193una syscall extra.
194
195### T10 · Materializar un árbol: hardlink vs copia
196
197| 2 000 ficheros de 1 byte | ext4 | tmpfs |
198|---|---|---|
199| `link()` | 9,2 µs | 2,9 µs |
200| `open+read+write+close` | 142,2 µs | 5,0 µs |
201| **cociente** | **15,4×** | 1,7× |
202
203Lo que hace cara la copia es el **journal de ext4**, no el VFS. La estrategia de hardlinks de
204`.dmerge` está bien elegida — y su contrapartida ya está documentada (la caché retiene lo que
205se borra del store).
206
207### T11 · `copy_file_range`/`sendfile` no ganan sobre ext4
208
20964 MiB en caché: `read+write` 2 837 MB/s · `sendfile` 3 345 · `copy_file_range` 3 521. ~20%
210en el mejor caso. **ext4 no tiene reflink**, así que `copy_file_range` cae a una copia dentro
211del kernel. El «copiar en O(1)» que la gente espera **exige un FS con reflink** (xfs/btrfs) —
212sin medir acá, §9.
213
214### T12 · io_uring no es una ganancia universal
215
216| | ns/op |
217|---|---|
218| syscall nula | 78,7 |
219| **io_uring NOP en lote (piso de operación)** | **47,7** |
220| `pread` 4K en caché (ext4) | 540 |
221| io_uring read 4K profundidad 32 (ext4) | 594 |
222| `pread` 4K (tmpfs) | 602 |
223| io_uring read 4K (tmpfs) | **1 672** |
224
225El piso por operación es **0,61× una syscall**: en lote, io_uring amortiza el cruce. Pero
226sobre datos ya cacheados **pierde** — 10% en ext4 y **2,8× en tmpfs**. io_uring paga cuando
227las operaciones son muchas, agrupables y **de verdad bloquean**. Sobre caché es una
228pesimización.
229
230### T13 · THP puede ser 3× PEOR (y acá lo es)
231
23264 MiB anónimos, ns por página de 4 KiB: `MADV_HUGEPAGE` **3 046** · `MADV_NOHUGEPAGE`
233**1 013** · `MAP_POPULATE` **752**.
234
235La causa está en la máquina, no en el kernel:
236`/sys/kernel/mm/transparent_hugepage/defrag = madvise` ⇒ una región con `MADV_HUGEPAGE`
237dispara **compactación síncrona** en el fallo de página, y con 773 MiB libres eso es caro.
238**THP bajo presión de memoria es un pasivo de latencia, no una optimización.** Reproducido en
239las dos corridas independientes (128 MiB y 64 MiB).
240
241`mmap`+`munmap` de 4K: 1 664 ns.
242
243### T14 · El sandbox por namespaces cuesta ~1 ms por proceso
244
245`fork+exec` base 1 649 µs · con `unshare` de 6 namespaces 2 742 µs · con `USER|MNT` 2 050 µs.
246⇒ **+1,09 ms** por el juego completo, **+0,40 ms** por USER|MNT.
247
248Para hammer es ruido por fase de build (segundos a minutos). Sería caro **sólo si se
249enjaulara por-exec**: 20 000 execs × 1,09 ms = 22 s.
250
251### T15 · Cerrar descriptores antes del `exec`
252
253`dup+close` uno a uno: 0,2 µs/fd ⇒ 512 fds = 102 µs. `close_range(lo,hi)`: por debajo del
254ruido. Gratis, y ya está en mainline.
255
256### T16 · Hilo vs proceso
257
258`pthread_create`+`join` = **11,9 µs**, contra 109 µs de `fork+_exit` con el padre vacío:
259**9×**, y la distancia crece con el tamaño del padre (T1).
260
261### T17 · La jaula cobra POR SYSCALL — y el filtro largo no es lo que cobra
262
263T14 midió el sandbox por **namespaces**, que cobra por proceso. Adentro, harkaq (SDD 16) pone dos
264cosas más que cobran por **syscall**, que es la unidad en la que un build hace millones: un filtro
265seccomp y un ruleset Landlock. Medido con `bench_jaula.c` → `jaula.txt` (2026-09-01, mismo
266`momento`, 9 rondas, mínimos; **los cocientes son todos de esa corrida**).
267
268El filtro de `harkaq-exec.c` es una **denylist**: una cadena lineal de `BPF_JEQ` sobre
269`seccomp_data.nr` con 25 entradas y, al final, `RET ALLOW`. O sea que **toda syscall legítima
270recorre la cadena entera** antes de que la dejen pasar: la peor posición posible para el caso más
271frecuente. La intuición dice que eso se paga y que hay que ordenar la lista por frecuencia.
272
273| filtro | ns/`getppid` | vs piso |
274|---|---|---|
275| sin filtro | **75,0** | — |
276| denylist de 4 | 86,9 | +11,9 |
277| **denylist de 25 (la de harkaq)** | **87,4** | **+12,4 (+17%)** |
278| denylist de 64 | 88,5 | +13,5 |
279| denylist de 128 | 86,6 | +11,6 |
280| denylist de 250 | 88,3 | +13,3 |
281
282**Plano.** Un filtro 62 veces más largo cuesta lo mismo. La razón es que desde 5.11 el kernel arma
283un **bitmap de acción constante**: `seccomp_is_const_allow()` lee el programa BPF instrucción por
284instrucción y, para cada número de syscall cuyo veredicto no dependa de los argumentos, marca un
285bit — y a partir de ahí **el programa no se ejecuta nunca más**. El filtro de harkaq usa sólo
286`BPF_LD|W|ABS` sobre `nr`/`arch`, `BPF_JMP|BPF_JEQ|BPF_K` y `BPF_RET|BPF_K`, que es exactamente lo
287que ese analizador sabe seguir. Los +12 ns que quedan son el bit, no la cadena.
288
289**Y no es fe, es un control.** El mismo banco instala un **gemelo** de la misma longitud con una
290instrucción de más al principio —una carga de `args[0]`— que hace que el analizador se rinda.
291Mismo largo, misma cadena, sin bitmap:
292
293| gemelo que mira `args[0]` | 4 | 25 | 64 | 128 | 250 |
294|---|---|---|---|---|---|
295| ns/`getppid` | 111,6 | 104,4 | 111,1 | 121,4 | **139,9** |
296
297Ahí sí crece: **0,158 ns por instrucción de la cadena**. Si los dos hubieran salido planos, la
298medición no discriminaría y no diría nada; salen distintos, así que el bitmap existe y es lo que
299paga la cuenta.
300
301**La regla, que es lo que hay que llevarse**: un filtro seccomp es gratis **mientras no mire un
302argumento**. El día que alguien quiera filtrar `ioctl` por el número de petición o `clone` por sus
303flags, ese número de syscall —y sólo ése— se cae del bitmap y pasa a pagar la cadena entera en
304cada llamada. La longitud de la denylist no es la variable; **mirar argumentos, sí**.
305
306**El bitmap se paga al instalar, y se cobra por proceso, no por syscall:**
307
308| N | instalar la denylist (arma bitmap) | instalar el gemelo (no lo arma) |
309|---|---|---|
310| 4 | 26,5 µs | 14,4 µs |
311| **25 (harkaq)** | **69,8 µs** | 21,7 µs |
312| 64 | 140,6 µs | 26,5 µs |
313| 128 | 252,8 µs | 31,6 µs |
314| 250 | 395,4 µs | 52,2 µs |
315
316≈ **1,5 µs por entrada de la denylist**, porque el kernel recorre el programa entero una vez por
317cada número de syscall. A N=25 el bitmap cuesta 48,1 µs de más al instalar y ahorra 17,0 ns por
318syscall ⇒ **se amortiza en 2 830 syscalls**, que es una invocación y media de gcc (§9.4: 1 803).
319Y harkaq lo instala **una vez por fase de build**, no por proceso: el filtro y su bitmap se heredan
320por `fork`/`exec`, así que lo paga el primer proceso de la fase y lo cobran los miles que siguen.
321
322**Dos cosas del filtro de harkaq que salieron de mirarlo para medirlo, y que no son de rendimiento:**
323
3241. **`jt`/`jf` de la BPF clásica son `__u8`.** En la forma de `poner_seccomp()` el salto más largo
325 mide N+3, así que la denylist tiene un **techo de ~252 entradas**: pasado eso el desplazamiento
326 da la vuelta y el filtro deja de decir lo que dice su código fuente. Hoy son 25 y no hay
327 problema; el problema es que crecer la lista es justo lo que uno hace sin pensarlo.
3282. **`f[k++] = BPF_JUMP(..., I_KILL - k - 1 + 1)` modificaba y leía `k` en la misma expresión** —
329 sin punto de secuencia, o sea UB en C11. Funcionaba porque gcc y clang leen `k` *después* del
330 incremento, y el `+1` final era exactamente la compensación de eso. Si algún compilador leyera
331 antes, el salto caería una instrucción más allá del final: el verificador clásico lo rechazaría
332 y harkaq saldría por su rama de `RECHAZO` — falla ruidoso, no silencioso, pero apoyado en UB.
333
334 **CERRADO el 2026-09-01**: el índice se fija en `I_ARCH` antes de usarlo. **El código generado es
335 idéntico** —mismo `objdump` del objeto a `-O1` y a `-O2`, la única línea que difiere es el nombre
336 del fichero— y el binario parcheado sigue poniendo la jaula: `ptrace` desde dentro da `EPERM` y
337 un ejecutable fuera de la clausura no arranca. Que el desensamblado no cambie es lo que prueba
338 que era exactamente la misma cuenta escrita sin UB, y no un arreglo que además mueve el salto.
339
340### T18 · Landlock: el precio es ESTAR enjaulado, no cuántas reglas hay
341
342La política de harkaq se deriva **fichero a fichero** (D1: adentro, la dep declarada y el rootfs
343Alpine comparten `/usr`, así que una regla sobre `/usr` concedería las dos y la evidencia dejaría
344de significar algo). Una dep como boost son 15 829 ficheros ⇒ **~17 000 reglas**. La pregunta
345obvia es si un ruleset así encarece cada `open`.
346
347| ruleset | ns/`open`+`close` | vs piso |
348|---|---|---|
349| sin Landlock | **791,7** | — |
350| 1 regla | 1 264,7 | **+473 (+60%)** |
351| 16 reglas | 1 278,4 | +487 |
352| 256 reglas | 1 309,3 | +518 |
353| 4 096 reglas | 1 365,9 | +574 |
354| **16 384 reglas (~boost)** | **1 489,5** | **+698 (+88%)** |
355
356**Todo el precio está en el primer escalón.** Estar bajo Landlock cuesta +473 ns; multiplicar el
357ruleset por 16 384 agrega +225 ns más (+18% sobre el `open` ya enjaulado). La forma es la de una
358búsqueda en árbol, no la de un barrido. **La decisión de diseño discutible de harkaq —enumerar la
359clausura fichero a fichero en vez de conceder directorios— resulta ser casi gratis**, y eso ahora
360está medido en vez de supuesto.
361
362Dos medidas más, con 16 384 reglas puestas:
363
364- **`stat` no paga**: 522,8 ns sin Landlock, 508,4 con — o sea nada, dentro del ruido. Landlock no
365 engancha `getattr`, sólo la apertura y la modificación de rutas. Importa porque `stat` es la
366 syscall que un build hace más (T8), y porque el instinto es suponer que la jaula toca todo.
367- **Un `open` DENEGADO sale más barato que uno concedido**: 1 081,1 contra 1 489,5. Se midió
368 esperando lo contrario —el peor caso, subir el árbol hasta la raíz sin encontrar regla— y el
369 resultado es que fallar antes de montar el descriptor ahorra más de lo que cuesta buscar. **La
370 denegación no es un riesgo de coste**, que era la duda que justificaba medirla.
371
372**Armar el ruleset sí es lineal**: 30,46 ms para 16 384 reglas (**1,86 µs por regla**, un `open`
373`O_PATH` + un `landlock_add_rule` + un `close` cada una). Es precio **por fase de build**, igual
374que el filtro: 30,5 ms contra una fase que dura de segundos a minutos. Pero es la misma advertencia
375de T14 con otro número: **si algún día se enjaulara por-exec**, 20 000 execs × 30,6 ms = **10
376minutos** de puro armado de jaula.
377
378### T17+T18 · Lo que la jaula le cuesta a un compilado, en total
379
380Con los números de §9.4 (un `gcc` = 1 803 syscalls y 228 `openat` sobre 30,5 ms de compilado):
381
382| | cuenta | µs |
383|---|---|---|
384| seccomp | 1 803 syscalls × 12,4 ns | 22,4 |
385| Landlock | 101 `open` que llegan al hook × 473–698 ns | 48–70 |
386| **total** | | **70–93 µs sobre 30 500 µs = 0,23–0,31%** |
387
388Las 127 `openat` que fallan con `ENOENT` no llegan al hook de Landlock —la resolución de ruta
389falla antes—, así que la cota alta contando las 228 sería 0,43–0,60%. **En cualquiera de las dos
390lecturas, el grano fino de harkaq gasta menos de la décima parte del presupuesto de <5% de SDD 16**,
391y lo caro de enjaular sigue siendo lo que ya decía T14: el ~1,09 ms por proceso de los namespaces,
392que es diez veces todo esto junto.
393
394### T19 · El sandbox apila una capa overlay por dep — y lo caro no es lo que parece
395
396T17/T18 midieron lo que harkaq pone DENTRO del sandbox. Esto es lo que pone el sandbox mismo:
397`crates/hammer-build/src/sandbox.rs` monta el rootfs Alpine como capa overlay de arriba y **una capa
398más por cada dependencia de build** debajo. Sólo cuando la suma de las rutas se acerca al tope las
399funde en una sola capa de hardlinks (`merge_deps_layer`, el `.dmerge` del store). Con el store en
400`/mnt/vvv/hammer/store` ese umbral cae en las **~27 deps**, así que **1 108 de las 1 171 recetas del
401corpus (95%) construyen con las capas APILADAS**, hasta 27 de ellas. Nadie había medido qué cuesta
402esa N. Medido con `bench_overlay.c` → `overlay.txt` (2026-09-01, mismo `momento`, 7 rondas,
403mínimos; los cocientes son todos de una misma corrida).
404
405El banco monta y desmonta el overlay en **cada ronda**, así que las dentries del overlay están
406frías en cada medida y las del filesystem de abajo, calientes: lo que se aísla es el paseo del
407overlay por sus capas.
408
409| N capas | acierto en la capa 0 | acierto en la capa N-1 | **FALLO** | montar |
410|---|---|---|---|---|
411| 1 | 4 068 ns | — | 3 200 ns | 152 µs |
412| 2 | 4 723 | 4 630 | 4 919 | 199 µs |
413| 8 | 4 205 | 6 209 | 15 867 | 195 µs |
414| 16 | 4 970 | 9 330 | 30 205 | 219 µs |
415| **24** | 4 056 | **10 893** | **44 716** | 245 µs |
416| 64 | 4 717 | 22 939 | **124 448** | 365 µs |
417
418**Tres leyes salen de ahí:**
419
4201. **El acierto en la capa de arriba es plano** (~4,2 µs sin importar N): se encuentra a la primera
421 y no se mira nada más.
4222. **El acierto en la capa del fondo crece +295 ns por capa** — el overlay va preguntando hacia
423 abajo hasta encontrarlo.
4243. **El que paga es el FALLO: +1 925 ns por capa**, porque para decir «no está» hay que preguntar
425 a **todas**. A 64 capas un `stat` que falla cuesta **124 µs**, o sea **1 580 syscalls nulas**.
426
427**Y la ley que lo desactiva todo: se paga UNA VEZ POR RUTA, no por acceso.**
428
429| | ns |
430|---|---|
431| la misma ruta repetida (dentry ya creada) | **664–693, plano en N** |
432| `open` de una ruta recién `stat`-eada | 1 727–2 394, plano en N |
433
434El kernel cachea la dentry fusionada del overlay: el segundo acceso a la misma ruta ya no pasea
435nada. El montaje dura **toda la fase de build**, así que el impuesto de una fase es
436«rutas DISTINTAS tocadas × capas», no «lookups × capas».
437
438**La alternativa —fundirlas en una capa de hardlinks— sale plana en todo**, que es lo esperable
439(vuelve a ser un solo directorio):
440
441| N | acierto | FALLO | fundirlas |
442|---|---|---|---|
443| 8 | 4 061 ns | 3 835 ns | 55,8 ms |
444| 24 | 4 442 | 4 118 | 128,5 ms |
445| 64 | 3 983 | 3 673 | 376,4 ms |
446
447≈ **23 µs por fichero hardlinkeado** (20 µs con artefactos reales del store). Como piso, el mismo
448árbol sin overlay: acierto 878 ns, fallo 2 588 ns.
449
450**Con artefactos REALES del store** (64 con `usr/include`, de 100 a 3 087 ficheros) la forma se
451repite y las constantes bajan —árboles más profundos, dentries de abajo más calientes—: el fallo
452sube **+738 ns por capa** con nombres nunca vistos y **+363 ns** cuando el nombre ya se buscó antes
453(la dentry negativa de ext4 está puesta y sólo se paga el paseo del overlay). Fundir 24 deps reales
454= 16 112 ficheros = **264 ms**.
455
456**La cuenta que decide, y sale al revés de la intuición.** Un `cc -O2 -c` de 12 KB de fuente toca
457**381 rutas distintas que fallan** (352 bajo `/usr`, o sea dentro del overlay) y 260 que aciertan
458—`strace` del propio store, §5 de `overlay.txt`—. A 24 capas reales, cada primer fallo cuesta
459**+7,5 µs** contra la capa fundida ⇒ 381 × 7,5 µs = **2,9 ms**, y se paga **una vez por fase**, no
460por unidad de traducción: la segunda unidad que busque las mismas cabeceras ya las encuentra
461cacheadas. Contra una fase de compilado de segundos a minutos, es **la misma décima de por ciento
462que la jaula de T17/T18**.
463
464Al revés: para que fundir las capas se pagara sola en lookups ahorrados harían falta
465**264 ms / 7,5 µs ≈ 35 200 primeros fallos** en una sola fase — dos órdenes de magnitud más de lo
466que gasta un compilado. ⇒ **`merge_deps_layer` NO es una optimización de velocidad. Es un rodeo de
467un muro**, y conviene que quede escrito porque la caché que deja (`.dmerge`) tiene un precio
468conocido en disco: hardlinkea los artefactos y **retiene lo que borrás del store**.
469
470**El muro, medido exactamente.** El comentario de `sandbox.rs` decía «~1 página (4096 B)» y acierta
471al byte. Bisecado con `mount(2)` directo (`mnt2.c`; **el `mount(8)` de util-linux se rinde mucho
472antes —entre 8 y 12 capas acá— así que bisecarlo desde la shell da un muro FALSO**):
473
474| capas | opciones | |
475|---|---|---|
476| 41 | 4 059 B | monta |
477| **42** | **4 158 B** | **falla, `ENOENT`** |
478
479Una página exacta, y el error es el engañoso «No such file or directory» que ya estaba anotado:
480**el kernel no dice «demasiado largo», dice «no existe»**. Con eso el umbral de `sandbox.rs` queda
481calibrado y no por casualidad: dispara a las ~27 deps y el muro está en 41, o sea **un factor 1,5
482de margen**. Y el margen es estructural, no del store de esta máquina: con rutas de longitud L el
483muro está en ~4002/(L+1) capas y el umbral en 3000/(L+16), cuyo cociente es **≥1,33 sea cual sea
484L**. No hay que recalibrarlo si el store se muda.
485
486**Y el muro tiene puerta, medida.** El límite es del `mount(2)` clásico, que pasa todas las opciones
487en un único buffer de una página. La API de montaje nueva las pasa de a una y overlayfs acepta
488`lowerdir+` repetido; el mismo banco monta así **las 64 capas reales que `mount(2)` no puede**, con
489el mismo coste por capa y montando en 406 µs:
490
491| N (API nueva) | acierto capa N-1 | FALLO | montar |
492|---|---|---|---|
493| 24 | 6 996 ns | 23 070 ns | 302 µs |
494| 48 | 25 388 | 39 832 | 362 µs |
495| **64** | 37 536 | 52 042 | **406 µs** |
496
497Sin muro, `merge_deps_layer` —y con él `.dmerge` entero— dejaría de hacer falta. El precio está en
498§7-H11: **bwrap no usa esa API**, y el overlay se monta dentro de su namespace.
499
500## 3. Las siete sorpresas
501
502Las anoto aparte porque contradicen el consejo de manual:
503
5041. **THP más lento que 4K** (T13) — el manual dice que THP acelera; con `defrag=madvise` y
505 memoria apretada, triplica el coste del fallo.
5062. **io_uring más lento que `pread`** sobre tmpfs (T12) — el manual dice que io_uring es el
507 futuro; sobre datos cacheados el futuro pierde 2,8×.
5083. **`stat` cuesta lo mismo en tmpfs que en ext4** (T8) — invalida la reacción natural
509 («movamos el árbol a tmpfs y vuela»): el coste está en el VFS.
5104. **`taskstats` binario NO es más rápido que parsear `/proc`** (§9.1) — 2 688 vs 2 697 ns por
511 consulta. La intuición «binario le gana a texto» falla porque el ida y vuelta de netlink cuesta
512 lo mismo que `open`+`read`+parse. Su valor es el **contenido** (delay accounting, que `/proc` no
513 da) y la estabilidad bajo carga, no la velocidad.
5145. **Sondear `/proc` no es lento: es CIEGO** (§9.1) — de 200 procesos de vida mínima lanzados uno
515 cada 10 ms, un sondeo cada 100 ms vio **cero**. Toda la discusión de «cuánto cuesta el barrido»
516 (T3) presupone que el barrido VE lo que hay, y para procesos cortos no lo ve.
5176. **Un filtro seccomp más largo NO cuesta más** (T17) — el consejo de manual es acortar la lista y
518 ordenarla por frecuencia; con el bitmap de acción constante, 4 entradas y 250 valen lo mismo.
519 Lo que sí cuesta es **mirar un argumento**: eso saca a esa syscall del bitmap y le devuelve la
520 cadena entera. La variable no es el largo, es si el veredicto depende de los args.
5217. **Apilar 24 capas overlay no encarece el build** (T19) — el consejo de manual (y el propio
522 comentario de `sandbox.rs`) invita a leer la fusión de deps como una optimización de velocidad;
523 medida, no lo es: el overlay cobra por PRIMER toque de cada ruta, no por acceso, y fundir 24
524 deps sólo se pagaría sola a partir de 35 200 primeros fallos en una misma fase. Lo que sí
525 justifica fundirlas es el muro de una página del `mount(2)`, que está en 42 capas.
526
527## 4. El hallazgo cruzado: los kernels de hammer no tienen `MEMCG`
528
529No lo buscaba; salió de verificar la recomendación antes de escribirla.
530
531**Lado hammer.** Las recetas hacen `make ARCH=x86_64 defconfig` y después
532`scripts/config`. Lo que `arch/x86/configs/x86_64_defconfig` trae de contabilidad:
533`CONFIG_TASKSTATS=y`, `TASK_DELAY_ACCT=y`, `CGROUPS=y`, `CGROUP_SCHED=y`, `CGROUP_PIDS=y`,
534`CGROUP_FREEZER=y`, `CONNECTOR=y` ⇒ y `PROC_EVENTS` es `default y` con `depends on
535CONNECTOR=y`, así que **también entra**.
536
537**`CONFIG_MEMCG` no está en el defconfig y no tiene `default y` ⇒ queda en `n` en todos los
538kernels de hammer.** `CONFIG_PSI`, igual.
539
540**Lado tawasuyu.** `sandokan-core::Engine` expone `set_memory_max` / `set_memory_high`
541(`shared/sandokan/sandokan-core/src/engine.rs:60-72`), `sandokan-local` los delega a
542`arje_incarnate::cgroup` (`03_ukupacha/sandokan/sandokan-local/src/lib.rs:675-681`), y ahí:
543
544```rust
545// arje/init/arje-incarnate/src/cgroup.rs:60
546let _ = std::fs::write(abs.join("cpu.weight"), format!("{w}\n")); // error DESCARTADO
547// :79
548match std::fs::write(&path, format!("{mem}\n")) {
549 Err(e) => tracing::warn!(..., "memory.max write failed"), // sólo un warn
550```
551
552**Consecuencia.** Sobre un sistema hammer, una Card que pide un tope de memoria arranca
553**sin tope**, y la única huella es una línea de log. Es exactamente la forma de fallo que
554`CLAUDE.md` §3 nombra: *un ausente falla ruidosamente; un vacío llega hasta el final diciendo
555que todo fue bien*. La jaula que no está se comporta igual que la jaula que sí está, hasta
556que hace falta.
557
558### 4.bis · Confirmado sobre los artefactos, no deducido del defconfig
559
560Lo de arriba se dedujo leyendo `x86_64_defconfig`. Se comprobó después contra los **`.config` que
561las recetas instalan** en `boot/config-<versión>`, que es la prueba dura porque incluye lo que hizo
562`olddefconfig`. Los **11 kernels sellados del store** —cinco nombres de artefacto: `linux`,
563`linux-generic`, `linux-metal`, `linux-metal-dual`, `linux-gioser`— traen literalmente:
564
565```
566# CONFIG_MEMCG is not set
567# CONFIG_PSI is not set
568```
569
570y ninguno tiene `CFS_BANDWIDTH` (⇒ `cpu.max`, la cuota dura de CPU, tampoco existe; hoy nadie la
571pide, pero cuando `pacha` la quiera será otro re-hasheo). Lo que **sí** está en los once:
572`CGROUP_SCHED`+`FAIR_GROUP_SCHED` (`cpu.weight`), `CGROUP_PIDS` (`pids.max`), `CPUSETS`
573(`cpuset.cpus`), `BLK_CGROUP`+`BLK_CGROUP_IOCOST` (`io.weight`, con la sintaxis «default N» que
574arje ya usa), `TASKSTATS`+`CONNECTOR`+`PROC_EVENTS`, `SECCOMP_FILTER`, `SECURITY_LANDLOCK`,
575`FANOTIFY`, `OVERLAY_FS` con metacopy e `IO_URING`.
576
577**Y por qué el bug sobrevivió meses**: el kernel de la máquina de desarrollo (Artix 7.1.4) **sí**
578trae `MEMCG`, `PSI` y `CFS_BANDWIDTH`. El fallo sólo existe del lado del artefacto sellado, que es
579el lado que nadie mira con un depurador.
580
581### 4.ter · El guardián: `hammer kernel contract`
582
583Un hallazgo escrito en un documento no impide que vuelva a pasar. El contrato vive en
584`docs/state/kernel-contract.toml` y declara, por capacidad: los símbolos que la construyen, la
585interfaz que aparece o no aparece (`memory.max`, `pids.max`, `landlock_restrict_self`…), **el
586consumidor con ruta y símbolo**, y **cómo falla hoy si no está**. Se comprueba contra un `.config`
587ya producido:
588
589```sh
590hammer --store ./store kernel contract --sealed # barre los kernels sellados
591hammer kernel contract --profile anfitrion-cards # el kernel vivo de esta máquina
592hammer kernel contract --list # qué promete hammer (esto es el W6)
593```
594
595Tres decisiones que valen la pena nombrar:
596
5971. **Por perfil, no global** — misma lección que el gate de hardware. `linux.toml` es el kernel de
598 QEMU del selfhost-verify: no hospeda Cards y su hash es load-bearing del baseline `of_tree`.
599 Exigirle contabilidad de memoria sería rechazar una receta sana, así que ahí los límites por
600 unidad son `wants` y no `requires`.
6012. **Contra el `.config`, no contra la receta** — entre el `scripts/config -e X` y el `.config`
602 está `olddefconfig`, que puede tragarse el símbolo sin decir nada (es justo lo que
603 `hammer kernel diff-back` ya vigila para los planes).
6043. **Distingue apagado de ausente** — un símbolo que el `.config` ni nombra puede ser un
605 renombrado entre versiones. Cuenta igual como capacidad ausente, pero se informa aparte; y un
606 kernel sin perfil declarado queda **SIN COMPROBAR** en vez de contar como aprobado.
6074. **Distingue vigente de superado** (agregado al pagar H1) — el store guarda **todos** los
608 sellados, no el último. Mirándolos a todos por igual, arreglar la receta dejaba el gate rojo
609 para siempre por artefactos que nadie va a volver a construir, y **un portón que no puede
610 ponerse verde deja de leerse**. Ahora cada sellado se compara con el `ArtifactHash` que la
611 receta de hoy produce —la misma vigencia de `hammer hash --check`, lab incluido—: el vigente
612 bloquea; el superado sale en su propia sección con lo que le falta, porque sigue siendo cierto
613 que una máquina que arranque ese kernel corre sus Cards con lo que ese kernel traiga. Y dos
614 negativas explícitas: si la vigencia no se puede resolver se comprueba **todo** y se dice por
615 qué, y **cero vigentes con superados a la vista sale ≠0**, no verde — el vacío leído como
616 presencia (CLAUDE.md §3) es justo el fallo que este guardián vino a arreglar.
617
618Estado al escribirlo: **2 de 11 configs sellados cumplían su perfil** (los dos `linux`), los 9 de
619perfil `anfitrion-cards` fallaban por `MEMCG`. **Estado tras pagar H1 (2026-08-30): 5 de 5 vigentes
620cumplen**, y los 10 sellados que quedaron atrás salen listados como superados. El kernel vivo de
621esta máquina pasa las 11 exigidas.
622
623Lo que **sí** funciona en un kernel hammer: `cpu.weight` (CGROUP_SCHED), `pids.max`
624(CGROUP_PIDS), `cpu.stat` (rstat del core) y `cgroup.freeze` — este último porque el freezer
625v2 es `obj-y` en `kernel/cgroup/Makefile`, no el `CONFIG_CGROUP_FREEZER` de v1.
626
627## 5. Qué se esquiva SIN escribir kernel
628
629| Tasa | Reemplazo mainline | Ganancia medida |
630|---|---|---|
631| T1 `fork` de padre grande | `posix_spawn` / `CLONE_VFORK` | 17× a 256 MB, y plano |
632| T2 `ld.so` | binario estático musl | 4,2× |
633| T3 `/proc` texto | `getrusage`, `taskstats` | 11× |
634| T4 sumar `/proc` por unidad | agregado de cgroup v2 | O(1) vs O(N), cruce en N=2 |
635| T5 liveness por `/proc` | `pidfd` (+ sin carrera de PID) | 2,9× y correctitud |
636| T3 enumerar procesos | proc connector (netlink): eventos push | **0 vs 200 procesos vistos** (§9.1) |
637| T6 señales | `eventfd`/`signalfd`/`pidfd` | 15 syscalls → 1 |
638| T8 tormenta de `stat` | `getdents64`, `fstatat(dirfd,…)` | 3,9×–12× |
639| T9 hermetismo de rutas | `openat2 RESOLVE_BENEATH` | ~gratis |
640| T10 materializar árbol | `link()` | 15,4× en ext4 |
641| T11 copiar un fichero entero | `FICLONE`/reflink **en xfs o btrfs** | **25 600× a 512 MiB** (§9.2) |
642| T15 cerrar fds | `close_range` | 102 µs → ~0 |
643
644**Ninguna de estas exige un módulo, un parche ni un kernel propio.** Es la primera conclusión
645del documento y la que ordena todo lo demás.
646
647## 6. Qué exigiría tocar el kernel — y su precio en hammer
648
649Después de agotar §5 quedan tres deseos legítimos que mainline no da:
650
6511. **Una syscall que devuelva un lote de estadísticas por proceso en binario.** Lo más
652 cercano es `taskstats` (por tarea, netlink) y el agregado de cgroup. No existe el «dame
653 los 291 en una llamada».
6542. **Eventos de estado con más contexto que el proc connector.**
6553. **Un plano de control de unidades que no pase por el filesystem.**
656
657Su precio, concreto:
658
659- **Es built-in, no `.ko`** (`-d MODULES`). ⇒ parche a la receta ⇒ entra en `hash_inputs`
660 ⇒ kernel nuevo, artefacto nuevo, y rebase manual en cada versión.
661- **Nunca un número de syscall nuevo**: choca con upstream para siempre. Lo compatible es un
662 char device, un fichero propio en sysfs, o una kfunc BPF.
663- **Con eBPF (que es la forma sin código out-of-tree) el precio es `DEBUG_INFO_BTF`**, hoy
664 apagado en las cuatro recetas con motivo escrito (`linux.toml:32`: *«pahole (BTF), ausente
665 del toolchain»*). Deuda ya medio pagada: dwarves construye (frente libdw/musl cerrado).
666- **El bloqueo de diseño, que no es técnico**: `SDD-SERVIDOR-AJENO.md` promete *«cero
667 invasión — la máquina sigue siendo su Ubuntu de siempre»*. Un sandokan que dependa de un
668 kernel hammer deja de correr ahí. ⇒ **el núcleo se queda portable siempre; lo rápido es un
669 backend opcional detectado en runtime, con `/proc` como respaldo que no se borra.**
670
671## 7. Propuestas — lado hammer
672
673- **H1 · `-e MEMCG -e PSI` en los kernels que hospedan Cards. PAGADO 2026-08-30.** Cierra §4 y
674 habilita `memory.current` (T4) y las señales de presión. Se hizo en **una sola tanda** —el precio
675 era el re-hasheo de todos los artefactos de kernel— sobre `linux-generic`, `linux-metal`,
676 `linux-metal-dual` y la derivada `linux-gioser`, que hubo que **regenerar**: una receta derivada
677 inlinea la fase `configure` de su base y sin regenerarla se queda con la vieja. En `linux.toml`
678 (perfil `qemu-serial`) NO corre: su hash es el baseline del selfhost-verify y ahí no hay Cards.
679
680 Tres cosas que valen para la próxima:
681
682 1. **El `x86_64_defconfig` de 7.1 ya no trae `MEMCG=y`** — de ahí que hiciera falta pedirlo. Y
683 `MEMCG` no tiene un solo `depends on` (sólo `select`s), así que el `olddefconfig` no se lo
684 podía tragar; se comprobó igual **contra el `.config` producido**, que es la regla.
685 2. **Precio en bytes, medido**: bzImage `linux-generic` 16 856 064 → 16 937 984 (**+80 KiB,
686 +0,49%**), `linux-metal` +84 KiB (+0,57%), `linux-metal-dual` +88 KiB (+0,53%), `linux-gioser`
687 +80 KiB (+0,71%). Contabilidad por unidad y presión, por medio punto porcentual de kernel.
688 3. **La prueba de que se pagó es el mismo comando**: `hammer kernel contract --sealed` sale 0 y
689 dice `5 de 5 config(s) VIGENTES cumplen su perfil`. La salida cruda quedó en
690 `docs/evidencia/tasas-kernel-2026-08-29/contrato-h1-pagado.txt`, junto con el `diff-back` del
691 plan de gioser sobre el config nuevo (35 cumplidos, 0 incumplidos).
692- **H2 · `MODULES=n` se queda.** Si algún día hace falta código de kernel, es built-in y
693 parcheado en la receta. Queda escrito para no rediscutirlo.
694- **H3 · Contención de rutas: el sitio no era harkaq, era la hidratación.** La propuesta original
695 decía «que harkaq use `openat2 RESOLVE_BENEATH` donde hoy valida rutas a mano (T9, ~gratis)».
696 Buscando ese sitio (2026-08-30) resultó que **harkaq no valida rutas a mano**: delega la
697 contención en Landlock, que es más fuerte que cualquier chequeo de ruta. Donde no se validaba
698 **nada** era en `hammer-build/hydrate.rs`.
699
700 **El fallo, reproducido**: hidratar es `target_fhs.join(rel)` y escribir por ruta ⇒ **un symlink
701 de directorio ya presente en el FHS se sigue**. Un artefacto que trae `usr/share/pkg → /algún/lado`
702 deja ese symlink puesto —los symlinks se replican literales, y así debe ser— y la hidratación
703 siguiente escribía `usr/share/pkg/archivo` **fuera del root, devolviendo `Ok(1)`**. Con
704 `hammer hydrate --into` sobre una imagen, eso es escribir en el sistema anfitrión diciendo que
705 todo fue bien: el fallo de CLAUDE.md §3, otra vez.
706
707 **Cuánto se estaba disparando: cero.** Del store: **160 symlinks absolutos, ninguno a un
708 directorio**; **~42 000 relativos, ninguno que salga de su artefacto**. Era una mina desactivada,
709 no un incendio — y por eso nadie lo iba a encontrar mirando logs.
710
711 **HECHO**: `no_escapa()` comprueba la contención **por directorio** (no por fichero: un
712 `canonicalize` por entrada costaría un realpath por fichero, T8/§9.4) y falla nombrando el
713 symlink y adónde lleva. Semántica **`RESOLVE_IN_ROOT`, no `RESOLVE_NO_SYMLINKS`**: un symlink que
714 se queda dentro tiene que seguir andando (usr-merge `/lib → usr/lib`, los `22@2x → 22` de los
715 temas de iconos), y hay un test para cada dirección en
716 `crates/hammer-build/tests/hidratacion_no_escapa.rs`.
717
718 **Y el mismo patrón estaba en el OTRO proyector**: `hammer-upgrade::project_plan` es
719 `target_root.join(rel)` + escribir por ruta, igual que hydrate. Con `--root /` no cambia nada
720 (todo empieza por `/`), pero `hammer upgrade apply --root /mnt/imagen` es el caso real de armar
721 una imagen: reproducido —la generación 2 escribía fuera del root con `ApplyReport` en verde— y
722 tapado con la misma regla (se mide el **directorio padre**, porque un `Replaced` sobre un symlink
723 que apunta afuera es legítimo: `rename` pisa el symlink, no escribe a través de él). Dos tests,
724 uno por dirección, en `crates/hammer-upgrade/src/lib.rs`.
725
726 **Lo que queda de la propuesta original**: `canonicalize`+escribir es comprobar-y-después-usar, o
727 sea que un adversario **concurrente** podría cambiar un componente entre las dos. Para el fallo
728 real —un symlink persistente puesto por otro artefacto— alcanza y es determinista. La versión sin
729 carrera es mover las escrituras a `openat2(RESOLVE_IN_ROOT)` + `linkat`/`renameat`, y arrastra el
730 camino de `patchelf` (que necesita una ruta, no un fd). Queda escrito acá con su precio, que es
731 más de lo que se sabía cuando H3 se llamaba «~gratis».
732- **H4 · Ningún `Command` de hammer pone `pre_exec`** — Rust apaga el camino `posix_spawn` en
733 cuanto hay uno, y cae a `fork+exec` (T1: 17× a 256 MB tocados). `hammer-build/sandbox.rs` lanza
734 `bwrap` sin `pre_exec`, o sea que estaba bien **por suerte**: nadie lo comprobaba. **HECHO**: el
735 test `crates/hammer-build/tests/sin_pre_exec.rs` barre las fuentes del workspace y falla si
736 aparece uno (comprobado en los dos sentidos: inyectando un `pre_exec` el test se pone rojo, y un
737 barrido que no encuentra fuentes también, para que no apruebe por vacío). Si algún día hace falta
738 de verdad, la salida es escribir en el test por qué ese caso paga los 17×, no borrarlo.
739- **H5 · La materialización de dependencias se queda en hardlink** (T10) y el experimento de
740 reflink (§9) alimenta SDD 18 (wawafs), donde el store como FS vivo cambiaría el cociente.
741- **H6 · No `madvise(MADV_HUGEPAGE)` en el camino de build** mientras `defrag=madvise` y la
742 granja corra apretada de RAM (T13).
743- **H7 · `DEBUG_INFO_BTF` sigue apagado — pero ya no por falta de número.** §9.5 midió qué
744 compra: un iterador `task` saca la foto de procesos **cerca de 7× más barato** que el barrido de
745 `/proc` (6,9-8,0× en cuatro corridas y dos órdenes), y —lo que de verdad decide— **al precio de
746 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.
747
748 El precio, en cambio, es más grande de lo que decía esta línea («pahole en el lab y un
749 re-hasheo»). Son **cinco** cosas —la quinta salió construyendo, el 2026-08-31— y ninguna es un rediseño:
750
751 1. **Los kernels no compilan con información de depuración**: `CONFIG_DEBUG_INFO_NONE=y` en los
752 `.config` sellados. BTF exige DWARF, así que primero hay que encender `DEBUG_INFO_DWARF5`.
753 Eso encarece el build, **no el `bzImage`**: el DWARF vive en `vmlinux`, que la receta no
754 empaqueta — lo único que entra a la imagen es la sección `.BTF`.
755 2. **pahole está en el catálogo, pero el kernel no lo declara.** `recipes/dwarves.toml` (1.30)
756 se selló al cerrarse el frente libdw/musl, y su propia cabecera lo dice: «El kernel NO la
757 declara todavía».
758 3. **Ese pahole era DINÁMICO pese a `link = "static"` — PAGADO el 2026-08-31.** El artefacto de
759 entonces (`8057bcd…`) traía `INTERP` y cinco `NEEDED` (`libelf`, `libz`, `liblzma`, `libbz2`,
760 `libc`), y corre **dentro del sandbox del build del kernel**: sin esas librerías ahí no
761 arranca, y el fallo habría salido como «pahole no está» estando. Arreglado al reabrirse el
762 frente de `link = static` (ver el `git log` de `recipes/dwarves.toml`); el artefacto vigente
763 es `v1.30` y estáticamente enlazado. **Y quedó probado de la única manera que vale: el build
764 de medición de abajo lo encontró y lo ejecutó dentro del sandbox**, no en el host.
765 4. **BTF mete a pahole en el juego de la bit-repro.** El `.BTF` lo emite pahole ⇒ su versión
766 pasa a ser parte de la identidad del artefacto, y el lab **no está en `hash_inputs`**: dos
767 labs con pahole distinto sellarían bytes distintos en la misma dirección sin que el store lo
768 note. Es el agujero conocido, con una herramienta más adentro.
769 5. **Y una quinta, que no está en ninguna documentación de BTF: hace falta `python3`.**
770 Encender BTF hace que kbuild descienda a `tools/bpf/resolve_btfids`, que compila un **libbpf
771 vendorizado** cuyo Makefile genera `bpf_helper_defs.h` con un script de Python ⇒
772 `env: 'python3': No such file or directory`, `Error 127`. Salió construyendo, no leyendo.
773
774 **Y ya hay dos consumidores nombrados, no cero.** El iterador de §9.5, que H8 aceptaría porque
775 trae ruta y símbolo (`bpf_iter_create`, `attach_btf_id` resuelto contra el BTF del vmlinux); y
776 **sched-ext**, que es por lo que existe la receta de dwarves — `SCHED_CLASS_EXT` depende de
777 `DEBUG_INFO_BTF`. El primero está medido; el segundo todavía no tiene su símbolo.
778
779 **El precio en bytes, MEDIDO el 2026-08-31** (`docs/evidencia/tasas-kernel-2026-08-29/btf-precio.txt`).
780 Se construyó una copia derivada de `linux-generic` con una sola diferencia en el `.config`
781 —`DEBUG_INFO_DWARF5` + `DEBUG_INFO_BTF`— y todo lo demás byte a byte igual:
782
783 | | bzImage | delta |
784 |---|---|---|
785 | `linux-generic` sellado (sin BTF) | 16 937 984 B | — |
786 | el mismo con DWARF5 + BTF | 19 473 408 B | **+2 535 424 B = +2,42 MiB = +14,97%** |
787
788 La sección `.BTF` del `vmlinux` son **7,52 MiB** sin comprimir (`.BTF_ids`, 2 344 B más) y entran
789 al `bzImage` comprimidos 3,11×. **Contra la única vara comparable que tenemos: H1 costó +80 KiB
790 (+0,49%) del mismo bzImage ⇒ BTF cuesta 31 veces lo que costó H1.** Eso no lo decide, pero lo
791 saca del terreno de «un re-hasheo y ya»: un +15% de imagen es una discusión de producto, no de
792 toolchain, y hay que tenerla contra los dos consumidores nombrados.
793
794 **Sobre `linux-generic` y no sobre `linux`, que es más barato de construir**: BTF tiene
795 `depends on BPF_SYSCALL` y el `.config` sellado de `linux` lo trae **apagado**, así que medir ahí
796 habría obligado a encender las dos cosas a la vez y el delta mezclaría dos precios.
797
798 **El número de Artix no servía — y falla en la dirección contraria a la esperable.** Esta sección
799 decía que sus 6,4 MB «no dicen nada de uno monolítico y pelado». Cierto, pero el nuestro sale
800 **más grande**: 7,5 MiB. Artix es **modular** y el `.BTF` de su `vmlinux` cubre sólo el core
801 built-in; el nuestro es monolítico (i915 + nouveau + radeon + iwlwifi + todo lo demás son `=y`) y
802 los tipos de todos esos drivers entran. **Un kernel con menos drivers cargables tiene MÁS BTF que
803 uno enorme lleno de módulos.**
804
805 **Lo que cuesta construirlo no es lo que se paga al arrancar**: el `vmlinux` con DWARF pesa
806 561 MiB —396 de ellos `.debug_info`— y el árbol de build llegó a 7,9 G; el build tardó 48 min con
807 `-j2`. Nada de eso entra en la imagen: la receta empaqueta `bzImage`, no `vmlinux`. Es precio de
808 build (tiempo, disco y la RAM de pahole), no de arranque.
809
810 **Y una trampa del kconfig que hay que dejar escrita, porque es CLAUDE.md §3 dentro de `make`:**
811 `scripts/pahole-version.sh` imprime **`0`** si pahole no está en el PATH, `depends on
812 PAHOLE_VERSION >= 122` deja de cumplirse, y **`olddefconfig` borra la línea en silencio**. El
813 build sale **OK** y sella un kernel **sin BTF** diciendo que todo fue bien. Por eso la fase
814 `configure` de la receta de medición comprueba el `.config` **producido** y sale 1 si no está —
815 la misma regla de §4.bis, aplicada un escalón antes. Salió `OK: CONFIG_DEBUG_INFO_BTF=y`, que es
816 lo que paga la parte (3): ese pahole estático **se encuentra y se ejecuta dentro del sandbox**.
817
818 El artefacto de medición **se borró a propósito** tras anotar los números: `hammer kernel
819 contract --sealed` lo listaba como SIN COMPROBAR —no declara `[[target]]`, que es justo lo que
820 exige H8— o sea un ⚠ permanente en `docs/state/kernel-contract.txt`, que el cron commitea cada
821 30 min. Un aviso fijo que no corresponde a ningún problema es exactamente cómo se deja de leer un
822 vigía. La receta derivada queda junto a la evidencia, con las instrucciones para reconstruirla.
823- **H10 · El filtro de harkaq se queda como está — y ahora se sabe por qué.** T17/T18 midieron el
824 grano fino de la jaula y la conclusión es que **no hay nada que optimizar**: 0,23–0,31% de un
825 compilado, contra el ~1,09 ms por proceso de los namespaces (T14) que es diez veces más. Tres
826 cosas que sí quedan escritas para que no se rompan por accidente:
827
828 1. **No reordenar la denylist «por frecuencia» ni recortarla «para que sea rápida».** Es plana en
829 su longitud, por el bitmap de acción constante. Es trabajo cero con riesgo de romper la
830 política.
831 2. **Un filtro que mire argumentos deja de ser gratis, y sólo para las syscalls que mire.** Si
832 algún día hace falta (filtrar `ioctl` por petición, `clone` por flags), el precio es la cadena
833 entera en cada llamada de esa syscall — 0,158 ns por instrucción, medido. Se decide con ese
834 número delante, no con la intuición de que «un filtro es un filtro».
835 3. **La denylist tiene un techo de ~252 entradas** por el `__u8` de los saltos de la BPF clásica.
836 Hoy son 25. **HECHO**: un `_Static_assert` en `harkaq-exec.c` lo cierra, para que crecer la
837 lista no rompa el filtro en silencio — la misma forma de fallo que CLAUDE.md §3. Y **el UB del
838 punto (2) de T17 también está cerrado**, con el desensamblado como prueba de que no cambia
839 nada más.
840
841 Y la política **fichero a fichero** de D1 queda confirmada por el lado del coste: cambiarla por
842 reglas de directorio ahorraría 225 ns por `open` de los 698, o sea el 32% de lo barato, a cambio
843 de que la evidencia deje de significar algo. No se toca.
844- **H8 · El contrato de capacidades es parte del release del kernel** (§4.ter). Toda receta de
845 kernel nueva declara su `[[target]]` con perfil; un kernel sin perfil sale **SIN COMPROBAR**, no
846 aprobado. Y toda capacidad nueva entra con consumidor —ruta y símbolo— o no entra: «estaría bueno
847 tener» no es un contrato.
848- **H9 · El experimento de reflink cambia el cálculo de SDD 18, no el de hoy.** Con `.dmerge` sobre
849 ext4 el hardlink sigue siendo la elección correcta (T10). Lo que §9.2 agrega es que un store
850 sobre **xfs o btrfs** compraría semántica de copia —mutación privada, sin la contrapartida de
851 retener lo borrado— al mismo precio. Es un argumento para wawafs, medido; no un cambio de
852 filesystem decidido acá.
853
854- **H11 · El umbral de fusión de capas se queda, y ahora se sabe qué es.** T19 lo midió: no
855 compra velocidad (2,9 ms por fase de compilado a 24 capas), compra **no chocar contra el muro de
856 una página de `mount(2)`**, que está en 42 capas / 4 158 B de opciones. Tres consecuencias:
857
858 1. **No bajar el umbral «para que vaya más rápido».** No hay nada que ganar y sí que perder: cada
859 fusión cuesta ~20 µs por fichero (264 ms para 24 deps reales) y deja un árbol de hardlinks en
860 `.dmerge` que **retiene los artefactos que borres del store**. Es la deuda de disco conocida.
861 2. **Y por lo mismo, podar `.dmerge` es GRATIS en rendimiento.** Lo único que se pierde al
862 borrarla es rehacer el `cp -al` de la próxima receta grande: cientos de milisegundos. Eso
863 cambia el cálculo de la poda, que hasta ahora se hacía sin saber qué se estaba tirando.
864 3. **El umbral no hay que recalibrarlo si el store se muda**: el muro va como ~4002/(L+1) capas y
865 el umbral como 3000/(L+16), así que el margen es ≥1,33 para cualquier longitud de ruta L.
866
867 **Lo que queda abierto, con su precio:** montar el overlay con la API nueva (`fsopen`+`fsconfig`
868 con `lowerdir+` repetido) borra el muro —el banco monta así las 64 capas reales que `mount(2)`
869 rechaza— y con él la necesidad de `.dmerge`. El precio no es el código de hammer: **es bwrap**,
870 que monta el overlay dentro de su namespace con la API vieja. O se parchea bwrap, o el overlay se
871 monta fuera y bwrap lo entra con `--bind`, lo que mueve el montaje al lado privilegiado. No se
872 decide acá; se deja medido que la puerta existe.
873
874## 8. Propuestas — lado tawasuyu
875
876> **✅ LAS SEIS CERRADAS (2026-09-02/03).** Detalle y evidencia en el handoff espejo del otro
877> repo (`tawasuyu/HANDOFF-TASAS-KERNEL-DESDE-HAMMER.md` §3). Lo que hay que saber desde acá:
878>
879> - **W4 dejó tres pendientes y se cerraron como W4.bis (2026-09-03).** El grande no era lo que
880> este documento suponía: en arje el límite **no se descartaba, no se pedía**. El camino
881> `plain` —el de casi todas las Cards, con los namespaces en `false`— no creaba cgroup, y
882> `apply_rlimits_to_cgroup` la llamaba sólo shuma, así que arje nunca escribió `memory.max`
883> ni `pids.max`. Ahora hay una sola puerta (`arje-incarnate::cgroup::preparar`) y el hijo del
884> camino `plain` entra al cgroup entre `fork` y `execve`.
885> - **`docs/state/kernel-contract.toml` quedó al día el 2026-09-03**: alta de
886> `proceso-por-descriptor` (pidfd, que se usa desde W1 y no estaba declarada), `consumer` por
887> nombre de función sin número de línea, y los `silent` de los cinco cgroups reescritos —
888> describían un bug ya arreglado, que es la peor forma que tiene un guardián de mentir.
889> - **Lo que sigue sin evidencia**: el OOM-kill del kernel sobre un Ente de arje. Sólo se puede
890> ver en una máquina con delegación de cgroups; el clon de desarrollo corre con uid 1001 sobre
891> la jerarquía de root.
892
893En una línea cada una, como se propusieron:
894
895- **W1 · `pidfd` para liveness y señales** en `sandokan-local` (hoy `access("/proc/<pid>")` y
896 `waitpid(WNOHANG)` en sondeo). Se gana correctitud (carrera de PID) antes que velocidad.
897- **W2 · Telemetría por unidad desde el cgroup**, no sumando `/proc` (T4). Con **sonda de
898 capacidad**: si `memory.current` no existe (kernel sin MEMCG), decirlo, no callarlo. Del lado
899 hammer esa sonda ya tiene con qué contrastarse: `hammer kernel contract --list --json` publica
900 qué promete cada perfil de kernel (§4.ter), que es el W6 en formato de dato.
901- **W3 · `arje-incarnate` con `posix_spawn`** donde el `pre_exec` no sea imprescindible (T1),
902 y `close_range` en el que quede.
903- **W4 · Los errores de cgroup dejan de descartarse.** `let _ = write(...)` y `warn!` sobre un
904 límite no aplicado son el bug de §4, no un detalle de estilo.
905- **W5 · `/proc` no se borra nunca**: es el respaldo portable que hace posible
906 SDD-SERVIDOR-AJENO.
907- **W6 · El contrato entre repos es el trait `Engine`, no una ABI de kernel.** hammer publica
908 la lista de símbolos garantizados (verificable con `hammer kernel probe`); tawasuyu elige
909 backend en runtime.
910
911## 9. Los experimentos nombrados — cinco medidos, uno abierto
912
913La primera corrida los dejó nombrados y sin medir, todos por privilegios o por herramienta
914ausente. **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
915(load ~1,3 sobre 4 vCPU, 4,3 GiB libres) ⇒ los números de acá **no se cruzan** con los de §2.
916
917### 9.1 · proc connector y `taskstats` — MEDIDO (`bench_connector.c`, `connector.txt`)
918
919**a) Latencia.** Esperar el evento `PROC_EVENT_EXIT` del connector **no cuesta más** que el
920`waitpid` que el padre ya hace: 116,0 µs contra 114,8 µs por `fork+_exit` (mínimo de 7 rondas de
921200), es decir dentro del ruido, con **0 eventos perdidos de 1 400**. Con una advertencia dura que
922salió del propio experimento: el multicast del connector **no reintenta**. Si el buffer del socket
923se llena, el evento se pierde y quien lo espera se cuelga para siempre — la primera versión del
924programa se colgó exactamente así. Un supervisor sobre connector necesita `SO_RCVBUF` grande **y
925un plazo**, no una espera infinita.
926
927**b) Cobertura — el resultado que importa.** 200 procesos de vida mínima, uno cada 10 ms:
928
929| observador | vistos | CPU |
930|---|---|---|
931| sondeo de `/proc` cada 100 ms (23 barridos) | **0 de 200** | 10,5 ms |
932| proc connector (eventos) | **200 de 200** | 95,4 ms |
933
934El sondeo no es caro: es **ciego**. Y la CPU del connector de esa tabla es cota superior — el
935programa lo lee con espera activa de 200 µs, que no es el modo de un supervisor real (sería
936`epoll`, donde el coste es despertar por evento).
937
938**c) `taskstats`.** La familia genetlink existe y responde. Por consulta: **2 688 ns** contra
939**2 697 ns** de `open`+`read`+parse de `/proc/self/stat`. **No gana en velocidad** (sorpresa §3.4).
940Lo que da y `/proc` no: contabilidad de retrasos (delay accounting) y una estructura binaria
941estable en vez de un texto que hay que parsear posicionalmente.
942
943### 9.2 · Copia con reflink — MEDIDO (`bench_reflink.c`, `reflink.txt`)
944
945`mkfs` sobre un loop, xfs con `reflink=1` y btrfs por defecto. Copiar un fichero **entero**:
946
947| | ext4 | xfs (loop) | btrfs (loop) |
948|---|---|---|---|
949| `FICLONE` (reflink) | **no soportado** | 0,010 ms | 0,006 ms |
950| `copy_file_range` | 13,66 ms | 0,010 ms | 0,006 ms |
951| `read+write` (1 MiB) | 14,46 ms | 10,96 ms | 31,32 ms |
952
953Y la prueba de que es O(1) y no «rápido»: **con el fichero ocho veces más grande (512 MiB), el
954reflink pasa de 0,010 a 0,020 ms** —el doble, no ocho veces— mientras `read+write` pasa de 10,96 a
955512,7 ms. **25 600× a 512 MiB en xfs.** En btrfs, 0,006 → 0,015 ms.
956
957Confirma T11 por el lado contrario: en ext4 `copy_file_range` no gana nada (13,66 contra 14,46 ms,
958un 6%) **porque ext4 no tiene reflink**; el «copiar en O(1)» que la gente espera de esa syscall es
959una propiedad del filesystem, no de la syscall. Material directo para SDD 18 (wawafs): con reflink
960se obtiene semántica de **copia** al precio del hardlink, y sin la contrapartida del hardlink —que
961`.dmerge` ya paga: retener en la caché lo que se borró del store.
962
963### 9.3 · io_uring con `SQPOLL` — MEDIDO (`bench_sqpoll.c`, `sqpoll.txt`)
964
9654K aleatorios en caché, profundidad 32, fd registrado:
966
967| ns/op | ext4 | tmpfs |
968|---|---|---|
969| `pread` | **554** | **749** |
970| io_uring sin SQPOLL | 618 | 2 030 |
971| io_uring con SQPOLL | 868 | 1 409 |
972
973**No cambia el veredicto de T12.** Sobre datos cacheados `SQPOLL` sigue perdiendo contra la
974syscall (1,6× peor en ext4), y aunque en tmpfs mejora al io_uring normal (1,4×), sigue estando
9751,9× por detrás de `pread`. Encima **se come un core** con el hilo del kernel, que en una máquina
976de 4 vCPU es el 25% de la granja. `SQPOLL` es para operaciones que **de verdad bloquean**, no para
977amortizar cruces sobre caché.
978
979### 9.4 · Coste en syscalls de un compilado — MEDIDO (`syscalls-compilador.txt`)
980
981La corrida original decía «sin `strace`/`perf` en esta máquina no se puede atribuir». **El
982`strace` estaba en el propio corpus** (`store/3c1fd8c0…-strace`, 6.19): la distro se paga a sí
983misma la herramienta de diagnóstico. Un `-c` de un `main()` trivial con stdio/stdlib/string:
984
985| | syscalls | con error | el grueso |
986|---|---|---|---|
987| `cc -O2 -c` (gcc 16) | 1 803 | **1 095 (61%)** | **919 de 923 `readlink` FALLAN** |
988| `zig cc -O2 -c` (zig 0.16) | 3 608 | 214 (6%) | 1 104 `futex` (70% del tiempo en syscall) |
989
990Dos lecturas:
991
992- **La tormenta de rutas de T8 existe y es de `readlink`, no de `stat`** — canonicalización de
993 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
994 de compilado: el 0,5%.** La respuesta a la pregunta original («¿vale segundos o minutos por
995 receta?») es **milisegundos por invocación**: segundos por millar de compilados. No es ahí donde
996 está el tiempo de una tanda.
997- **`zig cc` tiene otro perfil**: casi no falla rutas, pero coordina hilos. El tiempo que
998 `strace -c` le atribuye es espera **bloqueada**, no CPU — y por eso `strace -c` no sirve para
999 comparar compiladores, sólo para contar llamadas.
1000
1001### 9.5 · Iterador BPF contra `/proc` — MEDIDO (`bench_bpf_iter.c` + `bpf_iter_task.bpf.c` → `bpf-iter.txt`)
1002
1003Medido el **2026-08-31**, sobre el kernel de desarrollo (Artix 7.1.4, que sí trae BTF) —
1004**no sobre un kernel de hammer, que es justamente lo que está en discusión**. Tercera corrida,
1005condiciones propias: **sus números no se cruzan con los de §2 ni con los de §9.1-9.4.**
1006
1007Los dos caminos hacen **el mismo trabajo**: sacar `{pid, ppid, estado, utime, stime, comm}` de
1008todos los procesos vivos, que es la foto que sandokan necesita de la máquina. Uno lo hace con
1009`readdir` + `open`+`read`+parse de `/proc/<pid>/stat`; el otro con un `iter/task` que escribe
1010**registros binarios de tamaño fijo** con `bpf_seq_write`, leídos de un solo fd.
1011
1012| 245 procesos vivos, 33 rondas, mínimo | total | por proceso | × sobre el piso |
1013|---|---|---|---|
1014| A) `/proc` readdir+`stat`+parse | 1 356,1 µs | 5,54 µs | 12,1× |
1015| B) iterador BPF (binario) | **196,1 µs** | **0,80 µs** | **1,75×** |
1016| C) piso: `readdir(/proc)` a secas | 112,1 µs | 0,46 µs | 1× |
1017
1018**6,9× a favor del iterador** — pero el número que decide no es ése: **el iterador cuesta casi lo
1019mismo que listar el directorio y no abrir nada** (1,75× el piso, contra 12,1× de `/proc`). Leer
1020los campos deja de tener precio; lo que se paga es enumerar.
1021
1022Siete corridas, dos órdenes (A→B y B→A, para descartar que el primero de cada ronda pague el
1023enfriamiento de las dentries de `/proc`): **6,9-8,0× en las cuatro de 33 rondas**, en las dos
1024direcciones. La de 15 rondas dio 4,50× y es de la que hay que desconfiar, no de la mayoría: con
1025pocas rondas el mínimo no converge y la máquina estaba cargada.
1026
1027**Amortización**: cargar y verificar el programa cuesta **13-17 ms una vez**; cada barrido ahorra
1028más de 1 ms ⇒ **se paga solo en 12-40 barridos** según la corrida, 15 en la citada. Para un
1029sandokan que mire la máquina una vez por segundo, medio minuto.
1030
1031**Lo que la ganancia cuesta, comprobado y no supuesto** — son dos barreras, no una:
1032
10331. **BTF es un portón, no una optimización.** El programa cargado informa
1034 `prog_type=26 (BPF_PROG_TYPE_TRACING)`, `attach_btf_id=80137`, `attach_btf_obj_id=1`: el
1035 iterador **se ata por id de tipo BTF del vmlinux**. Un kernel sin `DEBUG_INFO_BTF` no publica
1036 `/sys/kernel/btf/vmlinux` y no hay a qué atarse. **No hay versión de esto que esquive H7.**
10372. **Privilegio**: `CAP_BPF`+`CAP_PERFMON` o root. Sin él, `bpf_object__load` sale
1038 `Operation not permitted`. Una Card no lo tiene ni debe tenerlo ⇒ **el consumidor tiene que
1039 ser el proceso privilegiado**. Y del lado tawasuyu eso **no es un cambio en el sitio**: el
1040 barrido de `/proc` de hoy vive en `sandokan-local`, que corre **sin privilegio** (se mete en un
1041 user namespace a propósito), mientras que el único proceso privilegiado de la máquina es
1042 `sandokan-daemon` —lo dice su propio `cortafuegos.rs`—. O sea que W2 por esta vía **no es un
1043 reemplazo en el lugar: mueve la telemetría al otro lado de la frontera de privilegio**, y ése
1044 es un coste de diseño que no aparece en el cociente de 7×.
1045
1046**Y los dos caminos NO dicen exactamente lo mismo.** De 245 procesos, **24 `comm` difieren**, y
1047son **todos kworkers**: `/proc` **sintetiza** el nombre pegándole la workqueue que el hilo está
1048sirviendo (`kworker/0:0H-kblockd`), mientras `task->comm` trae el nombre pelado
1049(`kworker/0:0H`). Ningún proceso de usuario difiere. Para las Cards da igual; para cualquiera que
1050muestre nombres de kworker, no. **Un reemplazo que se vende como "la misma información más
1051rápido" tenía una diferencia de contenido, y sólo apareció cotejando registro contra registro** —
1052no medir es lo que la habría dejado pasar.
1053
1054**Nota de método**: el bench aborta con exit 2 si el iterador devuelve **cero registros**. Un
1055barrido vacío es infinitamente rápido y no mide nada: es CLAUDE.md §3 aplicado a un benchmark.
1056
1057### 9.6 · Lo que sigue abierto
1058
10591. **Un Intel con PTI — y el experimento no es el que decía esta línea.** Decía: «en el laptop
1060 (TigerLake) las mismas medidas darían peor». Eso compara **dos máquinas**, así que mezcla PTI
1061 con microarquitectura, frecuencia y hipervisor: cualquier diferencia se le podría atribuir a
1062 cualquiera de las cuatro. El experimento que aísla la tasa es **una sola máquina con `pti=on` y
1063 con `pti=off`** —el kernel acepta `pti=on` aunque el CPU no sea vulnerable, así que forzarlo es
1064 posible—, midiendo el piso de syscall **en función del working set tocado**: sin PTI es plano,
1065 y con PTI crece, porque lo que PTI cobra es el cambio de CR3 y la TLB que se lleva puesta.
1066
1067 **Y `momento` no sirve ni forzándolo, por una razón que salió mirando**: este invitado **no
1068 tiene PCID** (`grep pcid /proc/cpuinfo` no devuelve nada; 88 flags en total). Sin PCID cada
1069 entrada y salida del kernel con PTI es un vaciado completo de TLB, o sea el régimen **peor
1070 caso**, que no es el que pagaría un TigerLake —que sí trae `pcid`+`invpcid`—. Medirlo acá daría
1071 una cota superior verdadera y una respuesta engañosa a la pregunta que importa. ⇒ **el
1072 experimento necesita el laptop**, y ahí necesita las dos corridas, no una.
1073
1074## 10. Evidencia
1075
1076`docs/evidencia/tasas-kernel-2026-08-29/` — **primera corrida**: cuatro programas en C
1077(`bench_proc.c`, `bench_io.c`, `bench_extra.c`, `bench_pin.c`) y sus cinco salidas crudas
1078(`proc.txt`, `io-ext4.txt`, `io-tmpfs.txt`, `extra.txt`, `pin.txt`). Se compilan con
1079`cc -O2` (`bench_io` pide `-luring`, `bench_proc`/`bench_pin` piden `-lpthread`) y se corren
1080sin argumentos salvo `bench_io <dir>` y `bench_pin <estatico> <dinamico> <musl>`.
1081
1082**Nota de unidad**: en `pin.txt` la sección de fallos de página imprime ns/página × 1000 por
1083un factor de escala equivocado en el programa; los valores citados en T13 son los de esa
1084salida divididos por 1000, y coinciden con `io-ext4.txt`, que sí está en la unidad correcta.
1085
1086**Segunda corrida (§9, con root)**: `bench_connector.c` → `connector.txt`; `bench_reflink.c` →
1087`reflink.txt` (xfs y btrfs sobre loop, montados y desmontados por el propio guion);
1088`bench_sqpoll.c` → `sqpoll.txt`; y `syscalls-compilador.txt`, hecho con el `strace` **del store**.
1089Se compilan igual que los otros (`bench_sqpoll` pide `-luring`) y los tres primeros piden root:
1090`bench_connector` por `CAP_NET_ADMIN`, `bench_reflink` por `mkfs`+`mount`, `bench_sqpoll` por
1091`CAP_SYS_NICE`.
1092
1093**Contrato de capacidades**: `docs/state/kernel-contract.toml` (el dato) y
1094`hammer kernel contract --list` (la vista). Los ficheros de esta carpeta no cambian; el contrato sí
1095—cada vez que un consumidor nuevo usa el kernel por nombre.
1096
1097**Tercera corrida (§9.5, iterador BPF, 2026-08-31)**: `bpf_iter_task.bpf.c` (el programa BPF) +
1098`bench_bpf_iter.c` (el de usuario) → `bpf-iter.txt`. Se compilan con
1099`clang -O2 -g -target bpf -D__TARGET_ARCH_x86 -c bpf_iter_task.bpf.c -o bpf_iter_task.bpf.o` y
1100`cc -O2 -o bench_bpf_iter bench_bpf_iter.c -lbpf`; se corre `./bench_bpf_iter [rondas] [orden]`
1101como root, con el `.bpf.o` al lado. **No usa `vmlinux.h`** —haría falta bpftool, que no está en la
1102máquina ni en el store— sino los tipos mínimos declarados a mano con `preserve_access_index`, que
1103es lo que deja a CO-RE reubicar los offsets contra el BTF del kernel en carga.
1104
1105**Cuarta corrida (§7-H7, precio de BTF)**: `btf-precio.txt` —el delta de bzImage, la sección `.BTF`,
1106las condiciones y las dos deps que hubo que añadir— junto con `linux-generic-btf.toml` (la receta
1107derivada, con las instrucciones para reconstruirla) y `config-7.1.2-generic-btf` (el `.config`
1108producido, que es donde se comprueba que BTF sobrevivió al `olddefconfig`). **No es un
1109microbenchmark: es un kernel construido**, así que sus números no se cruzan con los de §2 ni §9.
1110
1111**Quinta corrida (T17/T18, la jaula por syscall, 2026-09-01)**: `bench_jaula.c` → `jaula.txt`. Se
1112compila con `cc -O2 -o bench_jaula bench_jaula.c` y se corre `./bench_jaula [rondas] [dir-temporal]`
1113**sin root** —ni seccomp con `no_new_privs` ni Landlock lo piden—. Deja un árbol de 16 384 ficheros
1114en el directorio temporal que hay que borrar a mano. Cada configuración vive en un hijo propio,
1115porque ni el filtro ni el ruleset se pueden quitar una vez puestos. **Y aborta con exit 2 si la
1116jaula no quedó puesta**: comprueba que un número de la denylist da `EPERM` y que un fichero testigo
1117fuera del ruleset da `EACCES`, porque medir una jaula apagada da números preciosos y falsos
1118(CLAUDE.md §3, la misma regla que el exit 2 de §9.5).
1119
1120**Sexta corrida (T19, el overlay por capa, 2026-09-01)**: `bench_overlay.c` → `overlay.txt`. Se
1121compila con `cc -O2 -o bench_overlay bench_overlay.c` y **pide root** (hace `mount(2)` de overlay).
1122Tres modos: sin argumentos extra construye su propio árbol sintético (secciones A/B/C);
1123`./bench_overlay <rondas> <dir-base> <fichero-con-capas>` apila las rutas de ese fichero, una por
1124línea, la primera arriba (D1/D2); y con un cuarto argumento cualquiera usa la API de montaje nueva
1125(E). **El merge de hardlinks cae en el filesystem de las capas, no en el del banco** —el store de
1126`momento` es un bind de `/dev/sdb` y el árbol del banco vive en `/dev/sdc`, así que `cp -al` sería
1127cross-device: el mismo cálculo que hace `merge_deps_layer`, y el canario lo destapó—. Deja árboles
1128grandes (hasta 16 384 ficheros sintéticos y un merge de los artefactos reales) que hay que borrar a
1129mano. **Aborta con exit 2 si la medición no está puesta**: comprueba que todos los ficheros de la
1130capa más profunda se ven a través del montaje y que un inexistente no. `overlay.txt` trae además la
1131bisección del muro con `mount(2)` directo y el recuento de rutas distintas de un compilado.
1132
1133**El pago de H1** (2026-08-30): `contrato-h1-pagado.txt` — el barrido entero con los cuatro kernels
1134reconstruidos (5 de 5 vigentes en verde, 10 superados listados) y el `diff-back` del plan de gioser
1135contra su `.config` nuevo. Es la única salida de esta carpeta que sí cambia si se vuelve a correr:
1136las demás son mediciones de un momento, ésta es un veredicto de hoy.
25-tasas-del-kernel.md
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
-
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.
-
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.
-
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).
-
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).
-
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).
-
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).
-
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/<pid>: la carrera, no la lentitud
| ns |
access("/proc/<pid>") — 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:
-
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.
-
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í:
- 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.
- El acierto en la capa del fondo crece +295 ns por capa — el overlay va preguntando hacia
abajo hasta encontrarlo.
- 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:
- THP más lento que 4K (T13) — el manual dice que THP acelera; con
defrag=madvise y
memoria apretada, triplica el coste del fallo.
- 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×.
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.
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.
- 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.
- 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.
- 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í:
// 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-<versión>, 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:
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:
- 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.
- 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).
- 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.
- 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:
- 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».
- Eventos de estado con más contexto que el proc connector.
- 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:
- 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 selects), así que el olddefconfig no se lo
podía tragar; se comprobó igual contra el .config producido, que es la regla.
- 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.
- 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:
- 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.
- 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».
- 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.
- 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.
- 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:
- 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.
- 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».
- 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:
- 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.
- 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.
- 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/<pid>") 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/<pid>/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:
- 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.
- 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
-
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 <dir> y bench_pin <estatico> <dinamico> <musl>.
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 <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:
las demás son mediciones de un momento, ésta es un veredicto de hoy.