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 construyen **sin `CONFIG_MEMCG`**, y arje/sandokan escriben `memory.max` ignorando el
30 error. Un límite de memoria pedido por una Card **no se aplica y nadie se entera**. §4.
31 Confirmado después sobre los **11 `.config` sellados**, no deducido del defconfig (§4.bis), y
32 ya vigilado por `hammer kernel contract` (§4.ter): 2 de 11 cumplen su perfil.
334. **Sondear `/proc` no es lento, es ciego** (§9.1): de 200 procesos de vida mínima, un sondeo
34 cada 100 ms vio **cero**; el proc connector, los 200. Y **el «copiar en O(1)» existe pero no en
35 ext4**: en xfs/btrfs un reflink de 512 MiB tarda 0,020 ms contra 512,7 ms de `read+write`
36 (§9.2).
37
38## 1. Método y condiciones — leer antes de citar un número
39
40- **Máquina**: `momento`, KVM sobre AMD EPYC-Rome, 4 vCPU, 7,7 GiB RAM, Linux 7.1.4,
41 glibc/Artix. Store en `/dev/sdb` (ext4), árbol en `/dev/sdc` (ext4), `/tmp` tmpfs.
42- **Mitigaciones activas**: retpolines (`spectre_v2`), retbleed, SSBD por prctl. **Sin PTI**
43 (AMD, `meltdown: Not affected`). En un Intel con PTI el piso de syscall sería peor.
44- **La máquina estaba CARGADA**: load 7,3 sobre 4 vCPU, un build de LLVM en vuelo, 773 MiB
45 libres. ⇒ **los absolutos son cotas superiores; la señal está en los cocientes dentro de
46 una misma corrida**, que es como está escrito todo lo de abajo.
47- Cada medida son 7–9 rondas; se reporta **mínimo** (mejor caso, el estimador robusto al
48 ruido del scheduler) y **mediana**. Las corridas marcadas «pinneado» fijan el proceso a un
49 cpu para sacar de en medio la cola del planificador.
50- **SEGUNDA CORRIDA, mismo día, con root** (§9): la máquina estaba mucho más suelta —load ~1,3
51 sobre 4 vCPU y 4,3 GiB libres— y por eso **los absolutos de las dos corridas NO se comparan
52 entre sí**. Cada cociente que se cita abajo sale de una sola corrida, y se dice de cuál.
53- Fuentes y salidas crudas: `docs/evidencia/tasas-kernel-2026-08-29/`.
54- **Piso de referencia**: una syscall nula (`getppid`) cuesta **78,7 ns**. El vDSO
55 (`clock_gettime`) cuesta 31,2 ns y por syscall 143,8 ns ⇒ **cruzar al kernel son ~110 ns**.
56 Todo lo de abajo se lee en «syscalls equivalentes».
57
58## 2. La lista
59
60### T1 · `fork()` copia el espacio de direcciones del padre
61
62| padre con … | `fork+_exit+wait` | `posix_spawn+wait` |
63|---|---|---|
64| 0 MB tocados | 109 µs | 231 µs |
65| 64 MB | 938 µs | 234 µs |
66| 256 MB | **3 949 µs** | **231 µs** |
67
68≈ **59 ns por página de 4 KiB del padre**, ida y vuelta. `posix_spawn` (que usa
69`CLONE_VFORK|CLONE_VM`) es **plano**: no mira el tamaño del padre. A 256 MB, **17× más
70barato — haciendo estrictamente más trabajo** (además ejecuta un binario).
71
72Es la tasa POSIX por excelencia: `fork` no se puede arreglar sin romper la semántica, y la
73salida está dentro del propio POSIX.
74
75### T2 · `exec` + enlazado dinámico
76
77| | µs/exec |
78|---|---|
79| musl ESTÁTICO | **77,4** |
80| glibc ESTÁTICO | 232,7 |
81| glibc DINÁMICO | 324,3 |
82
83`ld.so` cuesta **+92 µs** (+39%). Pero el salto grande es glibc→musl: **4,2× entre el peor y
84el mejor**, con el mismo `main(){return 0;}`. El corpus de hammer ya está del lado bueno
85(verificado: los binarios del store son `statically linked`); esto **cuantifica** lo que la
86de-Alpinización compró: un build que lanza 20 000 procesos se ahorra ~5 s sólo en arranque.
87
88### T3 · `/proc` es una API de TEXTO, y se paga como tal
89
90| | ns | = syscalls |
91|---|---|---|
92| `getrusage(RUSAGE_SELF)` | 238 | 3 |
93| `clock_gettime(PROCESS_CPUTIME)` | 214 | 3 |
94| `pread`+parse `/proc/self/stat` (fd ya abierto) | 1 271 | 16 |
95| `open`+`read`+parse `/proc/self/stat` | **2 692** | **34** |
96| `open`+`read`+parse `/proc/self/status` (VmRSS) | **4 643** | **59** |
97
98Leer el CPU propio por `/proc` cuesta **11× más** que `getrusage`. Y la mitad del coste es
99abrir la ruta, no el texto (1 271 vs 2 692 con el fd cacheado).
100
101**Barrido completo** (291 procesos vivos): `readdir` solo **106 µs**; `readdir` + `stat` de
102cada pid **991 µs**, o sea **3,4 µs por proceso**.
103
104### T4 · Contabilidad por unidad: cgroup v2 es O(1) donde `/proc` es O(procesos)
105
106Leer `cpu.stat` + `memory.current` + `pids.current` de un cgroup: **4,6 µs**, y **no depende
107de cuántos procesos tenga la unidad**. Sumar `/proc` para esa misma unidad: 3,4 µs × N.
108**El cruce está en N = 2.** Para una unidad de 20 procesos, 15× a favor del cgroup.
109
110### T5 · Liveness por `/proc/<pid>`: la carrera, no la lentitud
111
112| | ns |
113|---|---|
114| `access("/proc/<pid>")` — lo que se usa hoy | 345 |
115| `kill(pid, 0)` | 119 |
116| `pidfd_send_signal(fd, 0)` | 119 |
117
118`pidfd` **no es más rápido que `kill(0)`**; es 2,9× más rápido que la ruta `/proc`. Su valor
119real es otro: **no hay reuso de PID**, y el fd se vuelve legible al morir el proceso ⇒ entra
120en el `epoll` y el supervisor deja de sondear.
121
122### T6 · Señales: la notificación más cara de POSIX
123
124`kill(self,SIGUSR1)` + handler = **1 165 ns = 15 syscalls**. Un `eventfd` en el bucle de
125eventos hace el mismo trabajo por una fracción.
126
127### T7 · Despertar cuesta µs; sincronizar en usuario cuesta ns
128
129| | ns |
130|---|---|
131| `pthread_mutex` lock+unlock sin disputa | **8,6** |
132| RTT `eventfd` (2 hilos, mismo cpu) | 2 507 |
133| RTT `pipe` | 2 956 |
134| RTT `socketpair` AF_UNIX | 3 639 |
135
136**291× entre sincronizar en espacio de usuario y despertar por el kernel.** El bus de arje
137(postcard sobre socket Unix) paga ~3,6 µs por ida y vuelta: aceptable por mensaje, ruinoso
138por campo.
139
140Adyacente: `sched_yield` 174 ns; `nanosleep(1 ms)` se pasa **65 µs** bajo carga ⇒ los lazos
141de control no se construyen sobre `sleep`.
142
143### T8 · `stat()` es tasa del VFS, no del filesystem
144
145| ns/fichero | ext4 | tmpfs |
146|---|---|---|
147| `stat()` por ruta completa | 648 | 714 |
148| `fstatat(dirfd, nombre)` | 439 | 489 |
149| `statx(…, DONT_SYNC)` | 467 | 593 |
150| `openat`+`close` | 899 | 961 |
151| **`getdents64` (barrido, sin stat)** | **168** | **59** |
152
153**ext4 ≈ tmpfs en `stat`.** ⇒ una tormenta de `stat` **no se arregla cambiando de
154filesystem**; se arregla no haciéndola: `getdents64` da el barrido **3,9× más barato en ext4
155y 12× en tmpfs**, y `fstatat` relativo a un `dirfd` ahorra 32% sobre la ruta completa.
156
157**Profundidad de ruta**: 12 componentes extra cuestan +205 ns en ext4 y +203 en tmpfs ⇒
158**~17 ns por componente**, idéntico en ambos. Otra vez: el VFS, no el disco.
159
160### T9 · `openat2` con `RESOLVE_BENEATH` es casi gratis
161
162ext4: 1 025 vs 899 ns (+14%). tmpfs: 763 vs 961 ns (midió *más rápido*, o sea dentro del
163ruido). ⇒ **la resolución hermética de rutas no tiene precio apreciable.** Es material
164directo para harkaq (SDD 16): la garantía «no se sale del árbol» sin demonio auxiliar y sin
165una syscall extra.
166
167### T10 · Materializar un árbol: hardlink vs copia
168
169| 2 000 ficheros de 1 byte | ext4 | tmpfs |
170|---|---|---|
171| `link()` | 9,2 µs | 2,9 µs |
172| `open+read+write+close` | 142,2 µs | 5,0 µs |
173| **cociente** | **15,4×** | 1,7× |
174
175Lo que hace cara la copia es el **journal de ext4**, no el VFS. La estrategia de hardlinks de
176`.dmerge` está bien elegida — y su contrapartida ya está documentada (la caché retiene lo que
177se borra del store).
178
179### T11 · `copy_file_range`/`sendfile` no ganan sobre ext4
180
18164 MiB en caché: `read+write` 2 837 MB/s · `sendfile` 3 345 · `copy_file_range` 3 521. ~20%
182en el mejor caso. **ext4 no tiene reflink**, así que `copy_file_range` cae a una copia dentro
183del kernel. El «copiar en O(1)» que la gente espera **exige un FS con reflink** (xfs/btrfs) —
184sin medir acá, §9.
185
186### T12 · io_uring no es una ganancia universal
187
188| | ns/op |
189|---|---|
190| syscall nula | 78,7 |
191| **io_uring NOP en lote (piso de operación)** | **47,7** |
192| `pread` 4K en caché (ext4) | 540 |
193| io_uring read 4K profundidad 32 (ext4) | 594 |
194| `pread` 4K (tmpfs) | 602 |
195| io_uring read 4K (tmpfs) | **1 672** |
196
197El piso por operación es **0,61× una syscall**: en lote, io_uring amortiza el cruce. Pero
198sobre datos ya cacheados **pierde** — 10% en ext4 y **2,8× en tmpfs**. io_uring paga cuando
199las operaciones son muchas, agrupables y **de verdad bloquean**. Sobre caché es una
200pesimización.
201
202### T13 · THP puede ser 3× PEOR (y acá lo es)
203
20464 MiB anónimos, ns por página de 4 KiB: `MADV_HUGEPAGE` **3 046** · `MADV_NOHUGEPAGE`
205**1 013** · `MAP_POPULATE` **752**.
206
207La causa está en la máquina, no en el kernel:
208`/sys/kernel/mm/transparent_hugepage/defrag = madvise` ⇒ una región con `MADV_HUGEPAGE`
209dispara **compactación síncrona** en el fallo de página, y con 773 MiB libres eso es caro.
210**THP bajo presión de memoria es un pasivo de latencia, no una optimización.** Reproducido en
211las dos corridas independientes (128 MiB y 64 MiB).
212
213`mmap`+`munmap` de 4K: 1 664 ns.
214
215### T14 · El sandbox por namespaces cuesta ~1 ms por proceso
216
217`fork+exec` base 1 649 µs · con `unshare` de 6 namespaces 2 742 µs · con `USER|MNT` 2 050 µs.
218⇒ **+1,09 ms** por el juego completo, **+0,40 ms** por USER|MNT.
219
220Para hammer es ruido por fase de build (segundos a minutos). Sería caro **sólo si se
221enjaulara por-exec**: 20 000 execs × 1,09 ms = 22 s.
222
223### T15 · Cerrar descriptores antes del `exec`
224
225`dup+close` uno a uno: 0,2 µs/fd ⇒ 512 fds = 102 µs. `close_range(lo,hi)`: por debajo del
226ruido. Gratis, y ya está en mainline.
227
228### T16 · Hilo vs proceso
229
230`pthread_create`+`join` = **11,9 µs**, contra 109 µs de `fork+_exit` con el padre vacío:
231**9×**, y la distancia crece con el tamaño del padre (T1).
232
233## 3. Las cinco sorpresas
234
235Las anoto aparte porque contradicen el consejo de manual:
236
2371. **THP más lento que 4K** (T13) — el manual dice que THP acelera; con `defrag=madvise` y
238 memoria apretada, triplica el coste del fallo.
2392. **io_uring más lento que `pread`** sobre tmpfs (T12) — el manual dice que io_uring es el
240 futuro; sobre datos cacheados el futuro pierde 2,8×.
2413. **`stat` cuesta lo mismo en tmpfs que en ext4** (T8) — invalida la reacción natural
242 («movamos el árbol a tmpfs y vuela»): el coste está en el VFS.
2434. **`taskstats` binario NO es más rápido que parsear `/proc`** (§9.1) — 2 688 vs 2 697 ns por
244 consulta. La intuición «binario le gana a texto» falla porque el ida y vuelta de netlink cuesta
245 lo mismo que `open`+`read`+parse. Su valor es el **contenido** (delay accounting, que `/proc` no
246 da) y la estabilidad bajo carga, no la velocidad.
2475. **Sondear `/proc` no es lento: es CIEGO** (§9.1) — de 200 procesos de vida mínima lanzados uno
248 cada 10 ms, un sondeo cada 100 ms vio **cero**. Toda la discusión de «cuánto cuesta el barrido»
249 (T3) presupone que el barrido VE lo que hay, y para procesos cortos no lo ve.
250
251## 4. El hallazgo cruzado: los kernels de hammer no tienen `MEMCG`
252
253No lo buscaba; salió de verificar la recomendación antes de escribirla.
254
255**Lado hammer.** Las recetas hacen `make ARCH=x86_64 defconfig` y después
256`scripts/config`. Lo que `arch/x86/configs/x86_64_defconfig` trae de contabilidad:
257`CONFIG_TASKSTATS=y`, `TASK_DELAY_ACCT=y`, `CGROUPS=y`, `CGROUP_SCHED=y`, `CGROUP_PIDS=y`,
258`CGROUP_FREEZER=y`, `CONNECTOR=y` ⇒ y `PROC_EVENTS` es `default y` con `depends on
259CONNECTOR=y`, así que **también entra**.
260
261**`CONFIG_MEMCG` no está en el defconfig y no tiene `default y` ⇒ queda en `n` en todos los
262kernels de hammer.** `CONFIG_PSI`, igual.
263
264**Lado tawasuyu.** `sandokan-core::Engine` expone `set_memory_max` / `set_memory_high`
265(`shared/sandokan/sandokan-core/src/engine.rs:60-72`), `sandokan-local` los delega a
266`arje_incarnate::cgroup` (`03_ukupacha/sandokan/sandokan-local/src/lib.rs:675-681`), y ahí:
267
268```rust
269// arje/init/arje-incarnate/src/cgroup.rs:60
270let _ = std::fs::write(abs.join("cpu.weight"), format!("{w}\n")); // error DESCARTADO
271// :79
272match std::fs::write(&path, format!("{mem}\n")) {
273 Err(e) => tracing::warn!(..., "memory.max write failed"), // sólo un warn
274```
275
276**Consecuencia.** Sobre un sistema hammer, una Card que pide un tope de memoria arranca
277**sin tope**, y la única huella es una línea de log. Es exactamente la forma de fallo que
278`CLAUDE.md` §3 nombra: *un ausente falla ruidosamente; un vacío llega hasta el final diciendo
279que todo fue bien*. La jaula que no está se comporta igual que la jaula que sí está, hasta
280que hace falta.
281
282### 4.bis · Confirmado sobre los artefactos, no deducido del defconfig
283
284Lo de arriba se dedujo leyendo `x86_64_defconfig`. Se comprobó después contra los **`.config` que
285las recetas instalan** en `boot/config-<versión>`, que es la prueba dura porque incluye lo que hizo
286`olddefconfig`. Los **11 kernels sellados del store** —cinco nombres de artefacto: `linux`,
287`linux-generic`, `linux-metal`, `linux-metal-dual`, `linux-gioser`— traen literalmente:
288
289```
290# CONFIG_MEMCG is not set
291# CONFIG_PSI is not set
292```
293
294y ninguno tiene `CFS_BANDWIDTH` (⇒ `cpu.max`, la cuota dura de CPU, tampoco existe; hoy nadie la
295pide, pero cuando `pacha` la quiera será otro re-hasheo). Lo que **sí** está en los once:
296`CGROUP_SCHED`+`FAIR_GROUP_SCHED` (`cpu.weight`), `CGROUP_PIDS` (`pids.max`), `CPUSETS`
297(`cpuset.cpus`), `BLK_CGROUP`+`BLK_CGROUP_IOCOST` (`io.weight`, con la sintaxis «default N» que
298arje ya usa), `TASKSTATS`+`CONNECTOR`+`PROC_EVENTS`, `SECCOMP_FILTER`, `SECURITY_LANDLOCK`,
299`FANOTIFY`, `OVERLAY_FS` con metacopy e `IO_URING`.
300
301**Y por qué el bug sobrevivió meses**: el kernel de la máquina de desarrollo (Artix 7.1.4) **sí**
302trae `MEMCG`, `PSI` y `CFS_BANDWIDTH`. El fallo sólo existe del lado del artefacto sellado, que es
303el lado que nadie mira con un depurador.
304
305### 4.ter · El guardián: `hammer kernel contract`
306
307Un hallazgo escrito en un documento no impide que vuelva a pasar. El contrato vive en
308`docs/state/kernel-contract.toml` y declara, por capacidad: los símbolos que la construyen, la
309interfaz que aparece o no aparece (`memory.max`, `pids.max`, `landlock_restrict_self`…), **el
310consumidor con ruta y símbolo**, y **cómo falla hoy si no está**. Se comprueba contra un `.config`
311ya producido:
312
313```sh
314hammer --store ./store kernel contract --sealed # barre los kernels sellados
315hammer kernel contract --profile anfitrion-cards # el kernel vivo de esta máquina
316hammer kernel contract --list # qué promete hammer (esto es el W6)
317```
318
319Tres decisiones que valen la pena nombrar:
320
3211. **Por perfil, no global** — misma lección que el gate de hardware. `linux.toml` es el kernel de
322 QEMU del selfhost-verify: no hospeda Cards y su hash es load-bearing del baseline `of_tree`.
323 Exigirle contabilidad de memoria sería rechazar una receta sana, así que ahí los límites por
324 unidad son `wants` y no `requires`.
3252. **Contra el `.config`, no contra la receta** — entre el `scripts/config -e X` y el `.config`
326 está `olddefconfig`, que puede tragarse el símbolo sin decir nada (es justo lo que
327 `hammer kernel diff-back` ya vigila para los planes).
3283. **Distingue apagado de ausente** — un símbolo que el `.config` ni nombra puede ser un
329 renombrado entre versiones. Cuenta igual como capacidad ausente, pero se informa aparte; y un
330 kernel sin perfil declarado queda **SIN COMPROBAR** en vez de contar como aprobado.
3314. **Distingue vigente de superado** (agregado al pagar H1) — el store guarda **todos** los
332 sellados, no el último. Mirándolos a todos por igual, arreglar la receta dejaba el gate rojo
333 para siempre por artefactos que nadie va a volver a construir, y **un portón que no puede
334 ponerse verde deja de leerse**. Ahora cada sellado se compara con el `ArtifactHash` que la
335 receta de hoy produce —la misma vigencia de `hammer hash --check`, lab incluido—: el vigente
336 bloquea; el superado sale en su propia sección con lo que le falta, porque sigue siendo cierto
337 que una máquina que arranque ese kernel corre sus Cards con lo que ese kernel traiga. Y dos
338 negativas explícitas: si la vigencia no se puede resolver se comprueba **todo** y se dice por
339 qué, y **cero vigentes con superados a la vista sale ≠0**, no verde — el vacío leído como
340 presencia (CLAUDE.md §3) es justo el fallo que este guardián vino a arreglar.
341
342Estado medido hoy: **2 de 11 configs sellados cumplen su perfil** (los dos `linux`), los 9 de
343perfil `anfitrion-cards` fallan por `MEMCG`. El kernel vivo de esta máquina pasa las 11 exigidas.
344
345Lo que **sí** funciona en un kernel hammer: `cpu.weight` (CGROUP_SCHED), `pids.max`
346(CGROUP_PIDS), `cpu.stat` (rstat del core) y `cgroup.freeze` — este último porque el freezer
347v2 es `obj-y` en `kernel/cgroup/Makefile`, no el `CONFIG_CGROUP_FREEZER` de v1.
348
349## 5. Qué se esquiva SIN escribir kernel
350
351| Tasa | Reemplazo mainline | Ganancia medida |
352|---|---|---|
353| T1 `fork` de padre grande | `posix_spawn` / `CLONE_VFORK` | 17× a 256 MB, y plano |
354| T2 `ld.so` | binario estático musl | 4,2× |
355| T3 `/proc` texto | `getrusage`, `taskstats` | 11× |
356| T4 sumar `/proc` por unidad | agregado de cgroup v2 | O(1) vs O(N), cruce en N=2 |
357| T5 liveness por `/proc` | `pidfd` (+ sin carrera de PID) | 2,9× y correctitud |
358| T3 enumerar procesos | proc connector (netlink): eventos push | **0 vs 200 procesos vistos** (§9.1) |
359| T6 señales | `eventfd`/`signalfd`/`pidfd` | 15 syscalls → 1 |
360| T8 tormenta de `stat` | `getdents64`, `fstatat(dirfd,…)` | 3,9×–12× |
361| T9 hermetismo de rutas | `openat2 RESOLVE_BENEATH` | ~gratis |
362| T10 materializar árbol | `link()` | 15,4× en ext4 |
363| T11 copiar un fichero entero | `FICLONE`/reflink **en xfs o btrfs** | **25 600× a 512 MiB** (§9.2) |
364| T15 cerrar fds | `close_range` | 102 µs → ~0 |
365
366**Ninguna de estas exige un módulo, un parche ni un kernel propio.** Es la primera conclusión
367del documento y la que ordena todo lo demás.
368
369## 6. Qué exigiría tocar el kernel — y su precio en hammer
370
371Después de agotar §5 quedan tres deseos legítimos que mainline no da:
372
3731. **Una syscall que devuelva un lote de estadísticas por proceso en binario.** Lo más
374 cercano es `taskstats` (por tarea, netlink) y el agregado de cgroup. No existe el «dame
375 los 291 en una llamada».
3762. **Eventos de estado con más contexto que el proc connector.**
3773. **Un plano de control de unidades que no pase por el filesystem.**
378
379Su precio, concreto:
380
381- **Es built-in, no `.ko`** (`-d MODULES`). ⇒ parche a la receta ⇒ entra en `hash_inputs`
382 ⇒ kernel nuevo, artefacto nuevo, y rebase manual en cada versión.
383- **Nunca un número de syscall nuevo**: choca con upstream para siempre. Lo compatible es un
384 char device, un fichero propio en sysfs, o una kfunc BPF.
385- **Con eBPF (que es la forma sin código out-of-tree) el precio es `DEBUG_INFO_BTF`**, hoy
386 apagado en las cuatro recetas con motivo escrito (`linux.toml:32`: *«pahole (BTF), ausente
387 del toolchain»*). Deuda ya medio pagada: dwarves construye (frente libdw/musl cerrado).
388- **El bloqueo de diseño, que no es técnico**: `SDD-SERVIDOR-AJENO.md` promete *«cero
389 invasión — la máquina sigue siendo su Ubuntu de siempre»*. Un sandokan que dependa de un
390 kernel hammer deja de correr ahí. ⇒ **el núcleo se queda portable siempre; lo rápido es un
391 backend opcional detectado en runtime, con `/proc` como respaldo que no se borra.**
392
393## 7. Propuestas — lado hammer
394
395- **H1 · `-e MEMCG -e PSI` en las cuatro recetas de kernel.** Cierra §4 y habilita
396 `memory.current` (T4) y las señales de presión. **Precio declarado**: re-hashea todos los
397 artefactos de kernel ⇒ hacerlo en la misma tanda que cualquier otro cambio de kernel, nunca
398 suelto. **Sigue PENDIENTE a propósito** — pero ya no puede olvidarse: desde §4.ter
399 `hammer kernel contract --sealed` sale distinto de cero mientras falte. Cuando se pague, el
400 mismo comando es la prueba de que se pagó. Nota de alcance: en `linux.toml` (perfil
401 `qemu-serial`) NO corre — su hash es el baseline del selfhost-verify y ahí no hay Cards.
402- **H2 · `MODULES=n` se queda.** Si algún día hace falta código de kernel, es built-in y
403 parcheado en la receta. Queda escrito para no rediscutirlo.
404- **H3 · `harkaq` usa `openat2 RESOLVE_BENEATH|RESOLVE_NO_SYMLINKS`** donde hoy valida rutas
405 a mano (T9, ~gratis).
406- **H4 · Auditar los `Command` de hammer que ponen `pre_exec`**: Rust apaga el camino
407 `posix_spawn` en cuanto hay `pre_exec`, y cae a `fork+exec` (T1). `hammer-build/sandbox.rs`
408 lanza `bwrap` sin `pre_exec` ⇒ está bien hoy; el punto es que sea una regla, no una suerte.
409- **H5 · La materialización de dependencias se queda en hardlink** (T10) y el experimento de
410 reflink (§9) alimenta SDD 18 (wawafs), donde el store como FS vivo cambiaría el cociente.
411- **H6 · No `madvise(MADV_HUGEPAGE)` en el camino de build** mientras `defrag=madvise` y la
412 granja corra apretada de RAM (T13).
413- **H7 · `DEBUG_INFO_BTF` sigue apagado** hasta que haya un consumidor real de BPF; cuando lo
414 haya, la deuda es pahole en el lab y un re-hasheo, no un rediseño.
415- **H8 · El contrato de capacidades es parte del release del kernel** (§4.ter). Toda receta de
416 kernel nueva declara su `[[target]]` con perfil; un kernel sin perfil sale **SIN COMPROBAR**, no
417 aprobado. Y toda capacidad nueva entra con consumidor —ruta y símbolo— o no entra: «estaría bueno
418 tener» no es un contrato.
419- **H9 · El experimento de reflink cambia el cálculo de SDD 18, no el de hoy.** Con `.dmerge` sobre
420 ext4 el hardlink sigue siendo la elección correcta (T10). Lo que §9.2 agrega es que un store
421 sobre **xfs o btrfs** compraría semántica de copia —mutación privada, sin la contrapartida de
422 retener lo borrado— al mismo precio. Es un argumento para wawafs, medido; no un cambio de
423 filesystem decidido acá.
424
425## 8. Propuestas — lado tawasuyu
426
427Detalle en el handoff espejo. En una línea cada una:
428
429- **W1 · `pidfd` para liveness y señales** en `sandokan-local` (hoy `access("/proc/<pid>")` y
430 `waitpid(WNOHANG)` en sondeo). Se gana correctitud (carrera de PID) antes que velocidad.
431- **W2 · Telemetría por unidad desde el cgroup**, no sumando `/proc` (T4). Con **sonda de
432 capacidad**: si `memory.current` no existe (kernel sin MEMCG), decirlo, no callarlo. Del lado
433 hammer esa sonda ya tiene con qué contrastarse: `hammer kernel contract --list --json` publica
434 qué promete cada perfil de kernel (§4.ter), que es el W6 en formato de dato.
435- **W3 · `arje-incarnate` con `posix_spawn`** donde el `pre_exec` no sea imprescindible (T1),
436 y `close_range` en el que quede.
437- **W4 · Los errores de cgroup dejan de descartarse.** `let _ = write(...)` y `warn!` sobre un
438 límite no aplicado son el bug de §4, no un detalle de estilo.
439- **W5 · `/proc` no se borra nunca**: es el respaldo portable que hace posible
440 SDD-SERVIDOR-AJENO.
441- **W6 · El contrato entre repos es el trait `Engine`, no una ABI de kernel.** hammer publica
442 la lista de símbolos garantizados (verificable con `hammer kernel probe`); tawasuyu elige
443 backend en runtime.
444
445## 9. Los experimentos nombrados — cuatro medidos, dos abiertos
446
447La primera corrida los dejó nombrados y sin medir, todos por privilegios o por herramienta
448ausente. **Cuatro cayeron el mismo día, con root.** Condiciones distintas a la corrida de arriba
449(load ~1,3 sobre 4 vCPU, 4,3 GiB libres) ⇒ los números de acá **no se cruzan** con los de §2.
450
451### 9.1 · proc connector y `taskstats` — MEDIDO (`bench_connector.c`, `connector.txt`)
452
453**a) Latencia.** Esperar el evento `PROC_EVENT_EXIT` del connector **no cuesta más** que el
454`waitpid` que el padre ya hace: 116,0 µs contra 114,8 µs por `fork+_exit` (mínimo de 7 rondas de
455200), es decir dentro del ruido, con **0 eventos perdidos de 1 400**. Con una advertencia dura que
456salió del propio experimento: el multicast del connector **no reintenta**. Si el buffer del socket
457se llena, el evento se pierde y quien lo espera se cuelga para siempre — la primera versión del
458programa se colgó exactamente así. Un supervisor sobre connector necesita `SO_RCVBUF` grande **y
459un plazo**, no una espera infinita.
460
461**b) Cobertura — el resultado que importa.** 200 procesos de vida mínima, uno cada 10 ms:
462
463| observador | vistos | CPU |
464|---|---|---|
465| sondeo de `/proc` cada 100 ms (23 barridos) | **0 de 200** | 10,5 ms |
466| proc connector (eventos) | **200 de 200** | 95,4 ms |
467
468El sondeo no es caro: es **ciego**. Y la CPU del connector de esa tabla es cota superior — el
469programa lo lee con espera activa de 200 µs, que no es el modo de un supervisor real (sería
470`epoll`, donde el coste es despertar por evento).
471
472**c) `taskstats`.** La familia genetlink existe y responde. Por consulta: **2 688 ns** contra
473**2 697 ns** de `open`+`read`+parse de `/proc/self/stat`. **No gana en velocidad** (sorpresa §3.4).
474Lo que da y `/proc` no: contabilidad de retrasos (delay accounting) y una estructura binaria
475estable en vez de un texto que hay que parsear posicionalmente.
476
477### 9.2 · Copia con reflink — MEDIDO (`bench_reflink.c`, `reflink.txt`)
478
479`mkfs` sobre un loop, xfs con `reflink=1` y btrfs por defecto. Copiar un fichero **entero**:
480
481| | ext4 | xfs (loop) | btrfs (loop) |
482|---|---|---|---|
483| `FICLONE` (reflink) | **no soportado** | 0,010 ms | 0,006 ms |
484| `copy_file_range` | 13,66 ms | 0,010 ms | 0,006 ms |
485| `read+write` (1 MiB) | 14,46 ms | 10,96 ms | 31,32 ms |
486
487Y la prueba de que es O(1) y no «rápido»: **con el fichero ocho veces más grande (512 MiB), el
488reflink pasa de 0,010 a 0,020 ms** —el doble, no ocho veces— mientras `read+write` pasa de 10,96 a
489512,7 ms. **25 600× a 512 MiB en xfs.** En btrfs, 0,006 → 0,015 ms.
490
491Confirma T11 por el lado contrario: en ext4 `copy_file_range` no gana nada (13,66 contra 14,46 ms,
492un 6%) **porque ext4 no tiene reflink**; el «copiar en O(1)» que la gente espera de esa syscall es
493una propiedad del filesystem, no de la syscall. Material directo para SDD 18 (wawafs): con reflink
494se obtiene semántica de **copia** al precio del hardlink, y sin la contrapartida del hardlink —que
495`.dmerge` ya paga: retener en la caché lo que se borró del store.
496
497### 9.3 · io_uring con `SQPOLL` — MEDIDO (`bench_sqpoll.c`, `sqpoll.txt`)
498
4994K aleatorios en caché, profundidad 32, fd registrado:
500
501| ns/op | ext4 | tmpfs |
502|---|---|---|
503| `pread` | **554** | **749** |
504| io_uring sin SQPOLL | 618 | 2 030 |
505| io_uring con SQPOLL | 868 | 1 409 |
506
507**No cambia el veredicto de T12.** Sobre datos cacheados `SQPOLL` sigue perdiendo contra la
508syscall (1,6× peor en ext4), y aunque en tmpfs mejora al io_uring normal (1,4×), sigue estando
5091,9× por detrás de `pread`. Encima **se come un core** con el hilo del kernel, que en una máquina
510de 4 vCPU es el 25% de la granja. `SQPOLL` es para operaciones que **de verdad bloquean**, no para
511amortizar cruces sobre caché.
512
513### 9.4 · Coste en syscalls de un compilado — MEDIDO (`syscalls-compilador.txt`)
514
515La corrida original decía «sin `strace`/`perf` en esta máquina no se puede atribuir». **El
516`strace` estaba en el propio corpus** (`store/3c1fd8c0…-strace`, 6.19): la distro se paga a sí
517misma la herramienta de diagnóstico. Un `-c` de un `main()` trivial con stdio/stdlib/string:
518
519| | syscalls | con error | el grueso |
520|---|---|---|---|
521| `cc -O2 -c` (gcc 16) | 1 803 | **1 095 (61%)** | **919 de 923 `readlink` FALLAN** |
522| `zig cc -O2 -c` (zig 0.16) | 3 608 | 214 (6%) | 1 104 `futex` (70% del tiempo en syscall) |
523
524Dos lecturas:
525
526- **La tormenta de rutas de T8 existe y es de `readlink`, no de `stat`** — canonicalización de
527 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
528 de compilado: el 0,5%.** La respuesta a la pregunta original («¿vale segundos o minutos por
529 receta?») es **milisegundos por invocación**: segundos por millar de compilados. No es ahí donde
530 está el tiempo de una tanda.
531- **`zig cc` tiene otro perfil**: casi no falla rutas, pero coordina hilos. El tiempo que
532 `strace -c` le atribuye es espera **bloqueada**, no CPU — y por eso `strace -c` no sirve para
533 comparar compiladores, sólo para contar llamadas.
534
535### 9.5 · Lo que sigue abierto
536
5371. **Iterador BPF contra `/proc`** (T3): exige BTF y privilegios. Va detrás de H7 —
538 `DEBUG_INFO_BTF` está apagado en las cuatro recetas y su deuda (pahole) ya está medio pagada.
5392. **Un Intel con PTI**: todos los pisos de syscall de acá son de un AMD sin PTI. En el laptop
540 (TigerLake) las mismas medidas darían peor, y ahí T3/T6 duelen más.
541
542## 10. Evidencia
543
544`docs/evidencia/tasas-kernel-2026-08-29/` — **primera corrida**: cuatro programas en C
545(`bench_proc.c`, `bench_io.c`, `bench_extra.c`, `bench_pin.c`) y sus cinco salidas crudas
546(`proc.txt`, `io-ext4.txt`, `io-tmpfs.txt`, `extra.txt`, `pin.txt`). Se compilan con
547`cc -O2` (`bench_io` pide `-luring`, `bench_proc`/`bench_pin` piden `-lpthread`) y se corren
548sin argumentos salvo `bench_io <dir>` y `bench_pin <estatico> <dinamico> <musl>`.
549
550**Nota de unidad**: en `pin.txt` la sección de fallos de página imprime ns/página × 1000 por
551un factor de escala equivocado en el programa; los valores citados en T13 son los de esa
552salida divididos por 1000, y coinciden con `io-ext4.txt`, que sí está en la unidad correcta.
553
554**Segunda corrida (§9, con root)**: `bench_connector.c` → `connector.txt`; `bench_reflink.c` →
555`reflink.txt` (xfs y btrfs sobre loop, montados y desmontados por el propio guion);
556`bench_sqpoll.c` → `sqpoll.txt`; y `syscalls-compilador.txt`, hecho con el `strace` **del store**.
557Se compilan igual que los otros (`bench_sqpoll` pide `-luring`) y los tres primeros piden root:
558`bench_connector` por `CAP_NET_ADMIN`, `bench_reflink` por `mkfs`+`mount`, `bench_sqpoll` por
559`CAP_SYS_NICE`.
560
561**Contrato de capacidades**: `docs/state/kernel-contract.toml` (el dato) y
562`hammer kernel contract --list` (la vista). Los ficheros de esta carpeta no cambian; el contrato sí
563—cada vez que un consumidor nuevo usa el kernel por nombre.
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
construyen sin
CONFIG_MEMCG, y arje/sandokan escriben memory.max ignorando el
error. Un límite de memoria pedido por una Card no se aplica y nadie se entera. §4.
Confirmado después sobre los 11 .config sellados, no deducido del defconfig (§4.bis), y
ya vigilado por hammer kernel contract (§4.ter): 2 de 11 cumplen su perfil.
- 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).
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.
- 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.
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).
3. Las cinco 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.
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 medido hoy: 2 de 11 configs sellados cumplen su perfil (los dos linux), los 9 de
perfil anfitrion-cards fallan por MEMCG. 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 las cuatro recetas de kernel. Cierra §4 y habilita
memory.current (T4) y las señales de presión. Precio declarado: re-hashea todos los
artefactos de kernel ⇒ hacerlo en la misma tanda que cualquier otro cambio de kernel, nunca
suelto. Sigue PENDIENTE a propósito — pero ya no puede olvidarse: desde §4.ter
hammer kernel contract --sealed sale distinto de cero mientras falte. Cuando se pague, el
mismo comando es la prueba de que se pagó. Nota de alcance: en linux.toml (perfil
qemu-serial) NO corre — su hash es el baseline del selfhost-verify y ahí no hay Cards.
- 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 ·
harkaq usa openat2 RESOLVE_BENEATH|RESOLVE_NO_SYMLINKS donde hoy valida rutas
a mano (T9, ~gratis).
- H4 · Auditar los
Command de hammer que ponen pre_exec: Rust apaga el camino
posix_spawn en cuanto hay pre_exec, y cae a fork+exec (T1). hammer-build/sandbox.rs
lanza bwrap sin pre_exec ⇒ está bien hoy; el punto es que sea una regla, no una suerte.
- 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 hasta que haya un consumidor real de BPF; cuando lo
haya, la deuda es pahole en el lab y un re-hasheo, no un rediseño.
- 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á.
8. Propuestas — lado tawasuyu
Detalle en el handoff espejo. En una línea cada una:
- 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 — cuatro medidos, dos abiertos
La primera corrida los dejó nombrados y sin medir, todos por privilegios o por herramienta
ausente. Cuatro cayeron el mismo día, con root. 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 · Lo que sigue abierto
- Iterador BPF contra
/proc (T3): exige BTF y privilegios. Va detrás de H7 —
DEBUG_INFO_BTF está apagado en las cuatro recetas y su deuda (pahole) ya está medio pagada.
- Un Intel con PTI: todos los pisos de syscall de acá son de un AMD sin PTI. En el laptop
(TigerLake) las mismas medidas darían peor, y ahí T3/T6 duelen más.
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.