Files
takana/docs/25-tasas-del-kernel.md
T
SergioandClaude Opus 5 aeeaa6d07f SDD 25: las tasas del kernel medidas, y el MEMCG que falta en las recetas
16 tasas de Linux medidas en momento (KVM, 4 vCPU, bajo carga): piso de
syscall, /proc como API de texto, fork vs espacio de direcciones del padre,
enlazado dinámico, VFS, io_uring, THP, namespaces. Programas en C y salidas
crudas en docs/evidencia/tasas-kernel-2026-08-29/.

Lo que sale de medir y después verificar contra las recetas: los kernels de
hammer se construyen sin CONFIG_MEMCG (no está en x86_64_defconfig y no tiene
default y), y arje/sandokan escriben memory.max descartando el error. Un tope
de memoria pedido por una Card no se aplica y sólo deja un warn.

Tres resultados que contradicen el consejo de manual y quedan documentados:
THP 3x más lento que 4K con defrag=madvise, io_uring 2,8x más lento que pread
sobre tmpfs, y stat cuesta lo mismo en tmpfs que en ext4 (es tasa del VFS).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACUcwo9mZsE5ocYVE9npih
2026-08-29 13:35:55 +00:00

19 KiB
Raw Blame History

SDD 25 — Las tasas del kernel: qué cobra Linux, cuánto, y por dónde se esquiva

Escrito 2026-08-29. Nace de una pregunta del operador: «¿hay cómo crear módulos para kernel para optimizar cosas que son estándares y por eso no se cambian — por ejemplo una API más cercana del control de procesos sin que sandokan use /proc? Con tawasuyu ponerse de acuerdo para pasar por debajo de mecanismos pesados POSIX.»

/proc y sandokan eran el ejemplo, no el alcance. Lo que sigue es la lista entera de tasas que Linux cobra por compatibilidad histórica, medida en esta máquina, y para cada una: si se esquiva, con qué, y quién paga hoy.

El espejo de este documento para el lado tawasuyu es tawasuyu/HANDOFF-TASAS-KERNEL-DESDE-HAMMER.md (mismo patrón que ADR 0010 ↔ HANDOFF-arranque-grafo.md).

0. La respuesta corta

  1. Un módulo cargable no es una opción en hammer y no hace falta. Las tres recetas de kernel (linux-generic.toml:43, linux-metal.toml:104, linux-metal-dual.toml:51) configuran -d MODULES: kernel monolítico. linux.toml no lo apaga pero sólo compila bzImage, nunca make modules. Cualquier código de kernel propio en hammer es built-in, o sea un parche a la receta — que además es lo correcto para el proyecto, porque la fase configure es la identidad del artefacto (SDD 22) y así queda sellado y atestado junto al kernel.
  2. Casi todo lo que duele ya tiene salida en mainline y nadie la está usando. pidfd, cgroup v2 agregado, posix_spawn, getdents64, close_range, openat2. Cero código de kernel, ganancias de 3× a 17× medidas abajo.
  3. La medición encontró un bug cruzado que no se buscaba: los kernels de hammer se 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.

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

  • Máquina: momento, KVM sobre AMD EPYC-Rome, 4 vCPU, 7,7 GiB RAM, Linux 7.1.4, glibc/Artix. Store en /dev/sdb (ext4), árbol en /dev/sdc (ext4), /tmp tmpfs.
  • Mitigaciones activas: retpolines (spectre_v2), retbleed, SSBD por prctl. Sin PTI (AMD, meltdown: Not affected). En un Intel con PTI el piso de syscall sería peor.
  • La máquina estaba CARGADA: load 7,3 sobre 4 vCPU, un build de LLVM en vuelo, 773 MiB libres. ⇒ los absolutos son cotas superiores; la señal está en los cocientes dentro de una misma corrida, que es como está escrito todo lo de abajo.
  • Cada medida son 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.
  • Fuentes y salidas crudas: docs/evidencia/tasas-kernel-2026-08-29/.
  • Piso de referencia: una syscall nula (getppid) cuesta 78,7 ns. El vDSO (clock_gettime) cuesta 31,2 ns y por syscall 143,8 ns ⇒ cruzar al kernel son ~110 ns. Todo lo de abajo se lee en «syscalls equivalentes».

2. La lista

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

padre con … fork+_exit+wait posix_spawn+wait
0 MB tocados 109 µs 231 µs
64 MB 938 µs 234 µs
256 MB 3 949 µs 231 µs

59 ns por página de 4 KiB del padre, ida y vuelta. posix_spawn (que usa CLONE_VFORK|CLONE_VM) es plano: no mira el tamaño del padre. A 256 MB, 17× más barato — haciendo estrictamente más trabajo (además ejecuta un binario).

Es la tasa POSIX por excelencia: fork no se puede arreglar sin romper la semántica, y la salida está dentro del propio POSIX.

T2 · exec + enlazado dinámico

µs/exec
musl ESTÁTICO 77,4
glibc ESTÁTICO 232,7
glibc DINÁMICO 324,3

ld.so cuesta +92 µs (+39%). Pero el salto grande es glibc→musl: 4,2× entre el peor y el mejor, con el mismo main(){return 0;}. El corpus de hammer ya está del lado bueno (verificado: los binarios del store son statically linked); esto cuantifica lo que la de-Alpinización compró: un build que lanza 20 000 procesos se ahorra ~5 s sólo en arranque.

T3 · /proc es una API de TEXTO, y se paga como tal

ns = syscalls
getrusage(RUSAGE_SELF) 238 3
clock_gettime(PROCESS_CPUTIME) 214 3
pread+parse /proc/self/stat (fd ya abierto) 1 271 16
open+read+parse /proc/self/stat 2 692 34
open+read+parse /proc/self/status (VmRSS) 4 643 59

Leer el CPU propio por /proc cuesta 11× más que getrusage. Y la mitad del coste es abrir la ruta, no el texto (1 271 vs 2 692 con el fd cacheado).

Barrido completo (291 procesos vivos): readdir solo 106 µs; readdir + stat de cada pid 991 µs, o sea 3,4 µs por proceso.

T4 · Contabilidad por unidad: cgroup v2 es O(1) donde /proc es O(procesos)

Leer cpu.stat + memory.current + pids.current de un cgroup: 4,6 µs, y no depende de cuántos procesos tenga la unidad. Sumar /proc para esa misma unidad: 3,4 µs × N. El cruce está en N = 2. Para una unidad de 20 procesos, 15× a favor del cgroup.

T5 · Liveness por /proc/<pid>: la carrera, no la lentitud

ns
access("/proc/<pid>") — lo que se usa hoy 345
kill(pid, 0) 119
pidfd_send_signal(fd, 0) 119

pidfd no es más rápido que kill(0); es 2,9× más rápido que la ruta /proc. Su valor real es otro: no hay reuso de PID, y el fd se vuelve legible al morir el proceso ⇒ entra en el epoll y el supervisor deja de sondear.

T6 · Señales: la notificación más cara de POSIX

kill(self,SIGUSR1) + handler = 1 165 ns = 15 syscalls. Un eventfd en el bucle de eventos hace el mismo trabajo por una fracción.

T7 · Despertar cuesta µs; sincronizar en usuario cuesta ns

ns
pthread_mutex lock+unlock sin disputa 8,6
RTT eventfd (2 hilos, mismo cpu) 2 507
RTT pipe 2 956
RTT socketpair AF_UNIX 3 639

291× entre sincronizar en espacio de usuario y despertar por el kernel. El bus de arje (postcard sobre socket Unix) paga ~3,6 µs por ida y vuelta: aceptable por mensaje, ruinoso por campo.

Adyacente: sched_yield 174 ns; nanosleep(1 ms) se pasa 65 µs bajo carga ⇒ los lazos de control no se construyen sobre sleep.

T8 · stat() es tasa del VFS, no del filesystem

ns/fichero ext4 tmpfs
stat() por ruta completa 648 714
fstatat(dirfd, nombre) 439 489
statx(…, DONT_SYNC) 467 593
openat+close 899 961
getdents64 (barrido, sin stat) 168 59

ext4 ≈ tmpfs en stat. ⇒ una tormenta de stat no se arregla cambiando de filesystem; se arregla no haciéndola: getdents64 da el barrido 3,9× más barato en ext4 y 12× en tmpfs, y fstatat relativo a un dirfd ahorra 32% sobre la ruta completa.

Profundidad de ruta: 12 componentes extra cuestan +205 ns en ext4 y +203 en tmpfs ⇒ ~17 ns por componente, idéntico en ambos. Otra vez: el VFS, no el disco.

T9 · openat2 con RESOLVE_BENEATH es casi gratis

ext4: 1 025 vs 899 ns (+14%). tmpfs: 763 vs 961 ns (midió más rápido, o sea dentro del ruido). ⇒ la resolución hermética de rutas no tiene precio apreciable. Es material directo para harkaq (SDD 16): la garantía «no se sale del árbol» sin demonio auxiliar y sin una syscall extra.

2 000 ficheros de 1 byte ext4 tmpfs
link() 9,2 µs 2,9 µs
open+read+write+close 142,2 µs 5,0 µs
cociente 15,4× 1,7×

Lo que hace cara la copia es el journal de ext4, no el VFS. La estrategia de hardlinks de .dmerge está bien elegida — y su contrapartida ya está documentada (la caché retiene lo que se borra del store).

T11 · copy_file_range/sendfile no ganan sobre ext4

64 MiB en caché: read+write 2 837 MB/s · sendfile 3 345 · copy_file_range 3 521. ~20% en el mejor caso. ext4 no tiene reflink, así que copy_file_range cae a una copia dentro del kernel. El «copiar en O(1)» que la gente espera exige un FS con reflink (xfs/btrfs) — sin medir acá, §9.

T12 · io_uring no es una ganancia universal

ns/op
syscall nula 78,7
io_uring NOP en lote (piso de operación) 47,7
pread 4K en caché (ext4) 540
io_uring read 4K profundidad 32 (ext4) 594
pread 4K (tmpfs) 602
io_uring read 4K (tmpfs) 1 672

El piso por operación es 0,61× una syscall: en lote, io_uring amortiza el cruce. Pero sobre datos ya cacheados pierde — 10% en ext4 y 2,8× en tmpfs. io_uring paga cuando las operaciones son muchas, agrupables y de verdad bloquean. Sobre caché es una pesimización.

T13 · THP puede ser 3× PEOR (y acá lo es)

64 MiB anónimos, ns por página de 4 KiB: MADV_HUGEPAGE 3 046 · MADV_NOHUGEPAGE 1 013 · MAP_POPULATE 752.

La causa está en la máquina, no en el kernel: /sys/kernel/mm/transparent_hugepage/defrag = madvise ⇒ una región con MADV_HUGEPAGE dispara compactación síncrona en el fallo de página, y con 773 MiB libres eso es caro. THP bajo presión de memoria es un pasivo de latencia, no una optimización. Reproducido en las dos corridas independientes (128 MiB y 64 MiB).

mmap+munmap de 4K: 1 664 ns.

T14 · El sandbox por namespaces cuesta ~1 ms por proceso

fork+exec base 1 649 µs · con unshare de 6 namespaces 2 742 µs · con USER|MNT 2 050 µs. ⇒ +1,09 ms por el juego completo, +0,40 ms por USER|MNT.

Para hammer es ruido por fase de build (segundos a minutos). Sería caro sólo si se enjaulara por-exec: 20 000 execs × 1,09 ms = 22 s.

T15 · Cerrar descriptores antes del exec

dup+close uno a uno: 0,2 µs/fd ⇒ 512 fds = 102 µs. close_range(lo,hi): por debajo del ruido. Gratis, y ya está en mainline.

T16 · Hilo vs proceso

pthread_create+join = 11,9 µs, contra 109 µs de fork+_exit con el padre vacío: 9×, y la distancia crece con el tamaño del padre (T1).

3. Las tres 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. El hallazgo cruzado: los kernels de hammer no tienen MEMCG

No lo buscaba; salió de verificar la recomendación antes de escribirla.

Lado hammer. Las recetas hacen make ARCH=x86_64 defconfig y después scripts/config. Lo que arch/x86/configs/x86_64_defconfig trae de contabilidad: CONFIG_TASKSTATS=y, TASK_DELAY_ACCT=y, CGROUPS=y, CGROUP_SCHED=y, CGROUP_PIDS=y, CGROUP_FREEZER=y, CONNECTOR=y ⇒ y PROC_EVENTS es default y con depends on CONNECTOR=y, así que también entra.

CONFIG_MEMCG no está en el defconfig y no tiene default y ⇒ queda en n en todos los kernels de hammer. CONFIG_PSI, igual.

Lado tawasuyu. sandokan-core::Engine expone set_memory_max / set_memory_high (shared/sandokan/sandokan-core/src/engine.rs:60-72), sandokan-local los delega a arje_incarnate::cgroup (03_ukupacha/sandokan/sandokan-local/src/lib.rs:675-681), y ahí:

// arje/init/arje-incarnate/src/cgroup.rs:60
let _ = std::fs::write(abs.join("cpu.weight"), format!("{w}\n"));   // error DESCARTADO
// :79
match std::fs::write(&path, format!("{mem}\n")) {
    Err(e) => tracing::warn!(..., "memory.max write failed"),        // sólo un warn

Consecuencia. Sobre un sistema hammer, una Card que pide un tope de memoria arranca sin tope, y la única huella es una línea de log. Es exactamente la forma de fallo que CLAUDE.md §3 nombra: un ausente falla ruidosamente; un vacío llega hasta el final diciendo que todo fue bien. La jaula que no está se comporta igual que la jaula que sí está, hasta que hace falta.

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

5. Qué se esquiva SIN escribir kernel

Tasa Reemplazo mainline Ganancia medida
T1 fork de padre grande posix_spawn / CLONE_VFORK 17× a 256 MB, y plano
T2 ld.so binario estático musl 4,2×
T3 /proc texto getrusage, taskstats 11×
T4 sumar /proc por unidad agregado de cgroup v2 O(1) vs O(N), cruce en N=2
T5 liveness por /proc pidfd (+ sin carrera de PID) 2,9× y correctitud
T3 enumerar procesos proc connector (netlink): eventos push deja de sondear
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
T15 cerrar fds close_range 102 µs → ~0

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

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

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

  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 hammer deja de correr ahí. ⇒ el núcleo se queda portable siempre; lo rápido es un backend opcional detectado en runtime, con /proc como respaldo que no se borra.

7. Propuestas — lado hammer

  • H1 · -e MEMCG -e PSI en las cuatro recetas de kernel. Cierra §4 y habilita memory.current (T4) y las señales de presión. Precio declarado: re-hashea todos los artefactos de kernel ⇒ hacerlo en la misma tanda que cualquier otro cambio de kernel, nunca suelto.
  • H2 · MODULES=n se queda. Si algún día hace falta código de kernel, es built-in y parcheado en la receta. Queda escrito para no rediscutirlo.
  • H3 · harkaq usa openat2 RESOLVE_BENEATH|RESOLVE_NO_SYMLINKS donde hoy valida rutas a mano (T9, ~gratis).
  • H4 · Auditar los Command de hammer que ponen pre_exec: Rust apaga el camino posix_spawn en cuanto hay pre_exec, y cae a fork+exec (T1). hammer-build/sandbox.rs lanza bwrap sin pre_exec ⇒ está bien hoy; el punto es que sea una regla, no una suerte.
  • H5 · La materialización de dependencias se queda en hardlink (T10) y el experimento de reflink (§9) alimenta SDD 18 (wawafs), donde el store como FS vivo cambiaría el cociente.
  • H6 · No madvise(MADV_HUGEPAGE) en el camino de build mientras defrag=madvise y la granja corra apretada de RAM (T13).
  • H7 · DEBUG_INFO_BTF sigue apagado hasta que haya un consumidor real de BPF; cuando lo haya, la deuda es pahole en el lab y un re-hasheo, no un rediseño.

8. Propuestas — lado tawasuyu

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

  • W1 · pidfd para liveness y señales en sandokan-local (hoy access("/proc/<pid>") y waitpid(WNOHANG) en sondeo). Se gana correctitud (carrera de PID) antes que velocidad.
  • W2 · Telemetría por unidad desde el cgroup, no sumando /proc (T4). Con sonda de capacidad: si memory.current no existe (kernel sin MEMCG), decirlo, no callarlo.
  • W3 · arje-incarnate con posix_spawn donde el pre_exec no sea imprescindible (T1), y close_range en el que quede.
  • W4 · Los errores de cgroup dejan de descartarse. let _ = write(...) y warn! sobre un límite no aplicado son el bug de §4, no un detalle de estilo.
  • W5 · /proc no se borra nunca: es el respaldo portable que hace posible SDD-SERVIDOR-AJENO.
  • W6 · El contrato entre repos es el trait Engine, no una ABI de kernel. hammer publica la lista de símbolos garantizados (verificable con hammer kernel probe); tawasuyu elige backend en runtime.

9. Lo que NO pude medir (experimentos nombrados)

Ninguno se omite en silencio:

  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.

10. Evidencia

docs/evidencia/tasas-kernel-2026-08-29/ — 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.