Vazão por ciclo de CPU: o número que sobrevive ao contato com um roteador
Um número de laboratório e um número de hardware quase nunca concordam. Medimos os dois, mantivemos os intervalos, revelamos a armadilha que uma vez produziu um resultado errado, e dissemos claramente onde perdemos.
Publicado em 6 de setembro de 2026
Em dois roteadores físicos, movendo tráfego real por uma LAN gigabit real, nosso núcleo de túnel entregou 122 Mbit/s, enquanto a implementação de túnel do próprio kernel do roteador entregou 66.8 Mbit/s — no mesmo remetente, com menos CPU por Mbit. Nenhuma das duas cifras conta a história inteira sozinha: os dois lados tiveram que usar geradores de tráfego diferentes, porque nenhum gerador único roda nos dois lados dessa comparação. Então a razão que vale a pena discutir é cada lado contra seu próprio teto sem túnel, que está na tabela abaixo e nos favorece ainda mais, não menos. Uma execução de laboratório com quatro VMs em um laptop mostra algo mais rápido em termos absolutos e, em um eixo, algo menos favorável. Confiamos mais no número do roteador, porque um roteador é o único dos dois por onde o tráfego de um cliente realmente passa.
Essa é a descoberta que vale a pena destacar logo de início, e também o motivo pelo qual este post tem duas seções de resultados em vez de uma: o laboratório e o hardware discordam sobre qual eixo importa, e disfarçar essa discordância custaria mais credibilidade do que qualquer um dos números ganha.
Leia isto antes dos números. Nosso plano de dados usa uma thread por padrão. O comparador que rodamos ao lado dele no laboratório usa quatro por padrão. Sem equalização, essa opção sozinha faz a vazão dele variar de ~789 a 1427 Mbit/s — uma diferença de padrões, não de planos de dados — e ela já produziu um número publicado errado aqui antes. Toda cifra de laboratório abaixo fixa os dois núcleos Rust em uma thread. As implementações em Go e no kernel naquela tabela rodaram com seus próprios padrões, que usam mais de uma — se algo, isso as favorece contra nossa thread única. A diferença do padrão de fábrica é mostrada separadamente, uma vez, para também não ficar escondida por omissão.
O que foi medido, e em quê
Dois ambientes independentes, porque um benchmark só é honesto dentro do ambiente em que rodou — um número do iperf3 e um número do bandwidth-test não são um único número usando dois nomes, e uma VM compartilhando os núcleos de um laptop não é um roteador.
Hardware — dois roteadores MikroTik, uma LAN gigabit, 2026-09-01. RB3011UiAS (ARMv7, 2×1400 MHz) como remetente, cAP ax (ARM64, 4×864 MHz) como receptor, RouterOS 7.24.1 em ambos. Nosso núcleo rodou como fv, em um contêiner RouterOS, em espaço de usuário, contra a própria implementação em kernel do protocolo WireGuard do roteador — o único comparador equivalente disponível nesse hardware, já que é a única alternativa que vem embutida no próprio RouterOS. Dois geradores, porque nenhum atravessa implementações de forma limpa: dd | nc para nossa execução em contêiner, o próprio bandwidth-test do RouterOS para a execução do kernel — cada um carrega seu próprio teto sem túnel como controle, para que um leitor possa validar o teto antes de confiar na razão.
Laboratório — quatro convidados Debian aarch64 sob QEMU/HVF em um host Apple silicon, 2026-08-31. Mesmos pares de chaves, endereços, portas, MTU e ativação de interface em todos os binários testados: nosso núcleo, boringtun 0.7 (o núcleo em espaço de usuário do qual o nosso foi derivado), wireguard-go (a implementação de referência do protocolo WireGuard, em espaço de usuário), e o próprio módulo do protocolo WireGuard do kernel Linux. Execuções de iperf3 de 15 segundos, três amostras cada, com os dois núcleos Rust fixados em uma thread de plano de dados pelo motivo explicado na nota acima.
task test:impls # lab: ours vs boringtun vs wireguard-go vs the kernel
task test:all # lab: single-hop, multi-hop, and the comparisonA execução no roteador ainda não é um alvo de task — é o fv iniciado manualmente em um contêiner RouterOS em cada caixa, contra o servidor de protocolo WireGuard embutido no RouterOS. A topologia completa e cada linha bruta ficam em nosso próprio docs/benchmarks/results.csv.
O número que importa: no roteador
| Medida | nosso núcleo (espaço de usuário) | túnel do próprio kernel do roteador |
|---|---|---|
| vazão | 122 Mbit/s | 66.8 Mbit/s |
| CPU, remetente | 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 |
| memória residente (remetente / receptor) | 3.0 / 3.1 MB | no kernel, não separável |
| gerador | dd | nc | bandwidth-test |
| teto sem túnel desse gerador | 187 Mbit/s | 220.5 Mbit/s |
| retido contra seu próprio teto | 65% | 30% |
Comprimento da barra ∝ Mbit/s, eixo 0–140. Mesmo remetente RB3011 nas duas execuções; ele é o teto de CPU o tempo todo, entre 65–79%, enquanto o cAP ax receptor fica ocioso perto de 20%.
Duas formas independentes de olhar concordam, o que é o que torna o resultado confiável em vez de escolhido a dedo: a vazão absoluta favorece nosso núcleo em 1.8×, e a vazão retida contra o próprio teto incontestado de cada gerador favorece em 2.2×. O que a tabela não diz: os dois geradores não são a mesma ferramenta, e é por isso que os dois tetos sem túnel são impressos em vez de uma única razão de destaque, e as duas cifras mudariam se o remetente estivesse recebendo tráfego de rede real em vez de gerá-lo localmente. Diferente da tabela de laboratório abaixo, estas são execuções únicas: as porcentagens entre parênteses são a faixa em que a CPU variou durante aquela execução, não uma dispersão entre repetições.
O laboratório: três núcleos em espaço de usuário, um kernel, threads equalizadas
| núcleo | tipo | TCP Mbit/s (faixa, n=3) | memória de pico | latência p99 |
|---|---|---|---|---|
| nosso, 1 thread | espaço de usuário, Rust | 1535 (1506–1554) | 3.5 MB | 0.94 ms |
| boringtun, 1 thread | espaço de usuário, Rust | 1427 (1346–1479) | 5.3 MB | 1.15 ms |
| wireguard-go, padrão | espaço de usuário, Go | 1228 (1221–1239) | 34.8 MB | 1.39 ms |
| kernel WireGuard, padrão | kernel, C | 1376 (1366–1386) | — (kernel, não separável) | 0.96 ms |
Como enviado de fábrica, sem equalização: nosso núcleo usa 1 thread por padrão e rodou a 1535 Mbit/s; o boringtun usa 4 threads por padrão e rodou a ~789 Mbit/s nessa configuração. Reportar apenas o número padrão-contra-padrão repetiria o mesmo erro na direção favorável. Os dois números são reais; só um deles isola o plano de dados da contagem de threads.
O custo de CPU é onde o laboratório justifica seu lugar neste post — é o único eixo que a tabela do roteador não consegue mostrar, porque o comparador no kernel de um roteador não te dá nenhum detalhamento de CPU para comparar.
Quanto menor, melhor. Eixo 0–2.5 s/Gbit, linear. Os dois segmentos se empilham da esquerda para a direita; a divisão é onde aparece a perda discutida na próxima seção.
Escala logarítmica, 1–50 MB, indicada porque não é o padrão honesto: em um eixo linear, os 34.8 MB do wireguard-go achatariam as outras duas barras a meros traços.
Onde não vencemos
Dois lugares, ambos explicados em vez de enterrados no CSV:
CPU em modo usuário, no laboratório. 0.347 s/Gbit contra o 0.290 do comparador Rust mais barato — a única coluna daquela tabela em que ele está à nossa frente. Nossa leitura é que isso foi comprado, não desperdiçado: nosso núcleo faz offload de checksum do TUN nas duas direções, o que move trabalho do kernel para a CPU em espaço de usuário, contabilizado como tempo de usuário — e a coluna de CPU de sistema, onde essa troca é paga de volta, está confortavelmente à frente (0.549 contra 0.787). O total de CPU ainda vence. A divisão usuário/sistema acima é mostrada especificamente para que essa troca seja visível em vez de apenas afirmada.
Tempo de ida e volta, no roteador. 1.22 ms contra os 0.92 ms do túnel de kernel — o único eixo de hardware em que o modo kernel vence de forma clara, pelo motivo comum de que nosso caminho custa um salto de veth do contêiner e uma passagem de agendamento em espaço de usuário que um caminho no kernel não paga. Curiosamente, o laboratório mostra a ordem oposta na latência p99 (0.94 ms contra 0.96 ms do kernel) — não tiramos nenhuma conclusão disso além de “ambientes diferentes, gargalos diferentes”, e dizemos isso em vez de escolher o número que mais favorece.
O que é novo e o que é herdado
Toda cifra acima carrega uma data, e as datas importam: a tabela do roteador é de 2026-09-01, a tabela do laboratório de 2026-08-31. Desde então, um salto coordenado de versão principal moveu as crates de criptografia subjacentes que nosso plano de dados chama a cada pacote — chacha20poly1305, blake2, curve25519-dalek — e ainda não repetimos a comparação completa de laboratório ou de roteador nessa árvore. Os números acima são reais e foram medidos com cuidado, mas têm seis dias no momento da publicação, em uma árvore anterior a esse salto. Estamos publicando-os com isso declarado em vez de em silêncio, e vamos datar uma nova execução quando tivermos uma. A cifra do roteador também não está fixada a um commit específico em nossos próprios registros — leia “2026-09-01” como “início de setembro”, não como algo ligado a uma mudança de otimização específica.
O que pudemos repetir hoje, nesta máquina, sem um roteador ou um laboratório de VMs, é a parte do caminho que o salto de criptografia tocou mais diretamente: o custo de encapsular e desencapsular, isolado de toda preocupação de socket, TUN e threading mencionada acima.
| operação, tamanho do pacote | nosso 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 |
Repetição em 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 amostras por ponto. Sem socket, sem TUN, sem threads — um handshake completo, depois encapsular/desencapsular em loop. Os resultados são mistos por direção e tamanho de pacote, todos dentro de alguns pontos percentuais em qualquer direção, que é exatamente o que esperaríamos: nosso núcleo derivou esse caminho do projeto upstream, e os dois agora estão na mesma geração de primitivas do RustCrypto. Esta não é uma alegação de vazão — ela diz que o caminho de criptografia derivado custa, ele próprio, aproximadamente o mesmo que o do upstream, dentro do ruído de medição, o que é o que torna confiáveis as vitórias de laboratório e roteador acima: elas vêm de outro lugar da pilha (agrupamento de syscalls, offload, threading), não de uma cifra mais veloz.
Reproduzindo
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 1A execução no roteador ainda é manual hoje: fv em um contêiner RouterOS em cada caixa, contra o próprio servidor de protocolo WireGuard do RouterOS.
Sendo direto sobre o que isso te dá: o arcabouço de testes e o results.csv bruto por trás de cada tabela aqui vivem em nossa árvore, que não é pública hoje. Leia os comandos acima como uma declaração exata do que executamos, não como algo que você pode clonar e repetir. A alegação menos dependente de nós é também a mais válida de se conferir — boringtun e wireguard-go são públicos, iperf3 é público, e a armadilha da contagem de threads no topo deste post (uma opção movendo um comparador entre aproximadamente 789 e 1427 Mbit/s em um único host) é reproduzível por qualquer pessoa com essas três ferramentas e nenhum código nosso no meio. Se isso se confirmar para você, as linhas equalizadas acima são as que valem a pena discutir. Se não se confirmar, queremos saber.