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