Rendimiento por ciclo de CPU: la cifra que sobrevive al contacto con un router
Una cifra de laboratorio y una cifra de hardware casi nunca coinciden. Medimos las dos, conservamos los intervalos, revelamos la trampa que en su día produjo un resultado erróneo, y decimos con claridad dónde perdimos.
Publicado el 6 de septiembre de 2026
En dos routers físicos, moviendo tráfico real sobre una LAN de gigabit real, el núcleo de nuestro túnel alcanzó 122 Mbit/s donde la propia implementación de túnel del kernel del router alcanzó 66.8 Mbit/s — en el mismo emisor, con menos CPU por Mbit. Ninguna de las dos cifras cuenta toda la historia por sí sola: los dos brazos tuvieron que usar generadores de tráfico distintos, porque ningún generador único funciona en los dos lados de esa comparación. Así que la proporción que vale la pena discutir es cada brazo frente a su propio techo sin túnel, que aparece en la tabla de más abajo y nos favorece más, no menos. Una ejecución de laboratorio con cuatro VM en un portátil da una cifra más rápida en términos absolutos y, en un eje, algo menos favorable. Confiamos más en la cifra del router, porque un router es el único de los dos por el que realmente pasa el tráfico de un cliente.
Esa es la conclusión con la que conviene empezar, y también la razón por la que esta entrada tiene dos secciones de resultados en lugar de una: el laboratorio y el hardware no están de acuerdo en qué eje importa más, y disimular ese desacuerdo costaría más credibilidad de la que gana cualquiera de las dos cifras.
Lee esto antes de ver las cifras. Nuestro plano de datos usa un solo hilo por defecto. El comparador que ejecutamos junto a él en el laboratorio usa cuatro por defecto. Sin igualar esto, ese ajuste por sí solo hace que su rendimiento oscile entre ~789 y 1427 Mbit/s: una diferencia en los valores por defecto, no en los planos de datos, y ya ha producido aquí una cifra publicada incorrecta en una ocasión. Cada cifra de laboratorio de más abajo fija ambos núcleos en Rust a un solo hilo. Las implementaciones en Go y en el kernel de esa tabla se ejecutaron con sus propios valores por defecto, que usan más de uno — si acaso, eso las favorece frente a nuestro hilo único. La diferencia entre los valores por defecto de fábrica se muestra por separado, una sola vez, para que tampoco quede oculta por omisión.
Qué se midió, y sobre qué
Dos entornos independientes, porque una prueba de rendimiento solo es honesta dentro del entorno en
el que se ejecutó — una cifra de iperf3 y una cifra de bandwidth-test no son una misma cifra
con dos nombres, y una VM que comparte los núcleos de un portátil no es un router.
Hardware — dos routers MikroTik, una LAN de gigabit, 2026-09-01. RB3011UiAS (ARMv7,
2×1400 MHz) como emisor, cAP ax (ARM64, 4×864 MHz) como receptor, RouterOS 7.24.1 en ambos.
Nuestro núcleo se ejecutó como fv, en un contenedor de RouterOS, en espacio de usuario, frente a
la propia implementación del protocolo WireGuard en el kernel del router — el único comparador
equivalente disponible en ese hardware, ya que es la única alternativa que trae RouterOS de serie.
Dos generadores, porque ninguno cruza limpiamente entre implementaciones: dd | nc para nuestra
ejecución en contenedor, y el propio bandwidth-test de RouterOS para la ejecución del kernel —
cada uno aporta su propio techo sin túnel como control, para que quien lea pueda comprobar el techo
antes de confiar en la proporción.
Laboratorio — cuatro invitados Debian aarch64 bajo QEMU/HVF en un host con silicio Apple,
2026-08-31. Mismos pares de claves, direcciones, puertos, MTU y activación de interfaz en cada
binario probado: nuestro núcleo, boringtun 0.7 (el núcleo en espacio de usuario del que se
bifurcó el nuestro), wireguard-go (la implementación de referencia del protocolo WireGuard, en
espacio de usuario), y el propio módulo del protocolo WireGuard del kernel de Linux. Ejecuciones de
iperf3 de 15 segundos, tres muestras cada una, con ambos núcleos en Rust fijados a un solo hilo
de plano de datos por la razón indicada en el recuadro de arriba.
task test:impls # lab: ours vs boringtun vs wireguard-go vs the kernel
task test:all # lab: single-hop, multi-hop, and the comparisonLa ejecución en el router todavía no es un objetivo de task: es fv iniciado a mano en un
contenedor de RouterOS en cada equipo, frente al servidor del protocolo WireGuard integrado en
RouterOS. La topología completa y cada fila en bruto están en nuestro propio
docs/benchmarks/results.csv.
La cifra que importa: en el router
| Medida | nuestro núcleo (espacio de usuario) | el túnel del kernel del router |
|---|---|---|
| rendimiento | 122 Mbit/s | 66.8 Mbit/s |
| CPU, emisor | 65% (63–67) | 74% (70–79) |
| CPU, receptor | 18% (10–22) | 20% (18–21) |
| CPU por Mbit | 0.53 %/Mbit | 1.10 %/Mbit |
| RTT | 1.22 ms | 0.92 ms |
| memoria residente (emisor / receptor) | 3.0 / 3.1 MB | en el kernel, no separable |
| generador | dd | nc | bandwidth-test |
| techo sin túnel de ese generador | 187 Mbit/s | 220.5 Mbit/s |
| retenido frente a su propio techo | 65% | 30% |
La longitud de la barra ∝ Mbit/s, eje 0–140. Mismo emisor RB3011 en ambas ejecuciones; es el techo de CPU en todo momento, entre 65–79%, mientras el cAP ax receptor está casi inactivo, cerca del 20%.
Dos formas de plantearlo, independientes entre sí, coinciden, y eso es lo que hace el resultado creíble en lugar de escogido a conveniencia: el rendimiento absoluto favorece a nuestro núcleo 1.8×, y el rendimiento retenido frente al techo indiscutido de cada generador lo favorece 2.2×. Lo que la tabla no dice: los dos generadores no son la misma herramienta, por lo que se imprimen ambos techos sin túnel en lugar de una única proporción destacada, y ambas cifras cambiarían si el emisor estuviera recibiendo tráfico de red real en lugar de generarlo localmente. A diferencia de la tabla de laboratorio de más abajo, estas son ejecuciones únicas: los porcentajes entre paréntesis son el rango en el que osciló la CPU durante esa ejecución, no una dispersión entre repeticiones.
El laboratorio: tres núcleos en espacio de usuario, un kernel, hilos igualados
| núcleo | tipo | TCP Mbit/s (rango, n=3) | memoria máxima | latencia p99 |
|---|---|---|---|---|
| el nuestro, 1 hilo | espacio de usuario, Rust | 1535 (1506–1554) | 3.5 MB | 0.94 ms |
| boringtun, 1 hilo | espacio de usuario, Rust | 1427 (1346–1479) | 5.3 MB | 1.15 ms |
| wireguard-go, por defecto | espacio de usuario, Go | 1228 (1221–1239) | 34.8 MB | 1.39 ms |
| WireGuard en el kernel, por defecto | kernel, C | 1376 (1366–1386) | — (kernel, no separable) | 0.96 ms |
Tal como se distribuyen, sin igualar: nuestro núcleo usa 1 hilo por defecto y alcanzó 1535 Mbit/s; boringtun usa 4 hilos por defecto y alcanzó ~789 Mbit/s con ese ajuste. Informar solo de la cifra por-defecto-contra-por-defecto repetiría el mismo error, en la dirección favorable. Ambas cifras son reales; solo una de ellas aísla el plano de datos del número de hilos.
El coste de CPU es donde el laboratorio se gana su lugar en esta entrada: es el único eje que la tabla del router no puede mostrar, porque el comparador integrado en el kernel de un router no ofrece ningún desglose de CPU con el que comparar.
Cuanto más bajo, mejor. Eje 0–2.5 s/Gbit, lineal. Ambos segmentos se apilan de izquierda a derecha; la división es donde aparece la pérdida de la siguiente sección.
Escala logarítmica, 1–50 MB, indicada porque no es la opción honesta por defecto: en un eje lineal, los 34.8 MB de wireguard-go aplanarían las otras dos barras hasta dejarlas como líneas finas.
Dónde no ganamos
Dos sitios, ambos explicados en lugar de enterrados en el CSV:
CPU en modo usuario, en el laboratorio. 0.347 s/Gbit frente a los 0.290 del comparador en Rust más barato — la única columna de esa tabla donde nos supera. Nuestra lectura es que se compra, no se desperdicia: nuestro núcleo hace descarga (offload) del checksum de TUN en ambas direcciones, lo que traslada trabajo del kernel a la CPU en espacio de usuario, contabilizado como tiempo de usuario — y la columna de CPU de sistema, donde se recupera esa inversión, va cómodamente por delante (0.549 frente a 0.787). El total de CPU sigue ganando. El desglose usuario/sistema de arriba se muestra específicamente para que esta compensación sea visible en lugar de solo afirmada.
Tiempo de ida y vuelta, en el router. 1.22 ms frente a los 0.92 ms del túnel del kernel — el único eje de hardware donde el kernel gana claramente, por la razón ordinaria de que nuestra ruta paga un salto veth del contenedor y un turno de planificación en espacio de usuario que una ruta en el kernel no paga. Curiosamente, el laboratorio muestra el orden contrario en la latencia p99 (0.94 ms frente a los 0.96 ms del kernel) — no le damos más lectura que «entornos distintos, cuellos de botella distintos», y lo decimos así en lugar de escoger la cifra que más favorezca.
Qué es reciente y qué es heredado
Cada cifra de arriba lleva una fecha, y las fechas importan: la tabla del router es del 2026-09-01,
la del laboratorio del 2026-08-31. Desde entonces, una subida de versión mayor coordinada cambió
los crates de criptografía subyacentes que nuestro plano de datos llama en cada paquete —
chacha20poly1305, blake2, curve25519-dalek — y no hemos vuelto a ejecutar la comparación
completa de laboratorio ni de router sobre ese árbol. Las cifras de arriba son reales y se
midieron con cuidado, pero tienen seis días a fecha de publicación, sobre un árbol anterior a esa
subida. Las publicamos indicándolo, en lugar de en silencio, y pondremos fecha a una repetición
cuando la tengamos. La cifra del router tampoco está fijada a un commit concreto en nuestros
propios registros: lee «2026-09-01» como «principios de septiembre», no como ligada a un único
cambio de optimización.
Lo que sí podríamos volver a ejecutar hoy, en esta máquina, sin un router ni un laboratorio de VM, es la parte de la ruta que la subida de criptografía tocó más directamente: el coste de encapsular y desencapsular, aislado de cualquier consideración de sockets, TUN o hilos de las de arriba.
| operación, tamaño de paquete | nuestro núcleo | boringtun 0.7.1 |
|---|---|---|
| encapsular, 64 B | 219.2 ns | 218.7 ns |
| encapsular, 512 B | 436.4 ns | 442.5 ns |
| encapsular, 1420 B | 938.1 ns | 968.3 ns |
| desencapsular, 64 B | 238.8 ns | 226.7 ns |
| desencapsular, 512 B | 562.1 ns | 539.2 ns |
| desencapsular, 1420 B | 1099.5 ns | 1069.4 ns |
Repetido el 2026-09-05, Apple M3 Max,
cargo bench -p forestvpn-quantum --bench datapath_benches -- --measurement-time 3 --warm-up-time 1,
criterion 0.8.2, 100 muestras por punto. Sin sockets, sin TUN, sin hilos — un protocolo de enlace
completado, y luego encapsular/desencapsular en bucle. Los resultados están repartidos por
dirección y tamaño de paquete, todos dentro de unos pocos puntos porcentuales en cualquier sentido,
que es exactamente lo que cabría esperar: nuestro núcleo bifurcó esta ruta desde su origen, y
ambos están ahora en la misma generación de primitivas de RustCrypto. Esto no es una afirmación
de rendimiento — dice que la ruta de criptografía bifurcada cuesta aproximadamente lo mismo que
la del origen, dentro del ruido, que es lo que hace que las victorias de laboratorio y de router de
arriba merezcan confianza: vienen de otra parte de la pila (agrupación de llamadas al sistema,
descarga de trabajo, hilos), no de un cifrado más rápido.
Cómo reproducirlo
task test:impls # lab: ours vs boringtun vs wireguard-go vs the kernel
task test:all # lab: single-hop, multi-hop, and the multihop cost table
cargo bench -p forestvpn-quantum --bench datapath_benches -- --measurement-time 3 --warm-up-time 1Hoy la ejecución en el router es manual: fv en un contenedor de RouterOS en cada equipo, frente
al propio servidor del protocolo WireGuard de RouterOS.
Siendo sinceros sobre lo que eso te ofrece: el arnés de pruebas y el results.csv en bruto detrás
de cada tabla de aquí viven en nuestro árbol, que hoy no es público. Lee los comandos de arriba
como una declaración exacta de lo que ejecutamos, no como algo que puedas clonar y repetir. La
afirmación que menos depende de nosotros es también la que más merece la pena comprobar —
boringtun y wireguard-go son públicos, iperf3 es público, y la trampa del número de hilos al
principio de esta entrada (un solo ajuste que mueve a un comparador entre aproximadamente 789 y
1427 Mbit/s en un único host) es reproducible por cualquiera que tenga esos tres y nada de nuestro
código de por medio. Si se sostiene para ti, las filas igualadas de arriba son las que vale la pena
discutir. Si no se sostiene, queremos saberlo.