tasas: SDD 25 al día — §4 confirmado sobre artefactos, §9 con cuatro medidos

- §0 y §4.bis: el «sin MEMCG» deja de ser deducción del defconfig. Los ONCE
  .config sellados traen `# CONFIG_MEMCG is not set` y `# CONFIG_PSI is not
  set`, y ninguno tiene CFS_BANDWIDTH (⇒ tampoco existe `cpu.max`; hoy nadie
  lo pide). Y se nombra por qué sobrevivió: el kernel de la máquina de
  desarrollo SÍ los trae — el fallo sólo existe del lado del artefacto.
- §4.ter: el guardián `hammer kernel contract`, con las tres decisiones de
  diseño (por perfil, contra el .config y no la receta, apagado ≠ ausente).
- §3: de tres sorpresas a cinco (taskstats no es más rápido que /proc; el
  sondeo es ciego, no lento).
- §9: reescrita. Cuatro de los seis experimentos nombrados quedaron medidos
  con root; siguen abiertos el iterador BPF (detrás de H7) y el Intel con PTI.
- §5, §7 y §10: filas medidas, H1 marcado como pendiente-pero-vigilado,
  H8 (el contrato es parte del release) y H9 (reflink alimenta SDD 18).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACUcwo9mZsE5ocYVE9npih
This commit is contained in:
Sergio
2026-08-29 19:34:16 +00:00
co-authored by Claude Opus 5
parent 7cb1a5de95
commit bfa3c6445a
+194 -20
View File
@@ -28,6 +28,12 @@ El espejo de este documento para el lado tawasuyu es
3. **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.
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).
## 1. Método y condiciones — leer antes de citar un número
@@ -41,6 +47,9 @@ El espejo de este documento para el lado tawasuyu es
- Cada medida son 79 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**.
@@ -221,7 +230,7 @@ ruido. Gratis, y ya está en mainline.
`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 tres sorpresas
## 3. Las cinco sorpresas
Las anoto aparte porque contradicen el consejo de manual:
@@ -231,6 +240,13 @@ Las anoto aparte porque contradicen el consejo de manual:
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`
@@ -263,6 +279,59 @@ match std::fs::write(&path, format!("{mem}\n")) {
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:
```sh
hammer --store ./store kernel contract --sealed # barre los kernels sellados
hammer kernel contract --profile anfitrion-cards # el kernel vivo de esta máquina
hammer kernel contract --list # qué promete hammer (esto es el W6)
```
Tres decisiones que valen la pena nombrar:
1. **Por perfil, no global** — misma lección que el gate de hardware. `linux.toml` es el kernel de
QEMU del selfhost-verify: no hospeda Cards y su hash es load-bearing del baseline `of_tree`.
Exigirle contabilidad de memoria sería rechazar una receta sana, así que ahí los límites por
unidad son `wants` y no `requires`.
2. **Contra el `.config`, no contra la receta** — entre el `scripts/config -e X` y el `.config`
está `olddefconfig`, que puede tragarse el símbolo sin decir nada (es justo lo que
`hammer kernel diff-back` ya vigila para los planes).
3. **Distingue apagado de ausente** — un símbolo que el `.config` ni nombra puede ser un
renombrado entre versiones. Cuenta igual como capacidad ausente, pero se informa aparte; y un
kernel sin perfil declarado queda **SIN COMPROBAR** en vez de contar como aprobado.
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.
@@ -276,11 +345,12 @@ v2 es `obj-y` en `kernel/cgroup/Makefile`, no el `CONFIG_CGROUP_FREEZER` de v1.
| 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 | deja de sondear |
| 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
@@ -315,7 +385,10 @@ Su precio, concreto:
- **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.
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
@@ -329,6 +402,15 @@ Su precio, concreto:
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
@@ -337,7 +419,9 @@ 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.
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
@@ -348,27 +432,106 @@ Detalle en el handoff espejo. En una línea cada una:
la lista de símbolos garantizados (verificable con `hammer kernel probe`); tawasuyu elige
backend en runtime.
## 9. Lo que NO pude medir (experimentos nombrados)
## 9. Los experimentos nombrados — cuatro medidos, dos abiertos
Ninguno se omite en silencio:
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.
1. **proc connector y `taskstats`**: suscribirse al netlink pide `CAP_NET_ADMIN`; el uid de
la sesión es 1001. Experimento: un binario chico corriendo como root en la granja,
midiendo latencia de evento `fork/exec/exit` contra el sondeo de `/proc`.
2. **Copia con reflink** (T11): exige `mkfs` sobre un loop (root). Experimento: xfs y btrfs
en loop, `copy_file_range` de 64 MiB, esperado O(1) contra los 2 837 MB/s de ext4.
3. **Iterador BPF contra `/proc`** (T3): exige BTF y privilegios. Va detrás de H7.
4. **io_uring con `SQPOLL`**: exige `CAP_SYS_NICE`. Cambiaría el veredicto de T12 para el
caso «muchas operaciones que bloquean».
5. **Coste real de una tanda de hammer en syscalls**: sin `strace`/`perf` en esta máquina no
se puede atribuir. Experimento: instalarlos en la granja y contar `stat`/`open` por
invocación de compilador, para saber si T8 vale segundos o minutos por receta.
6. **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.
### 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
1. **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.
2. **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/` — cuatro programas en C
`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
@@ -377,3 +540,14 @@ 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.