· 8 min de leitura

Rinha de Backend 2026: a matriz que travou as decisões antes do código

Segundo artigo da série. Antes de escrever Rust, a conta de memória eliminou metade das opções: axum como HTTP, quantização i16 obrigatória, preprocess em build-time e brute force SIMD como baseline.

Capa do post 2 da série Rinha de Backend 2026: título 'Matriz de decisões antes do código' à esquerda e três cards verticais à direita comparando f32 (estoura), i16 (no limite) e u8 (folga ~90 MB)

Começando

No primeiro artigo eu falei do desenho — Rust, brute force como baseline, dataset preprocessado, mmap, dois containers, 350 MB. Era plano. E fechei prometendo que o próximo seria sobre vetorização. Não foi. Antes de transformar plano em código, eu sentei e fiz a conta de memória que decidiu metade do projeto.

A conta que matou metade das opções

3 milhões de vetores, 14 dimensões, f32 (4 bytes). Por instância:

3_000_000 * 14 * 4 = 168 MB

Duas instâncias da API rodando o mesmo dataset:

168 MB * 2 = 336 MB

O orçamento total (LB + 2 APIs + tudo) é 350 MB. Sobram 14 MB para nginx, runtime do tokio, parsing JSON, buffers, kernel. Não cabe.

A leitura ingênua é “compartilhar o dataset entre as APIs com mmap”. Funciona em teoria — o page cache do Linux dedupa páginas read-only entre processos. Em cgroup v2, o accounting de páginas mapeadas não é totalmente determinístico, e eu não quero que o sucesso da submissão dependa de quanto o kernel resolveu contar pra cada container. Mmap entra como margem, não como rede de segurança.

Conclusão: quantização não é otimização, é estrutural. Sem reduzir o tamanho de cada vetor, a topologia oficial não cabe.

Comparação de orçamento de memória entre f32, i16 e u8: f32 estoura 350 MB por 162 MB, i16 cabe sem folga, u8 deixa 90 MB de margem
Três cenários de quantização contra o teto de 350 MB. f32 não cabe; i16 cabe no limite; u8 deixa margem.

A matriz de trade-offs

Com a conta acima decidindo a fronteira, o resto virou comparação direta.

HTTP stack

OpçãoOverheadDXRisco
axum + tokiosub-msalta, é o defaultbaixo
actix-web~igualmédiabaixo
hyper puro~100µs a menosbaixa, muito boilerplatemédio
pingora / monoiomenornicho, manutenção pesadaalto

Decisão: axum. Se o profiling mostrar que overhead de framework está perto de 1ms no caminho quente, dá pra descer pra hyper puro depois. Começar em hyper por receio é trocar tempo de feature por economia que ainda não foi medida.

Quantização

EstratégiaRAM/instânciaAcuráciaComplexidade
f32 (sem quantizar)168 MBexatatrivial — mas não cabe
i1684 MBerro estimado ~0,3%média
u842 MBerro estimado ~1%média (precisa reservar um valor como sentinela)

Os números de erro são estimativa de literatura (k-NN com vetores normalizados), não medição. Vou medir contra os fixtures oficiais antes de fechar a quantização no Slice 5+.

i16 é o ponto de equilíbrio: 84 MB × 2 = 168 MB cabe com folga, perda de detecção esperada é pequena, sentinela -1 (do last_transaction: null) mapeia limpa pra -32768. u8 fica como upgrade futuro — como u8 é unsigned, a sentinela vira um valor reservado (255, por exemplo), o que custa um nível de quantização (~0,4%) e exige cuidado pra não colidir com vetores normalizados.

Preprocessamento — build-time vs entrypoint

A pergunta era: descomprimir e quantizar dentro do docker build ou no ENTRYPOINT toda vez que o container subir?

Build-time vence por motivos específicos da Rinha:

  • Prévias ilimitadas. O fluxo natural de iteração é abrir muitas issues rinha/test pra calibrar parâmetros. Cada restart com preprocess no entrypoint custa 5–15s antes do /ready abrir. Build-time = restart instantâneo.
  • Mac Mini Late 2014. É a casa oficial da Rinha. CPU fraca amplifica o custo de fazer parse JSON + quantização no boot.
  • Reprodutibilidade. O .bin quantizado vira parte do hash da imagem. Mesma imagem, mesma latência. Em runtime preprocess sobra variabilidade do parser.
  • Custo do contra: imagem fica ~80 MB maior. Pull acontece antes do teste começar, fora do scoring. Custo zero pro p99.

Busca

Estratégiap99 estimado (i16, scan completo)Acurácia
Brute force scalar8–15 msexata
Brute force SIMD (AVX2 no target oficial; NEON no Mac dev)3–6 msexata
VP-Tree (busca exata sublinear)1–3 msexata
HNSW (ANN)<1 ms~99%

Caminho: brute SIMD primeiro, medir, VP-Tree, medir, ANN só se ainda faltar p99. Cada degrau é uma decisão de trade-off com erro mensurado contra o baseline anterior — exatamente o tipo de comparação que dá ANN com FN sob controle.

O orçamento de memória que defendi

Com i16:

LB (nginx):      20 MB
API1:           165 MB  (84 MB vetores i16 + 3 MB labels + ~75 MB runtime)
API2:           165 MB
                -------
Total:          350 MB  (no limite)

Sem folga. Qualquer alocação extra inesperada explode a margem. Por isso o caminho quente da API precisa ser zero-alloc — buffers reusados, sem Vec por request, sem String no JSON parser. Tudo sobre &[u8]. Esse vai ser o primeiro lugar onde o clippy vai brigar comigo.

Slice 1: o endpoint que não faz nada

Com tudo decidido, escrevi o Slice 1 — axum 0.8 com GET /ready e POST /fraud-score retornando placeholder fixo. Sem vetorização, sem busca, nada de fraud logic. O ponto era validar o contrato HTTP e medir o piso do framework.

async fn ready() -> Json<ReadyResponse> {
    Json(ReadyResponse { ready: true })
}

async fn fraud_score(_: Json<serde_json::Value>) -> (StatusCode, Json<FraudScoreResponse>) {
    (StatusCode::OK, Json(FraudScoreResponse { approved: true, fraud_score: 0.0 }))
}

Smoke do k6 contra a binary release, em localhost:

http_req_duration: avg=226µs  med=273µs  p95=326µs  max=339µs
checks_succeeded:  100% (20/20)
http_req_failed:   0%

Sem nada acontecendo, o framework cobra ~250µs por request. A meta interna que estabeleci é p99 < 1ms — ali a fórmula de pontuação satura em +3000 e cada 10× de melhoria abaixo disso não rende mais nada. Com axum cobrando 250µs do framework, sobra ~750µs de orçamento pra parsing, vetorização e busca. O artigo anterior dizia que o problema real não era a API. Esse número confirma: tem espaço de sobra pra colocar lógica nele.

O que também é decisão técnica: o setup do repositório

A Rinha exige duas branches no repositório público: main com código-fonte e submission apenas com artefatos de deploy (docker-compose.yml, nginx.conf, info.json). A branch submission aponta pra uma imagem Docker já publicada num registry público.

Eu escolhi:

  • Branch submission órfã. Sem histórico compartilhado com main. Nada de código-fonte vazando.
  • Tag-per-slice no GHCR. Cada Slice que passa fim-a-fim vira ghcr.io/obrunogonzaga/rinha-fraud-rust:v0.X.Y. A submission aponta pra tag específica, não pra :latest.
  • Prévias amarradas a SHA. Cada execução da Rinha sabe exatamente qual imagem rodou. Reverter é trivial; comparar resultados entre versões é honesto.

A alternativa preguiçosa — montar a submission no fim, com :latest — fecharia o feedback loop. Não dá pra rodar prévia ilimitada se você não consegue voltar pra qualquer versão antiga.

Onde estou agora

PR aberto, Slice 1 mergeado, smoke passando, baseline registrado, repo público com main e submission no ar. Próximo: vetorização das 14 dimensões com fixtures dos exemplos oficiais, sem errar na hora UTC nem no last_transaction: null.

Sem a matriz de trade-offs antes do código, eu provavelmente teria começado pelo HTTP “perfeito” e batido na parede da memória depois. A ordem certa é fazer a conta primeiro, decidir o que cabe, depois codar — mesmo que isso signifique passar uma sessão inteira lendo regulamento e fazendo planilha.

A Rinha é um exercício de orçamento antes de ser um exercício de busca vetorial.