tawasuyu

sergio/takana

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

TOML 604Rust 56Shell 36Markdown 281227 Definitions

⎇ 09168bf1e36909c19ae54d161654a75e83106fd8 docs/25-tasas-del-kernel.md Markdown · 718 lines · 42.5 KB History raw

SDD 25 §9.5: el iterador BPF, medido — y H7 pasa a tener precio 09168bf1e · Sergio ·

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. **Cuando `/proc` sí se sondea entero, hay algo cerca de 7× más barato** (§9.5, medido
40   2026-08-31): un iterador BPF `task` saca la misma foto **al precio de listar el directorio**
41   —1,75× el piso de `readdir` contra 12,1× de `/proc`—. Su portón es `DEBUG_INFO_BTF`, que sigue
42   apagado, y H7 ahora dice **cuánto cuesta encenderlo** en vez de sólo que está apagado.
43
44## 1. Método y condiciones — leer antes de citar un número
45
46- **Máquina**: `momento`, KVM sobre AMD EPYC-Rome, 4 vCPU, 7,7 GiB RAM, Linux 7.1.4,
47  glibc/Artix. Store en `/dev/sdb` (ext4), árbol en `/dev/sdc` (ext4), `/tmp` tmpfs.
48- **Mitigaciones activas**: retpolines (`spectre_v2`), retbleed, SSBD por prctl. **Sin PTI**
49  (AMD, `meltdown: Not affected`). En un Intel con PTI el piso de syscall sería peor.
50- **La máquina estaba CARGADA**: load 7,3 sobre 4 vCPU, un build de LLVM en vuelo, 773 MiB
51  libres. ⇒ **los absolutos son cotas superiores; la señal está en los cocientes dentro de
52  una misma corrida**, que es como está escrito todo lo de abajo.
53- Cada medida son 7–9 rondas; se reporta **mínimo** (mejor caso, el estimador robusto al
54  ruido del scheduler) y **mediana**. Las corridas marcadas «pinneado» fijan el proceso a un
55  cpu para sacar de en medio la cola del planificador.
56- **SEGUNDA CORRIDA, mismo día, con root** (§9): la máquina estaba mucho más suelta —load ~1,3
57  sobre 4 vCPU y 4,3 GiB libres— y por eso **los absolutos de las dos corridas NO se comparan
58  entre sí**. Cada cociente que se cita abajo sale de una sola corrida, y se dice de cuál.
59- Fuentes y salidas crudas: `docs/evidencia/tasas-kernel-2026-08-29/`.
60- **Piso de referencia**: una syscall nula (`getppid`) cuesta **78,7 ns**. El vDSO
61  (`clock_gettime`) cuesta 31,2 ns y por syscall 143,8 ns ⇒ **cruzar al kernel son ~110 ns**.
62  Todo lo de abajo se lee en «syscalls equivalentes».
63
64## 2. La lista
65
66### T1 · `fork()` copia el espacio de direcciones del padre
67
68| padre con … | `fork+_exit+wait` | `posix_spawn+wait` |
69|---|---|---|
70| 0 MB tocados | 109 µs | 231 µs |
71| 64 MB | 938 µs | 234 µs |
72| 256 MB | **3 949 µs** | **231 µs** |
73
74≈ **59 ns por página de 4 KiB del padre**, ida y vuelta. `posix_spawn` (que usa
75`CLONE_VFORK|CLONE_VM`) es **plano**: no mira el tamaño del padre. A 256 MB, **17× más
76barato — haciendo estrictamente más trabajo** (además ejecuta un binario).
77
78Es la tasa POSIX por excelencia: `fork` no se puede arreglar sin romper la semántica, y la
79salida está dentro del propio POSIX.
80
81### T2 · `exec` + enlazado dinámico
82
83| | µs/exec |
84|---|---|
85| musl ESTÁTICO | **77,4** |
86| glibc ESTÁTICO | 232,7 |
87| glibc DINÁMICO | 324,3 |
88
89`ld.so` cuesta **+92 µs** (+39%). Pero el salto grande es glibc→musl: **4,2× entre el peor y
90el mejor**, con el mismo `main(){return 0;}`. El corpus de hammer ya está del lado bueno
91(verificado: los binarios del store son `statically linked`); esto **cuantifica** lo que la
92de-Alpinización compró: un build que lanza 20 000 procesos se ahorra ~5 s sólo en arranque.
93
94### T3 · `/proc` es una API de TEXTO, y se paga como tal
95
96| | ns | = syscalls |
97|---|---|---|
98| `getrusage(RUSAGE_SELF)` | 238 | 3 |
99| `clock_gettime(PROCESS_CPUTIME)` | 214 | 3 |
100| `pread`+parse `/proc/self/stat` (fd ya abierto) | 1 271 | 16 |
101| `open`+`read`+parse `/proc/self/stat` | **2 692** | **34** |
102| `open`+`read`+parse `/proc/self/status` (VmRSS) | **4 643** | **59** |
103
104Leer el CPU propio por `/proc` cuesta **11× más** que `getrusage`. Y la mitad del coste es
105abrir la ruta, no el texto (1 271 vs 2 692 con el fd cacheado).
106
107**Barrido completo** (291 procesos vivos): `readdir` solo **106 µs**; `readdir` + `stat` de
108cada pid **991 µs**, o sea **3,4 µs por proceso**. Ese barrido tiene alternativa medida: un
109iterador BPF hace el mismo trabajo por **0,80 µs por proceso** contra 5,54 µs del mismo barrido
110medido en esa corrida (§9.5), pero exige BTF (H7).
111
112### T4 · Contabilidad por unidad: cgroup v2 es O(1) donde `/proc` es O(procesos)
113
114Leer `cpu.stat` + `memory.current` + `pids.current` de un cgroup: **4,6 µs**, y **no depende
115de cuántos procesos tenga la unidad**. Sumar `/proc` para esa misma unidad: 3,4 µs × N.
116**El cruce está en N = 2.** Para una unidad de 20 procesos, 15× a favor del cgroup.
117
118### T5 · Liveness por `/proc/<pid>`: la carrera, no la lentitud
119
120| | ns |
121|---|---|
122| `access("/proc/<pid>")` — lo que se usa hoy | 345 |
123| `kill(pid, 0)` | 119 |
124| `pidfd_send_signal(fd, 0)` | 119 |
125
126`pidfd` **no es más rápido que `kill(0)`**; es 2,9× más rápido que la ruta `/proc`. Su valor
127real es otro: **no hay reuso de PID**, y el fd se vuelve legible al morir el proceso ⇒ entra
128en el `epoll` y el supervisor deja de sondear.
129
130### T6 · Señales: la notificación más cara de POSIX
131
132`kill(self,SIGUSR1)` + handler = **1 165 ns = 15 syscalls**. Un `eventfd` en el bucle de
133eventos hace el mismo trabajo por una fracción.
134
135### T7 · Despertar cuesta µs; sincronizar en usuario cuesta ns
136
137| | ns |
138|---|---|
139| `pthread_mutex` lock+unlock sin disputa | **8,6** |
140| RTT `eventfd` (2 hilos, mismo cpu) | 2 507 |
141| RTT `pipe` | 2 956 |
142| RTT `socketpair` AF_UNIX | 3 639 |
143
144**291× entre sincronizar en espacio de usuario y despertar por el kernel.** El bus de arje
145(postcard sobre socket Unix) paga ~3,6 µs por ida y vuelta: aceptable por mensaje, ruinoso
146por campo.
147
148Adyacente: `sched_yield` 174 ns; `nanosleep(1 ms)` se pasa **65 µs** bajo carga ⇒ los lazos
149de control no se construyen sobre `sleep`.
150
151### T8 · `stat()` es tasa del VFS, no del filesystem
152
153| ns/fichero | ext4 | tmpfs |
154|---|---|---|
155| `stat()` por ruta completa | 648 | 714 |
156| `fstatat(dirfd, nombre)` | 439 | 489 |
157| `statx(…, DONT_SYNC)` | 467 | 593 |
158| `openat`+`close` | 899 | 961 |
159| **`getdents64` (barrido, sin stat)** | **168** | **59** |
160
161**ext4 ≈ tmpfs en `stat`.** ⇒ una tormenta de `stat` **no se arregla cambiando de
162filesystem**; se arregla no haciéndola: `getdents64` da el barrido **3,9× más barato en ext4
163y 12× en tmpfs**, y `fstatat` relativo a un `dirfd` ahorra 32% sobre la ruta completa.
164
165**Profundidad de ruta**: 12 componentes extra cuestan +205 ns en ext4 y +203 en tmpfs ⇒
166**~17 ns por componente**, idéntico en ambos. Otra vez: el VFS, no el disco.
167
168### T9 · `openat2` con `RESOLVE_BENEATH` es casi gratis
169
170ext4: 1 025 vs 899 ns (+14%). tmpfs: 763 vs 961 ns (midió *más rápido*, o sea dentro del
171ruido). ⇒ **la resolución hermética de rutas no tiene precio apreciable.** Es material
172directo para harkaq (SDD 16): la garantía «no se sale del árbol» sin demonio auxiliar y sin
173una syscall extra.
174
175### T10 · Materializar un árbol: hardlink vs copia
176
177| 2 000 ficheros de 1 byte | ext4 | tmpfs |
178|---|---|---|
179| `link()` | 9,2 µs | 2,9 µs |
180| `open+read+write+close` | 142,2 µs | 5,0 µs |
181| **cociente** | **15,4×** | 1,7× |
182
183Lo que hace cara la copia es el **journal de ext4**, no el VFS. La estrategia de hardlinks de
184`.dmerge` está bien elegida — y su contrapartida ya está documentada (la caché retiene lo que
185se borra del store).
186
187### T11 · `copy_file_range`/`sendfile` no ganan sobre ext4
188
18964 MiB en caché: `read+write` 2 837 MB/s · `sendfile` 3 345 · `copy_file_range` 3 521. ~20%
190en el mejor caso. **ext4 no tiene reflink**, así que `copy_file_range` cae a una copia dentro
191del kernel. El «copiar en O(1)» que la gente espera **exige un FS con reflink** (xfs/btrfs) —
192sin medir acá, §9.
193
194### T12 · io_uring no es una ganancia universal
195
196| | ns/op |
197|---|---|
198| syscall nula | 78,7 |
199| **io_uring NOP en lote (piso de operación)** | **47,7** |
200| `pread` 4K en caché (ext4) | 540 |
201| io_uring read 4K profundidad 32 (ext4) | 594 |
202| `pread` 4K (tmpfs) | 602 |
203| io_uring read 4K (tmpfs) | **1 672** |
204
205El piso por operación es **0,61× una syscall**: en lote, io_uring amortiza el cruce. Pero
206sobre datos ya cacheados **pierde** — 10% en ext4 y **2,8× en tmpfs**. io_uring paga cuando
207las operaciones son muchas, agrupables y **de verdad bloquean**. Sobre caché es una
208pesimización.
209
210### T13 · THP puede ser 3× PEOR (y acá lo es)
211
21264 MiB anónimos, ns por página de 4 KiB: `MADV_HUGEPAGE` **3 046** · `MADV_NOHUGEPAGE`
213**1 013** · `MAP_POPULATE` **752**.
214
215La causa está en la máquina, no en el kernel:
216`/sys/kernel/mm/transparent_hugepage/defrag = madvise` ⇒ una región con `MADV_HUGEPAGE`
217dispara **compactación síncrona** en el fallo de página, y con 773 MiB libres eso es caro.
218**THP bajo presión de memoria es un pasivo de latencia, no una optimización.** Reproducido en
219las dos corridas independientes (128 MiB y 64 MiB).
220
221`mmap`+`munmap` de 4K: 1 664 ns.
222
223### T14 · El sandbox por namespaces cuesta ~1 ms por proceso
224
225`fork+exec` base 1 649 µs · con `unshare` de 6 namespaces 2 742 µs · con `USER|MNT` 2 050 µs.
226⇒ **+1,09 ms** por el juego completo, **+0,40 ms** por USER|MNT.
227
228Para hammer es ruido por fase de build (segundos a minutos). Sería caro **sólo si se
229enjaulara por-exec**: 20 000 execs × 1,09 ms = 22 s.
230
231### T15 · Cerrar descriptores antes del `exec`
232
233`dup+close` uno a uno: 0,2 µs/fd ⇒ 512 fds = 102 µs. `close_range(lo,hi)`: por debajo del
234ruido. Gratis, y ya está en mainline.
235
236### T16 · Hilo vs proceso
237
238`pthread_create`+`join` = **11,9 µs**, contra 109 µs de `fork+_exit` con el padre vacío:
239**9×**, y la distancia crece con el tamaño del padre (T1).
240
241## 3. Las cinco sorpresas
242
243Las anoto aparte porque contradicen el consejo de manual:
244
2451. **THP más lento que 4K** (T13) — el manual dice que THP acelera; con `defrag=madvise` y
246   memoria apretada, triplica el coste del fallo.
2472. **io_uring más lento que `pread`** sobre tmpfs (T12) — el manual dice que io_uring es el
248   futuro; sobre datos cacheados el futuro pierde 2,8×.
2493. **`stat` cuesta lo mismo en tmpfs que en ext4** (T8) — invalida la reacción natural
250   («movamos el árbol a tmpfs y vuela»): el coste está en el VFS.
2514. **`taskstats` binario NO es más rápido que parsear `/proc`** (§9.1) — 2 688 vs 2 697 ns por
252   consulta. La intuición «binario le gana a texto» falla porque el ida y vuelta de netlink cuesta
253   lo mismo que `open`+`read`+parse. Su valor es el **contenido** (delay accounting, que `/proc` no
254   da) y la estabilidad bajo carga, no la velocidad.
2555. **Sondear `/proc` no es lento: es CIEGO** (§9.1) — de 200 procesos de vida mínima lanzados uno
256   cada 10 ms, un sondeo cada 100 ms vio **cero**. Toda la discusión de «cuánto cuesta el barrido»
257   (T3) presupone que el barrido VE lo que hay, y para procesos cortos no lo ve.
258
259## 4. El hallazgo cruzado: los kernels de hammer no tienen `MEMCG`
260
261No lo buscaba; salió de verificar la recomendación antes de escribirla.
262
263**Lado hammer.** Las recetas hacen `make ARCH=x86_64 defconfig` y después
264`scripts/config`. Lo que `arch/x86/configs/x86_64_defconfig` trae de contabilidad:
265`CONFIG_TASKSTATS=y`, `TASK_DELAY_ACCT=y`, `CGROUPS=y`, `CGROUP_SCHED=y`, `CGROUP_PIDS=y`,
266`CGROUP_FREEZER=y`, `CONNECTOR=y` ⇒ y `PROC_EVENTS` es `default y` con `depends on
267CONNECTOR=y`, así que **también entra**.
268
269**`CONFIG_MEMCG` no está en el defconfig y no tiene `default y` ⇒ queda en `n` en todos los
270kernels de hammer.** `CONFIG_PSI`, igual.
271
272**Lado tawasuyu.** `sandokan-core::Engine` expone `set_memory_max` / `set_memory_high`
273(`shared/sandokan/sandokan-core/src/engine.rs:60-72`), `sandokan-local` los delega a
274`arje_incarnate::cgroup` (`03_ukupacha/sandokan/sandokan-local/src/lib.rs:675-681`), y ahí:
275
276```rust
277// arje/init/arje-incarnate/src/cgroup.rs:60
278let _ = std::fs::write(abs.join("cpu.weight"), format!("{w}\n"));   // error DESCARTADO
279// :79
280match std::fs::write(&path, format!("{mem}\n")) {
281    Err(e) => tracing::warn!(..., "memory.max write failed"),        // sólo un warn
282```
283
284**Consecuencia.** Sobre un sistema hammer, una Card que pide un tope de memoria arranca
285**sin tope**, y la única huella es una línea de log. Es exactamente la forma de fallo que
286`CLAUDE.md` §3 nombra: *un ausente falla ruidosamente; un vacío llega hasta el final diciendo
287que todo fue bien*. La jaula que no está se comporta igual que la jaula que sí está, hasta
288que hace falta.
289
290### 4.bis · Confirmado sobre los artefactos, no deducido del defconfig
291
292Lo de arriba se dedujo leyendo `x86_64_defconfig`. Se comprobó después contra los **`.config` que
293las recetas instalan** en `boot/config-<versión>`, que es la prueba dura porque incluye lo que hizo
294`olddefconfig`. Los **11 kernels sellados del store** —cinco nombres de artefacto: `linux`,
295`linux-generic`, `linux-metal`, `linux-metal-dual`, `linux-gioser`— traen literalmente:
296
297```
298# CONFIG_MEMCG is not set
299# CONFIG_PSI is not set
300```
301
302y ninguno tiene `CFS_BANDWIDTH` (⇒ `cpu.max`, la cuota dura de CPU, tampoco existe; hoy nadie la
303pide, pero cuando `pacha` la quiera será otro re-hasheo). Lo que **sí** está en los once:
304`CGROUP_SCHED`+`FAIR_GROUP_SCHED` (`cpu.weight`), `CGROUP_PIDS` (`pids.max`), `CPUSETS`
305(`cpuset.cpus`), `BLK_CGROUP`+`BLK_CGROUP_IOCOST` (`io.weight`, con la sintaxis «default N» que
306arje ya usa), `TASKSTATS`+`CONNECTOR`+`PROC_EVENTS`, `SECCOMP_FILTER`, `SECURITY_LANDLOCK`,
307`FANOTIFY`, `OVERLAY_FS` con metacopy e `IO_URING`.
308
309**Y por qué el bug sobrevivió meses**: el kernel de la máquina de desarrollo (Artix 7.1.4) **sí**
310trae `MEMCG`, `PSI` y `CFS_BANDWIDTH`. El fallo sólo existe del lado del artefacto sellado, que es
311el lado que nadie mira con un depurador.
312
313### 4.ter · El guardián: `hammer kernel contract`
314
315Un hallazgo escrito en un documento no impide que vuelva a pasar. El contrato vive en
316`docs/state/kernel-contract.toml` y declara, por capacidad: los símbolos que la construyen, la
317interfaz que aparece o no aparece (`memory.max`, `pids.max`, `landlock_restrict_self`…), **el
318consumidor con ruta y símbolo**, y **cómo falla hoy si no está**. Se comprueba contra un `.config`
319ya producido:
320
321```sh
322hammer --store ./store kernel contract --sealed     # barre los kernels sellados
323hammer kernel contract --profile anfitrion-cards    # el kernel vivo de esta máquina
324hammer kernel contract --list                       # qué promete hammer (esto es el W6)
325```
326
327Tres decisiones que valen la pena nombrar:
328
3291. **Por perfil, no global** — misma lección que el gate de hardware. `linux.toml` es el kernel de
330   QEMU del selfhost-verify: no hospeda Cards y su hash es load-bearing del baseline `of_tree`.
331   Exigirle contabilidad de memoria sería rechazar una receta sana, así que ahí los límites por
332   unidad son `wants` y no `requires`.
3332. **Contra el `.config`, no contra la receta** — entre el `scripts/config -e X` y el `.config`
334   está `olddefconfig`, que puede tragarse el símbolo sin decir nada (es justo lo que
335   `hammer kernel diff-back` ya vigila para los planes).
3363. **Distingue apagado de ausente** — un símbolo que el `.config` ni nombra puede ser un
337   renombrado entre versiones. Cuenta igual como capacidad ausente, pero se informa aparte; y un
338   kernel sin perfil declarado queda **SIN COMPROBAR** en vez de contar como aprobado.
3394. **Distingue vigente de superado** (agregado al pagar H1) — el store guarda **todos** los
340   sellados, no el último. Mirándolos a todos por igual, arreglar la receta dejaba el gate rojo
341   para siempre por artefactos que nadie va a volver a construir, y **un portón que no puede
342   ponerse verde deja de leerse**. Ahora cada sellado se compara con el `ArtifactHash` que la
343   receta de hoy produce —la misma vigencia de `hammer hash --check`, lab incluido—: el vigente
344   bloquea; el superado sale en su propia sección con lo que le falta, porque sigue siendo cierto
345   que una máquina que arranque ese kernel corre sus Cards con lo que ese kernel traiga. Y dos
346   negativas explícitas: si la vigencia no se puede resolver se comprueba **todo** y se dice por
347   qué, y **cero vigentes con superados a la vista sale ≠0**, no verde — el vacío leído como
348   presencia (CLAUDE.md §3) es justo el fallo que este guardián vino a arreglar.
349
350Estado al escribirlo: **2 de 11 configs sellados cumplían su perfil** (los dos `linux`), los 9 de
351perfil `anfitrion-cards` fallaban por `MEMCG`. **Estado tras pagar H1 (2026-08-30): 5 de 5 vigentes
352cumplen**, y los 10 sellados que quedaron atrás salen listados como superados. El kernel vivo de
353esta máquina pasa las 11 exigidas.
354
355Lo que **sí** funciona en un kernel hammer: `cpu.weight` (CGROUP_SCHED), `pids.max`
356(CGROUP_PIDS), `cpu.stat` (rstat del core) y `cgroup.freeze` — este último porque el freezer
357v2 es `obj-y` en `kernel/cgroup/Makefile`, no el `CONFIG_CGROUP_FREEZER` de v1.
358
359## 5. Qué se esquiva SIN escribir kernel
360
361| Tasa | Reemplazo mainline | Ganancia medida |
362|---|---|---|
363| T1 `fork` de padre grande | `posix_spawn` / `CLONE_VFORK` | 17× a 256 MB, y plano |
364| T2 `ld.so` | binario estático musl | 4,2× |
365| T3 `/proc` texto | `getrusage`, `taskstats` | 11× |
366| T4 sumar `/proc` por unidad | agregado de cgroup v2 | O(1) vs O(N), cruce en N=2 |
367| T5 liveness por `/proc` | `pidfd` (+ sin carrera de PID) | 2,9× y correctitud |
368| T3 enumerar procesos | proc connector (netlink): eventos push | **0 vs 200 procesos vistos** (§9.1) |
369| T6 señales | `eventfd`/`signalfd`/`pidfd` | 15 syscalls → 1 |
370| T8 tormenta de `stat` | `getdents64`, `fstatat(dirfd,…)` | 3,9×–12× |
371| T9 hermetismo de rutas | `openat2 RESOLVE_BENEATH` | ~gratis |
372| T10 materializar árbol | `link()` | 15,4× en ext4 |
373| T11 copiar un fichero entero | `FICLONE`/reflink **en xfs o btrfs** | **25 600× a 512 MiB** (§9.2) |
374| T15 cerrar fds | `close_range` | 102 µs → ~0 |
375
376**Ninguna de estas exige un módulo, un parche ni un kernel propio.** Es la primera conclusión
377del documento y la que ordena todo lo demás.
378
379## 6. Qué exigiría tocar el kernel — y su precio en hammer
380
381Después de agotar §5 quedan tres deseos legítimos que mainline no da:
382
3831. **Una syscall que devuelva un lote de estadísticas por proceso en binario.** Lo más
384   cercano es `taskstats` (por tarea, netlink) y el agregado de cgroup. No existe el «dame
385   los 291 en una llamada».
3862. **Eventos de estado con más contexto que el proc connector.**
3873. **Un plano de control de unidades que no pase por el filesystem.**
388
389Su precio, concreto:
390
391- **Es built-in, no `.ko`** (`-d MODULES`). ⇒ parche a la receta ⇒ entra en `hash_inputs`
392  ⇒ kernel nuevo, artefacto nuevo, y rebase manual en cada versión.
393- **Nunca un número de syscall nuevo**: choca con upstream para siempre. Lo compatible es un
394  char device, un fichero propio en sysfs, o una kfunc BPF.
395- **Con eBPF (que es la forma sin código out-of-tree) el precio es `DEBUG_INFO_BTF`**, hoy
396  apagado en las cuatro recetas con motivo escrito (`linux.toml:32`: *«pahole (BTF), ausente
397  del toolchain»*). Deuda ya medio pagada: dwarves construye (frente libdw/musl cerrado).
398- **El bloqueo de diseño, que no es técnico**: `SDD-SERVIDOR-AJENO.md` promete *«cero
399  invasión — la máquina sigue siendo su Ubuntu de siempre»*. Un sandokan que dependa de un
400  kernel hammer deja de correr ahí. ⇒ **el núcleo se queda portable siempre; lo rápido es un
401  backend opcional detectado en runtime, con `/proc` como respaldo que no se borra.**
402
403## 7. Propuestas — lado hammer
404
405- **H1 · `-e MEMCG -e PSI` en los kernels que hospedan Cards. PAGADO 2026-08-30.** Cierra §4 y
406  habilita `memory.current` (T4) y las señales de presión. Se hizo en **una sola tanda** —el precio
407  era el re-hasheo de todos los artefactos de kernel— sobre `linux-generic`, `linux-metal`,
408  `linux-metal-dual` y la derivada `linux-gioser`, que hubo que **regenerar**: una receta derivada
409  inlinea la fase `configure` de su base y sin regenerarla se queda con la vieja. En `linux.toml`
410  (perfil `qemu-serial`) NO corre: su hash es el baseline del selfhost-verify y ahí no hay Cards.
411
412  Tres cosas que valen para la próxima:
413
414  1. **El `x86_64_defconfig` de 7.1 ya no trae `MEMCG=y`** — de ahí que hiciera falta pedirlo. Y
415     `MEMCG` no tiene un solo `depends on` (sólo `select`s), así que el `olddefconfig` no se lo
416     podía tragar; se comprobó igual **contra el `.config` producido**, que es la regla.
417  2. **Precio en bytes, medido**: bzImage `linux-generic` 16 856 064 → 16 937 984 (**+80 KiB,
418     +0,49%**), `linux-metal` +84 KiB (+0,57%), `linux-metal-dual` +88 KiB (+0,53%), `linux-gioser`
419     +80 KiB (+0,71%). Contabilidad por unidad y presión, por medio punto porcentual de kernel.
420  3. **La prueba de que se pagó es el mismo comando**: `hammer kernel contract --sealed` sale 0 y
421     dice `5 de 5 config(s) VIGENTES cumplen su perfil`. La salida cruda quedó en
422     `docs/evidencia/tasas-kernel-2026-08-29/contrato-h1-pagado.txt`, junto con el `diff-back` del
423     plan de gioser sobre el config nuevo (35 cumplidos, 0 incumplidos).
424- **H2 · `MODULES=n` se queda.** Si algún día hace falta código de kernel, es built-in y
425  parcheado en la receta. Queda escrito para no rediscutirlo.
426- **H3 · Contención de rutas: el sitio no era harkaq, era la hidratación.** La propuesta original
427  decía «que harkaq use `openat2 RESOLVE_BENEATH` donde hoy valida rutas a mano (T9, ~gratis)».
428  Buscando ese sitio (2026-08-30) resultó que **harkaq no valida rutas a mano**: delega la
429  contención en Landlock, que es más fuerte que cualquier chequeo de ruta. Donde no se validaba
430  **nada** era en `hammer-build/hydrate.rs`.
431
432  **El fallo, reproducido**: hidratar es `target_fhs.join(rel)` y escribir por ruta ⇒ **un symlink
433  de directorio ya presente en el FHS se sigue**. Un artefacto que trae `usr/share/pkg → /algún/lado`
434  deja ese symlink puesto —los symlinks se replican literales, y así debe ser— y la hidratación
435  siguiente escribía `usr/share/pkg/archivo` **fuera del root, devolviendo `Ok(1)`**. Con
436  `hammer hydrate --into` sobre una imagen, eso es escribir en el sistema anfitrión diciendo que
437  todo fue bien: el fallo de CLAUDE.md §3, otra vez.
438
439  **Cuánto se estaba disparando: cero.** Del store: **160 symlinks absolutos, ninguno a un
440  directorio**; **~42 000 relativos, ninguno que salga de su artefacto**. Era una mina desactivada,
441  no un incendio — y por eso nadie lo iba a encontrar mirando logs.
442
443  **HECHO**: `no_escapa()` comprueba la contención **por directorio** (no por fichero: un
444  `canonicalize` por entrada costaría un realpath por fichero, T8/§9.4) y falla nombrando el
445  symlink y adónde lleva. Semántica **`RESOLVE_IN_ROOT`, no `RESOLVE_NO_SYMLINKS`**: un symlink que
446  se queda dentro tiene que seguir andando (usr-merge `/lib → usr/lib`, los `22@2x → 22` de los
447  temas de iconos), y hay un test para cada dirección en
448  `crates/hammer-build/tests/hidratacion_no_escapa.rs`.
449
450  **Y el mismo patrón estaba en el OTRO proyector**: `hammer-upgrade::project_plan` es
451  `target_root.join(rel)` + escribir por ruta, igual que hydrate. Con `--root /` no cambia nada
452  (todo empieza por `/`), pero `hammer upgrade apply --root /mnt/imagen` es el caso real de armar
453  una imagen: reproducido —la generación 2 escribía fuera del root con `ApplyReport` en verde— y
454  tapado con la misma regla (se mide el **directorio padre**, porque un `Replaced` sobre un symlink
455  que apunta afuera es legítimo: `rename` pisa el symlink, no escribe a través de él). Dos tests,
456  uno por dirección, en `crates/hammer-upgrade/src/lib.rs`.
457
458  **Lo que queda de la propuesta original**: `canonicalize`+escribir es comprobar-y-después-usar, o
459  sea que un adversario **concurrente** podría cambiar un componente entre las dos. Para el fallo
460  real —un symlink persistente puesto por otro artefacto— alcanza y es determinista. La versión sin
461  carrera es mover las escrituras a `openat2(RESOLVE_IN_ROOT)` + `linkat`/`renameat`, y arrastra el
462  camino de `patchelf` (que necesita una ruta, no un fd). Queda escrito acá con su precio, que es
463  más de lo que se sabía cuando H3 se llamaba «~gratis».
464- **H4 · Ningún `Command` de hammer pone `pre_exec`** — Rust apaga el camino `posix_spawn` en
465  cuanto hay uno, y cae a `fork+exec` (T1: 17× a 256 MB tocados). `hammer-build/sandbox.rs` lanza
466  `bwrap` sin `pre_exec`, o sea que estaba bien **por suerte**: nadie lo comprobaba. **HECHO**: el
467  test `crates/hammer-build/tests/sin_pre_exec.rs` barre las fuentes del workspace y falla si
468  aparece uno (comprobado en los dos sentidos: inyectando un `pre_exec` el test se pone rojo, y un
469  barrido que no encuentra fuentes también, para que no apruebe por vacío). Si algún día hace falta
470  de verdad, la salida es escribir en el test por qué ese caso paga los 17×, no borrarlo.
471- **H5 · La materialización de dependencias se queda en hardlink** (T10) y el experimento de
472  reflink (§9) alimenta SDD 18 (wawafs), donde el store como FS vivo cambiaría el cociente.
473- **H6 · No `madvise(MADV_HUGEPAGE)` en el camino de build** mientras `defrag=madvise` y la
474  granja corra apretada de RAM (T13).
475- **H7 · `DEBUG_INFO_BTF` sigue apagado — pero ya no por falta de número.** §9.5 midió qué
476  compra: un iterador `task` saca la foto de procesos **cerca de 7× más barato** que el barrido de
477  `/proc` (6,9-8,0× en cuatro corridas y dos órdenes), y —lo que de verdad decide— **al precio de
478  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.
479
480  El precio, en cambio, es más grande de lo que decía esta línea («pahole en el lab y un
481  re-hasheo»). Son **cuatro** cosas, y ninguna es un rediseño:
482
483  1. **Los kernels no compilan con información de depuración**: `CONFIG_DEBUG_INFO_NONE=y` en los
484     `.config` sellados. BTF exige DWARF, así que primero hay que encender `DEBUG_INFO_DWARF5`.
485     Eso encarece el build, **no el `bzImage`**: el DWARF vive en `vmlinux`, que la receta no
486     empaqueta — lo único que entra a la imagen es la sección `.BTF`.
487  2. **pahole está en el catálogo, pero el kernel no lo declara.** `recipes/dwarves.toml` (1.30)
488     se selló al cerrarse el frente libdw/musl, y su propia cabecera lo dice: «El kernel NO la
489     declara todavía».
490  3. **Ese pahole es DINÁMICO pese a `link = "static"`.** El binario del artefacto vigente
491     (`8057bcd…`) trae `INTERP` y cinco `NEEDED`: `libelf`, `libz`, `liblzma`, `libbz2`, `libc`.
492     Corre **dentro del sandbox del build del kernel**, así que esas librerías tienen que estar
493     ahí o no arranca — y el fallo saldría como «pahole no está» estando.
494  4. **BTF mete a pahole en el juego de la bit-repro.** El `.BTF` lo emite pahole ⇒ su versión
495     pasa a ser parte de la identidad del artefacto, y el lab **no está en `hash_inputs`**: dos
496     labs con pahole distinto sellarían bytes distintos en la misma dirección sin que el store lo
497     note. Es el agujero conocido, con una herramienta más adentro.
498
499  **Y ya hay dos consumidores nombrados, no cero.** El iterador de §9.5, que H8 aceptaría porque
500  trae ruta y símbolo (`bpf_iter_create`, `attach_btf_id` resuelto contra el BTF del vmlinux); y
501  **sched-ext**, que es por lo que existe la receta de dwarves — `SCHED_CLASS_EXT` depende de
502  `DEBUG_INFO_BTF`. El primero está medido; el segundo todavía no tiene su símbolo.
503
504  Lo que **no** está medido es el precio en bytes del `bzImage`, que es como se pagó H1. No se
505  puede estimar del kernel de Artix: sus 6,4 MB de BTF son de un kernel modular enorme y no dicen
506  nada de uno monolítico y pelado. **Se mide construyendo, y esa medición es el primer paso el día
507  que se decida encenderlo** — no antes, porque enciende el re-hasheo de los cuatro kernels.
508- **H8 · El contrato de capacidades es parte del release del kernel** (§4.ter). Toda receta de
509  kernel nueva declara su `[[target]]` con perfil; un kernel sin perfil sale **SIN COMPROBAR**, no
510  aprobado. Y toda capacidad nueva entra con consumidor —ruta y símbolo— o no entra: «estaría bueno
511  tener» no es un contrato.
512- **H9 · El experimento de reflink cambia el cálculo de SDD 18, no el de hoy.** Con `.dmerge` sobre
513  ext4 el hardlink sigue siendo la elección correcta (T10). Lo que §9.2 agrega es que un store
514  sobre **xfs o btrfs** compraría semántica de copia —mutación privada, sin la contrapartida de
515  retener lo borrado— al mismo precio. Es un argumento para wawafs, medido; no un cambio de
516  filesystem decidido acá.
517
518## 8. Propuestas — lado tawasuyu
519
520Detalle en el handoff espejo. En una línea cada una:
521
522- **W1 · `pidfd` para liveness y señales** en `sandokan-local` (hoy `access("/proc/<pid>")` y
523  `waitpid(WNOHANG)` en sondeo). Se gana correctitud (carrera de PID) antes que velocidad.
524- **W2 · Telemetría por unidad desde el cgroup**, no sumando `/proc` (T4). Con **sonda de
525  capacidad**: si `memory.current` no existe (kernel sin MEMCG), decirlo, no callarlo. Del lado
526  hammer esa sonda ya tiene con qué contrastarse: `hammer kernel contract --list --json` publica
527  qué promete cada perfil de kernel (§4.ter), que es el W6 en formato de dato.
528- **W3 · `arje-incarnate` con `posix_spawn`** donde el `pre_exec` no sea imprescindible (T1),
529  y `close_range` en el que quede.
530- **W4 · Los errores de cgroup dejan de descartarse.** `let _ = write(...)` y `warn!` sobre un
531  límite no aplicado son el bug de §4, no un detalle de estilo.
532- **W5 · `/proc` no se borra nunca**: es el respaldo portable que hace posible
533  SDD-SERVIDOR-AJENO.
534- **W6 · El contrato entre repos es el trait `Engine`, no una ABI de kernel.** hammer publica
535  la lista de símbolos garantizados (verificable con `hammer kernel probe`); tawasuyu elige
536  backend en runtime.
537
538## 9. Los experimentos nombrados — cinco medidos, uno abierto
539
540La primera corrida los dejó nombrados y sin medir, todos por privilegios o por herramienta
541ausente. **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
542(load ~1,3 sobre 4 vCPU, 4,3 GiB libres) ⇒ los números de acá **no se cruzan** con los de §2.
543
544### 9.1 · proc connector y `taskstats` — MEDIDO (`bench_connector.c`, `connector.txt`)
545
546**a) Latencia.** Esperar el evento `PROC_EVENT_EXIT` del connector **no cuesta más** que el
547`waitpid` que el padre ya hace: 116,0 µs contra 114,8 µs por `fork+_exit` (mínimo de 7 rondas de
548200), es decir dentro del ruido, con **0 eventos perdidos de 1 400**. Con una advertencia dura que
549salió del propio experimento: el multicast del connector **no reintenta**. Si el buffer del socket
550se llena, el evento se pierde y quien lo espera se cuelga para siempre — la primera versión del
551programa se colgó exactamente así. Un supervisor sobre connector necesita `SO_RCVBUF` grande **y
552un plazo**, no una espera infinita.
553
554**b) Cobertura — el resultado que importa.** 200 procesos de vida mínima, uno cada 10 ms:
555
556| observador | vistos | CPU |
557|---|---|---|
558| sondeo de `/proc` cada 100 ms (23 barridos) | **0 de 200** | 10,5 ms |
559| proc connector (eventos) | **200 de 200** | 95,4 ms |
560
561El sondeo no es caro: es **ciego**. Y la CPU del connector de esa tabla es cota superior — el
562programa lo lee con espera activa de 200 µs, que no es el modo de un supervisor real (sería
563`epoll`, donde el coste es despertar por evento).
564
565**c) `taskstats`.** La familia genetlink existe y responde. Por consulta: **2 688 ns** contra
566**2 697 ns** de `open`+`read`+parse de `/proc/self/stat`. **No gana en velocidad** (sorpresa §3.4).
567Lo que da y `/proc` no: contabilidad de retrasos (delay accounting) y una estructura binaria
568estable en vez de un texto que hay que parsear posicionalmente.
569
570### 9.2 · Copia con reflink — MEDIDO (`bench_reflink.c`, `reflink.txt`)
571
572`mkfs` sobre un loop, xfs con `reflink=1` y btrfs por defecto. Copiar un fichero **entero**:
573
574| | ext4 | xfs (loop) | btrfs (loop) |
575|---|---|---|---|
576| `FICLONE` (reflink) | **no soportado** | 0,010 ms | 0,006 ms |
577| `copy_file_range` | 13,66 ms | 0,010 ms | 0,006 ms |
578| `read+write` (1 MiB) | 14,46 ms | 10,96 ms | 31,32 ms |
579
580Y la prueba de que es O(1) y no «rápido»: **con el fichero ocho veces más grande (512 MiB), el
581reflink pasa de 0,010 a 0,020 ms** —el doble, no ocho veces— mientras `read+write` pasa de 10,96 a
582512,7 ms. **25 600× a 512 MiB en xfs.** En btrfs, 0,006 → 0,015 ms.
583
584Confirma T11 por el lado contrario: en ext4 `copy_file_range` no gana nada (13,66 contra 14,46 ms,
585un 6%) **porque ext4 no tiene reflink**; el «copiar en O(1)» que la gente espera de esa syscall es
586una propiedad del filesystem, no de la syscall. Material directo para SDD 18 (wawafs): con reflink
587se obtiene semántica de **copia** al precio del hardlink, y sin la contrapartida del hardlink —que
588`.dmerge` ya paga: retener en la caché lo que se borró del store.
589
590### 9.3 · io_uring con `SQPOLL` — MEDIDO (`bench_sqpoll.c`, `sqpoll.txt`)
591
5924K aleatorios en caché, profundidad 32, fd registrado:
593
594| ns/op | ext4 | tmpfs |
595|---|---|---|
596| `pread` | **554** | **749** |
597| io_uring sin SQPOLL | 618 | 2 030 |
598| io_uring con SQPOLL | 868 | 1 409 |
599
600**No cambia el veredicto de T12.** Sobre datos cacheados `SQPOLL` sigue perdiendo contra la
601syscall (1,6× peor en ext4), y aunque en tmpfs mejora al io_uring normal (1,4×), sigue estando
6021,9× por detrás de `pread`. Encima **se come un core** con el hilo del kernel, que en una máquina
603de 4 vCPU es el 25% de la granja. `SQPOLL` es para operaciones que **de verdad bloquean**, no para
604amortizar cruces sobre caché.
605
606### 9.4 · Coste en syscalls de un compilado — MEDIDO (`syscalls-compilador.txt`)
607
608La corrida original decía «sin `strace`/`perf` en esta máquina no se puede atribuir». **El
609`strace` estaba en el propio corpus** (`store/3c1fd8c0…-strace`, 6.19): la distro se paga a sí
610misma la herramienta de diagnóstico. Un `-c` de un `main()` trivial con stdio/stdlib/string:
611
612| | syscalls | con error | el grueso |
613|---|---|---|---|
614| `cc -O2 -c` (gcc 16) | 1 803 | **1 095 (61%)** | **919 de 923 `readlink` FALLAN** |
615| `zig cc -O2 -c` (zig 0.16) | 3 608 | 214 (6%) | 1 104 `futex` (70% del tiempo en syscall) |
616
617Dos lecturas:
618
619- **La tormenta de rutas de T8 existe y es de `readlink`, no de `stat`** — canonicalización de
620  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
621  de compilado: el 0,5%.** La respuesta a la pregunta original («¿vale segundos o minutos por
622  receta?») es **milisegundos por invocación**: segundos por millar de compilados. No es ahí donde
623  está el tiempo de una tanda.
624- **`zig cc` tiene otro perfil**: casi no falla rutas, pero coordina hilos. El tiempo que
625  `strace -c` le atribuye es espera **bloqueada**, no CPU — y por eso `strace -c` no sirve para
626  comparar compiladores, sólo para contar llamadas.
627
628### 9.5 · Iterador BPF contra `/proc` — MEDIDO (`bench_bpf_iter.c` + `bpf_iter_task.bpf.c` → `bpf-iter.txt`)
629
630Medido el **2026-08-31**, sobre el kernel de desarrollo (Artix 7.1.4, que sí trae BTF) —
631**no sobre un kernel de hammer, que es justamente lo que está en discusión**. Tercera corrida,
632condiciones propias: **sus números no se cruzan con los de §2 ni con los de §9.1-9.4.**
633
634Los dos caminos hacen **el mismo trabajo**: sacar `{pid, ppid, estado, utime, stime, comm}` de
635todos los procesos vivos, que es la foto que sandokan necesita de la máquina. Uno lo hace con
636`readdir` + `open`+`read`+parse de `/proc/<pid>/stat`; el otro con un `iter/task` que escribe
637**registros binarios de tamaño fijo** con `bpf_seq_write`, leídos de un solo fd.
638
639| 245 procesos vivos, 33 rondas, mínimo | total | por proceso | × sobre el piso |
640|---|---|---|---|
641| A) `/proc` readdir+`stat`+parse | 1 356,1 µs | 5,54 µs | 12,1× |
642| B) iterador BPF (binario) | **196,1 µs** | **0,80 µs** | **1,75×** |
643| C) piso: `readdir(/proc)` a secas | 112,1 µs | 0,46 µs | 1× |
644
645**6,9× a favor del iterador** — pero el número que decide no es ése: **el iterador cuesta casi lo
646mismo que listar el directorio y no abrir nada** (1,75× el piso, contra 12,1× de `/proc`). Leer
647los campos deja de tener precio; lo que se paga es enumerar.
648
649Siete corridas, dos órdenes (A→B y B→A, para descartar que el primero de cada ronda pague el
650enfriamiento de las dentries de `/proc`): **6,9-8,0× en las cuatro de 33 rondas**, en las dos
651direcciones. La de 15 rondas dio 4,50× y es de la que hay que desconfiar, no de la mayoría: con
652pocas rondas el mínimo no converge y la máquina estaba cargada.
653
654**Amortización**: cargar y verificar el programa cuesta **13-17 ms una vez**; cada barrido ahorra
655más de 1 ms ⇒ **se paga solo en 12-40 barridos** según la corrida, 15 en la citada. Para un
656sandokan que mire la máquina una vez por segundo, medio minuto.
657
658**Lo que la ganancia cuesta, comprobado y no supuesto** — son dos barreras, no una:
659
6601. **BTF es un portón, no una optimización.** El programa cargado informa
661   `prog_type=26 (BPF_PROG_TYPE_TRACING)`, `attach_btf_id=80137`, `attach_btf_obj_id=1`: el
662   iterador **se ata por id de tipo BTF del vmlinux**. Un kernel sin `DEBUG_INFO_BTF` no publica
663   `/sys/kernel/btf/vmlinux` y no hay a qué atarse. **No hay versión de esto que esquive H7.**
6642. **Privilegio**: `CAP_BPF`+`CAP_PERFMON` o root. Sin él, `bpf_object__load` sale
665   `Operation not permitted`. Una Card no lo tiene ni debe tenerlo ⇒ **el consumidor es el
666   supervisor, no la Card** — que es donde vive sandokan, así que encaja.
667
668**Y los dos caminos NO dicen exactamente lo mismo.** De 245 procesos, **24 `comm` difieren**, y
669son **todos kworkers**: `/proc` **sintetiza** el nombre pegándole la workqueue que el hilo está
670sirviendo (`kworker/0:0H-kblockd`), mientras `task->comm` trae el nombre pelado
671(`kworker/0:0H`). Ningún proceso de usuario difiere. Para las Cards da igual; para cualquiera que
672muestre nombres de kworker, no. **Un reemplazo que se vende como "la misma información más
673rápido" tenía una diferencia de contenido, y sólo apareció cotejando registro contra registro** —
674no medir es lo que la habría dejado pasar.
675
676**Nota de método**: el bench aborta con exit 2 si el iterador devuelve **cero registros**. Un
677barrido vacío es infinitamente rápido y no mide nada: es CLAUDE.md §3 aplicado a un benchmark.
678
679### 9.6 · Lo que sigue abierto
680
6811. **Un Intel con PTI**: todos los pisos de syscall de acá son de un AMD sin PTI. En el laptop
682   (TigerLake) las mismas medidas darían peor, y ahí T3/T6 duelen más.
683
684## 10. Evidencia
685
686`docs/evidencia/tasas-kernel-2026-08-29/` — **primera corrida**: cuatro programas en C
687(`bench_proc.c`, `bench_io.c`, `bench_extra.c`, `bench_pin.c`) y sus cinco salidas crudas
688(`proc.txt`, `io-ext4.txt`, `io-tmpfs.txt`, `extra.txt`, `pin.txt`). Se compilan con
689`cc -O2` (`bench_io` pide `-luring`, `bench_proc`/`bench_pin` piden `-lpthread`) y se corren
690sin argumentos salvo `bench_io <dir>` y `bench_pin <estatico> <dinamico> <musl>`.
691
692**Nota de unidad**: en `pin.txt` la sección de fallos de página imprime ns/página × 1000 por
693un factor de escala equivocado en el programa; los valores citados en T13 son los de esa
694salida divididos por 1000, y coinciden con `io-ext4.txt`, que sí está en la unidad correcta.
695
696**Segunda corrida (§9, con root)**: `bench_connector.c` → `connector.txt`; `bench_reflink.c` →
697`reflink.txt` (xfs y btrfs sobre loop, montados y desmontados por el propio guion);
698`bench_sqpoll.c` → `sqpoll.txt`; y `syscalls-compilador.txt`, hecho con el `strace` **del store**.
699Se compilan igual que los otros (`bench_sqpoll` pide `-luring`) y los tres primeros piden root:
700`bench_connector` por `CAP_NET_ADMIN`, `bench_reflink` por `mkfs`+`mount`, `bench_sqpoll` por
701`CAP_SYS_NICE`.
702
703**Contrato de capacidades**: `docs/state/kernel-contract.toml` (el dato) y
704`hammer kernel contract --list` (la vista). Los ficheros de esta carpeta no cambian; el contrato sí
705—cada vez que un consumidor nuevo usa el kernel por nombre.
706
707**Tercera corrida (§9.5, iterador BPF, 2026-08-31)**: `bpf_iter_task.bpf.c` (el programa BPF) +
708`bench_bpf_iter.c` (el de usuario) → `bpf-iter.txt`. Se compilan con
709`clang -O2 -g -target bpf -D__TARGET_ARCH_x86 -c bpf_iter_task.bpf.c -o bpf_iter_task.bpf.o` y
710`cc -O2 -o bench_bpf_iter bench_bpf_iter.c -lbpf`; se corre `./bench_bpf_iter [rondas] [orden]`
711como root, con el `.bpf.o` al lado. **No usa `vmlinux.h`** —haría falta bpftool, que no está en la
712máquina ni en el store— sino los tipos mínimos declarados a mano con `preserve_access_index`, que
713es lo que deja a CO-RE reubicar los offsets contra el BTF del kernel en carga.
714
715**El pago de H1** (2026-08-30): `contrato-h1-pagado.txt` — el barrido entero con los cuatro kernels
716reconstruidos (5 de 5 vigentes en verde, 10 superados listados) y el `diff-back` del plan de gioser
717contra su `.config` nuevo. Es la única salida de esta carpeta que sí cambia si se vuelve a correr:
718las 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

  1. Un módulo cargable no es una opción en hammer y no hace falta. Las tres recetas de kernel (linux-generic.toml:43, linux-metal.toml:104, linux-metal-dual.toml:51) configuran -d MODULES: kernel monolítico. linux.toml no lo apaga pero sólo compila bzImage, nunca make modules. Cualquier código de kernel propio en hammer es built-in, o sea un parche a la receta — que además es lo correcto para el proyecto, porque la fase configure es la identidad del artefacto (SDD 22) y así queda sellado y atestado junto al kernel.
  2. Casi todo lo que duele ya tiene salida en mainline y nadie la está usando. pidfd, cgroup v2 agregado, posix_spawn, getdents64, close_range, openat2. Cero código de kernel, ganancias de 3× a 17× medidas abajo.
  3. La medición encontró un bug cruzado que no se buscaba: los kernels de hammer se construían sin CONFIG_MEMCG, y arje/sandokan escriben memory.max ignorando el error. Un límite de memoria pedido por una Card no se aplicaba y nadie se enteraba. §4. Confirmado sobre los 11 .config sellados, no deducido del defconfig (§4.bis), vigilado por hammer kernel contract (§4.ter) y PAGADO el 2026-08-30: los cuatro kernels que hospedan Cards se reconstruyeron con MEMCG y PSI y el barrido sale 5 de 5 vigentes en verde (§7-H1).
  4. Sondear /proc no es lento, es ciego (§9.1): de 200 procesos de vida mínima, un sondeo cada 100 ms vio cero; el proc connector, los 200. Y el «copiar en O(1)» existe pero no en ext4: en xfs/btrfs un reflink de 512 MiB tarda 0,020 ms contra 512,7 ms de read+write (§9.2).
  5. 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 ahora dice cuánto cuesta encenderlo en vez de sólo que está apagado.

1. Método y condiciones — leer antes de citar un número

2. La lista

T1 · fork() copia el espacio de direcciones del padre

padre con …fork+_exit+waitposix_spawn+wait
0 MB tocados109 µs231 µs
64 MB938 µs234 µs
256 MB3 949 µs231 µ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ÁTICO77,4
glibc ESTÁTICO232,7
glibc DINÁMICO324,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)2383
clock_gettime(PROCESS_CPUTIME)2143
pread+parse /proc/self/stat (fd ya abierto)1 27116
open+read+parse /proc/self/stat2 69234
open+read+parse /proc/self/status (VmRSS)4 64359

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 hoy345
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 disputa8,6
RTT eventfd (2 hilos, mismo cpu)2 507
RTT pipe2 956
RTT socketpair AF_UNIX3 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/ficheroext4tmpfs
stat() por ruta completa648714
fstatat(dirfd, nombre)439489
statx(…, DONT_SYNC)467593
openat+close899961
getdents64 (barrido, sin stat)16859

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 byteext4tmpfs
link()9,2 µs2,9 µs
open+read+write+close142,2 µs5,0 µs
cociente15,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 nula78,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:

  1. THP más lento que 4K (T13) — el manual dice que THP acelera; con defrag=madvise y memoria apretada, triplica el coste del fallo.
  2. io_uring más lento que pread sobre tmpfs (T12) — el manual dice que io_uring es el futuro; sobre datos cacheados el futuro pierde 2,8×.
  3. stat cuesta lo mismo en tmpfs que en ext4 (T8) — invalida la reacción natural («movamos el árbol a tmpfs y vuela»): el coste está en el VFS.
  4. taskstats binario NO es más rápido que parsear /proc (§9.1) — 2 688 vs 2 697 ns por consulta. La intuición «binario le gana a texto» falla porque el ida y vuelta de netlink cuesta lo mismo que open+read+parse. Su valor es el contenido (delay accounting, que /proc no da) y la estabilidad bajo carga, no la velocidad.
  5. Sondear /proc no es lento: es CIEGO (§9.1) — de 200 procesos de vida mínima lanzados uno cada 10 ms, un sondeo cada 100 ms vio cero. Toda la discusión de «cuánto cuesta el barrido» (T3) presupone que el barrido VE lo que hay, y para procesos cortos no lo ve.

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:

  1. Por perfil, no global — misma lección que el gate de hardware. linux.toml es el kernel de QEMU del selfhost-verify: no hospeda Cards y su hash es load-bearing del baseline of_tree. Exigirle contabilidad de memoria sería rechazar una receta sana, así que ahí los límites por unidad son wants y no requires.
  2. Contra el .config, no contra la receta — entre el scripts/config -e X y el .config está olddefconfig, que puede tragarse el símbolo sin decir nada (es justo lo que hammer kernel diff-back ya vigila para los planes).
  3. Distingue apagado de ausente — un símbolo que el .config ni nombra puede ser un renombrado entre versiones. Cuenta igual como capacidad ausente, pero se informa aparte; y un kernel sin perfil declarado queda SIN COMPROBAR en vez de contar como aprobado.
  4. Distingue vigente de superado (agregado al pagar H1) — el store guarda todos los sellados, no el último. Mirándolos a todos por igual, arreglar la receta dejaba el gate rojo para siempre por artefactos que nadie va a volver a construir, y un portón que no puede ponerse verde deja de leerse. Ahora cada sellado se compara con el ArtifactHash que la receta de hoy produce —la misma vigencia de hammer hash --check, lab incluido—: el vigente bloquea; el superado sale en su propia sección con lo que le falta, porque sigue siendo cierto que una máquina que arranque ese kernel corre sus Cards con lo que ese kernel traiga. Y dos negativas explícitas: si la vigencia no se puede resolver se comprueba todo y se dice por qué, y cero vigentes con superados a la vista sale ≠0, no verde — el vacío leído como presencia (CLAUDE.md §3) es justo el fallo que este guardián vino a arreglar.

Estado al escribirlo: 2 de 11 configs sellados cumplían su perfil (los dos linux), los 9 de perfil anfitrion-cards fallaban por MEMCG. Estado tras pagar H1 (2026-08-30): 5 de 5 vigentes cumplen, y los 10 sellados que quedaron atrás salen listados como superados. El kernel vivo de esta máquina pasa las 11 exigidas.

Lo que sí funciona en un kernel hammer: cpu.weight (CGROUP_SCHED), pids.max (CGROUP_PIDS), cpu.stat (rstat del core) y cgroup.freeze — este último porque el freezer v2 es obj-y en kernel/cgroup/Makefile, no el CONFIG_CGROUP_FREEZER de v1.

5. Qué se esquiva SIN escribir kernel

TasaReemplazo mainlineGanancia medida
T1 fork de padre grandeposix_spawn / CLONE_VFORK17× a 256 MB, y plano
T2 ld.sobinario estático musl4,2×
T3 /proc textogetrusage, taskstats11×
T4 sumar /proc por unidadagregado de cgroup v2O(1) vs O(N), cruce en N=2
T5 liveness por /procpidfd (+ sin carrera de PID)2,9× y correctitud
T3 enumerar procesosproc connector (netlink): eventos push0 vs 200 procesos vistos (§9.1)
T6 señaleseventfd/signalfd/pidfd15 syscalls → 1
T8 tormenta de statgetdents64, fstatat(dirfd,…)3,9×–12×
T9 hermetismo de rutasopenat2 RESOLVE_BENEATH~gratis
T10 materializar árbollink()15,4× en ext4
T11 copiar un fichero enteroFICLONE/reflink en xfs o btrfs25 600× a 512 MiB (§9.2)
T15 cerrar fdsclose_range102 µs → ~0

Ninguna de estas exige un módulo, un parche ni un kernel propio. Es la primera conclusión del documento y la que ordena todo lo demás.

6. Qué exigiría tocar el kernel — y su precio en hammer

Después de agotar §5 quedan tres deseos legítimos que mainline no da:

  1. Una syscall que devuelva un lote de estadísticas por proceso en binario. Lo más cercano es taskstats (por tarea, netlink) y el agregado de cgroup. No existe el «dame los 291 en una llamada».
  2. Eventos de estado con más contexto que el proc connector.
  3. Un plano de control de unidades que no pase por el filesystem.

Su precio, concreto:

7. Propuestas — lado hammer

8. Propuestas — lado tawasuyu

Detalle en el handoff espejo. En una línea cada una:

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:

observadorvistosCPU
sondeo de /proc cada 100 ms (23 barridos)0 de 20010,5 ms
proc connector (eventos)200 de 20095,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:

ext4xfs (loop)btrfs (loop)
FICLONE (reflink)no soportado0,010 ms0,006 ms
copy_file_range13,66 ms0,010 ms0,006 ms
read+write (1 MiB)14,46 ms10,96 ms31,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/opext4tmpfs
pread554749
io_uring sin SQPOLL6182 030
io_uring con SQPOLL8681 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:

syscallscon errorel grueso
cc -O2 -c (gcc 16)1 8031 095 (61%)919 de 923 readlink FALLAN
zig cc -O2 -c (zig 0.16)3 608214 (6%)1 104 futex (70% del tiempo en syscall)

Dos lecturas:

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ínimototalpor proceso× sobre el piso
A) /proc readdir+stat+parse1 356,1 µs5,54 µs12,1×
B) iterador BPF (binario)196,1 µs0,80 µs1,75×
C) piso: readdir(/proc) a secas112,1 µs0,46 µs1×

6,9× a favor del iterador — pero el número que decide no es ése: el iterador cuesta casi lo mismo que listar el directorio y no abrir nada (1,75× el piso, contra 12,1× de /proc). Leer los campos deja de tener precio; lo que se paga es enumerar.

Siete corridas, dos órdenes (A→B y B→A, para descartar que el primero de cada ronda pague el enfriamiento de las dentries de /proc): 6,9-8,0× en las cuatro de 33 rondas, en las dos direcciones. La de 15 rondas dio 4,50× y es de la que hay que desconfiar, no de la mayoría: con pocas rondas el mínimo no converge y la máquina estaba cargada.

Amortización: cargar y verificar el programa cuesta 13-17 ms una vez; cada barrido ahorra más de 1 ms ⇒ se paga solo en 12-40 barridos según la corrida, 15 en la citada. Para un sandokan que mire la máquina una vez por segundo, medio minuto.

Lo que la ganancia cuesta, comprobado y no supuesto — son dos barreras, no una:

  1. BTF es un portón, no una optimización. El programa cargado informa prog_type=26 (BPF_PROG_TYPE_TRACING), attach_btf_id=80137, attach_btf_obj_id=1: el iterador se ata por id de tipo BTF del vmlinux. Un kernel sin DEBUG_INFO_BTF no publica /sys/kernel/btf/vmlinux y no hay a qué atarse. No hay versión de esto que esquive H7.
  2. Privilegio: CAP_BPF+CAP_PERFMON o root. Sin él, bpf_object__load sale Operation not permitted. Una Card no lo tiene ni debe tenerlo ⇒ el consumidor es el supervisor, no la Card — que es donde vive sandokan, así que encaja.

Y los dos caminos NO dicen exactamente lo mismo. De 245 procesos, 24 comm difieren, y son todos kworkers: /proc sintetiza el nombre pegándole la workqueue que el hilo está sirviendo (kworker/0:0H-kblockd), mientras task->comm trae el nombre pelado (kworker/0:0H). Ningún proceso de usuario difiere. Para las Cards da igual; para cualquiera que muestre nombres de kworker, no. Un reemplazo que se vende como "la misma información más rápido" tenía una diferencia de contenido, y sólo apareció cotejando registro contra registro — no medir es lo que la habría dejado pasar.

Nota de método: el bench aborta con exit 2 si el iterador devuelve cero registros. Un barrido vacío es infinitamente rápido y no mide nada: es CLAUDE.md §3 aplicado a un benchmark.

9.6 · Lo que sigue abierto

  1. Un Intel con PTI: 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.

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.

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.