Rinha de Backend 2026: do código à nota oficial
Sexto artigo da série. O Dockerfile multi-stage que embute os 3M de refs, o target-cpu que dá 16× i16 por instrução, um segfault que só existe sob emulação QEMU e a imagem que nasceu privada e derrubou a inscrição. O caminho que transformou o brute force em -6000 oficial.

Começando
No post anterior o brute force tirou último lugar: -6000, p99 cravado no teto, 12.689 timeouts — e 0 falso positivo, 0 falso negativo. O algoritmo classificava perfeito; só não cabia no relógio. Aquele post terminou com o número. Esse aqui é sobre como o número saiu: o que separa um binário Rust que roda na minha máquina de uma nota medida pela Engine oficial num Mac Mini de 2014.
Tem mais armadilha nesse caminho do que no algoritmo. Duas delas me custaram horas: um segfault que só existe quando você emula a arquitetura errada, e uma imagem Docker que subiu privada e derrubou a própria inscrição.
O Dockerfile não é detalhe de deploy — é parte do orçamento
A regra da Rinha proíbe código-fonte na branch submission. O que a Engine vê é um docker-compose.yml apontando para uma imagem publicada. Então a imagem precisa carregar tudo: binário, os 3 milhões de referências já preprocessadas, e nada além disso.
O Dockerfile é multi-stage, e cada estágio existe por um motivo de orçamento ou de tempo de build:
FROM rust:1.88-bookworm AS chef
RUN cargo install cargo-chef --locked
FROM chef AS planner
COPY Cargo.toml Cargo.lock ./
COPY .cargo .cargo
COPY src src
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json # só deps
COPY Cargo.toml Cargo.lock ./
COPY .cargo .cargo
COPY src src
RUN cargo build --release --locked --bin rinha_backend_2026 --bin preprocess
FROM builder AS preprocessor
COPY resources/references.json.gz resources/references.json.gz
RUN mkdir -p /out/data \
&& ./target/release/preprocess resources/references.json.gz /out/data
FROM gcr.io/distroless/cc-debian12:nonroot
COPY --from=builder /app/target/release/rinha_backend_2026 /app/api
COPY --from=preprocessor /out/data /data
ENV REFS_DATA_DIR=/data
HEALTHCHECK --interval=2s --timeout=1s --start-period=20s --retries=5 \
CMD ["/app/api", "--healthcheck"]
ENTRYPOINT ["/app/api"]
Três decisões embutidas aí:
cargo-chef separa deps do crate. Sem isso, mudar uma linha em src/ reconstrói axum, tokio e companhia do zero. Com o recipe.json, o estágio cook só compila dependências e fica cacheado entre builds — iteração em src/ recompila ~30 s em vez de minutos. Importa muito quando cada build roda sob emulação (já chego lá).
O preprocess roda no build, não no startup. O estágio preprocessor executa o binário de preprocess dentro da imagem, lê references.json.gz, e cospe data/refs.i16.bin + data/labels.bin + data/metadata.json para /data. O runtime distroless recebe esses arquivos prontos e nunca toca em JSON nem em gzip. Startup determinístico, sem descompressão, sem serde_json cobrando memória no boot.
Os artefatos são assados na imagem, não montados em volume (ADR-0001). Cada container faz mmap da sua cópia. A alternativa — volume compartilhado pro kernel deduplicar páginas no page cache entre os dois containers — economizaria ~84 MB, mas exige ou versionar o arquivo gigante em submission/ (proibido pela regra) ou um init service copiando dados no compose. Com 170 MB de orçamento por API e ~50 MB de folga medida no baseline, page sharing explícito não é load-bearing. Otimização adiada até a primeira medição oficial dizer que precisa.
O target-cpu que dá 16× i16 por instrução
O scan do brute force é um loop escalar que depende da auto-vetorização do LLVM. O default do Rust pra x86_64-unknown-linux-gnu é a baseline “x86-64” — só SSE2, registradores de 128 bits, 8 i16 por operação.
O hardware oficial é um Mac Mini Late 2014, i5-4278U (Haswell). Haswell tem AVX2. Então .cargo/config.toml fixa target-cpu=x86-64-v3, escopado só pro alvo linux-gnu amd64 (ADR-0002):
[target.x86_64-unknown-linux-gnu]
rustflags = ["-C", "target-cpu=x86-64-v3"]
x86-64-v3 é um nível público de microarquitetura (AVX2, FMA, BMI) — não amarra um chip específico. O loop interno passa a empacotar 16 i16 por instrução em registradores de 256 bits, com mla (multiply-add) vetorial. O build arm64 nativo, usado no cargo test do dia a dia, não é afetado.
O trade-off é honesto: a imagem não roda em CPU pré-Haswell (Sandy/Ivy Bridge) — dá SIGILL no startup. Aceitável porque o ambiente oficial é Haswell e x86-64-v3 virou o piso de distros Linux modernas. Se um avaliador rodar em hardware mais velho e reportar o SIGILL, é só baixar pra x86-64-v2 e republicar.
A guerra do segfault que só existe sob QEMU
Aqui começa a história de guerra que prometi no post 5.
Eu desenvolvo em arm64 (Apple Silicon). A imagem oficial é linux/amd64. Pra publicar, docker buildx build --platform linux/amd64 roda os estágios sob QEMU em modo TCG — emulação de instrução amd64 num host arm64.
O build compilava. O binário de preprocess rodava, lia os 3 milhões de records, escrevia os arquivos de saída — e então segfaultava. Consistentemente. Depois de já ter escrito tudo.
O eprintln! final aparecia, os arquivos estavam corretos no disco, e mesmo assim o processo morria com SIGSEGV. A pista: acontecia depois do output, na hora de devolver a memória. O preprocess monta um Vec<Record> de ~3 milhões de elementos. Quando esse vetor é dropado no fim do main, o free do glibc faz memcpy pesado — e a emulação AVX2 do QEMU TCG corrompe alguma coisa nesse caminho específico de memcpy sob target-cpu=x86-64-v3. Não é bug do meu código: é a interação entre AVX2 emulado, o alocador do glibc e o destrutor de um Vec gigante.
O fix (PR #9) é deselegante de propósito:
fn main() {
if let Err(e) = run(...) {
eprintln!("preprocess failed: {e}");
process::exit(1);
}
// Os arquivos já foram escritos e flushados acima. Pular a limpeza
// do destrutor evita o SIGSEGV no free do glibc sob AVX2 emulado
// pelo QEMU. Processo curto, só de build-time — seguro.
process::exit(0);
}
process::exit(0) mata o processo sem rodar destrutores. Os arquivos já foram flushados; não há nada pra limpar que importe. Num binário de serviço isso seria preguiça perigosa. Num binário de build-time que escreve arquivos e morre, é exatamente o certo: o SO recupera a memória de qualquer jeito, e eu não pago um segfault de emulação por uma limpeza que ninguém vai ver.
A imagem que nasceu privada e derrubou a inscrição
Segunda armadilha. Publiquei v0.4.0 no GHCR, atualizei a branch submission, abri a issue de prévia. A Engine tentou rodar e falhou no smoke-test da inscrição — não conseguiu puxar a imagem.
Pacotes do GHCR nascem privados por padrão. A Engine é um terceiro anônimo: ela faz docker pull sem credencial nenhuma. Imagem privada = pull negado = inscrição quebrada antes de qualquer benchmark.
A correção tem duas partes (PR #10). Tornar o pacote público na hora — e, pra não repetir o erro nos próximos slices, fixar no Dockerfile a label que linka a imagem ao repositório:
LABEL org.opencontainers.image.source=https://github.com/obrunogonzaga/rinha-backend-2026-rust
LABEL org.opencontainers.image.licenses=MIT
A image.source faz o GHCR associar o pacote ao repo e herdar a visibilidade dele. Sem isso, cada publicação seria uma chance de subir privado de novo e só descobrir quando a prévia falhasse.
O compose que a Engine realmente roda
A branch submission carrega o docker-compose.yml que dispara o benchmark. Os limites do regulamento (1 CPU / 350 MB somando tudo) viram orçamento explícito:
services:
nginx:
image: nginx:1.27-alpine
deploy: { resources: { limits: { cpus: "0.10", memory: "10MB" } } }
depends_on:
api1: { condition: service_healthy }
api2: { condition: service_healthy }
api1:
image: ghcr.io/obrunogonzaga/rinha-fraud-rust:v0.4.0
environment:
TOKIO_WORKER_THREADS: "1"
MALLOC_ARENA_MAX: "2"
REFS_DATA_DIR: "/data"
deploy: { resources: { limits: { cpus: "0.45", memory: "170MB" } } }
api2: # idêntico a api1
Cada decisão é orçamento: 0.10/10MB pro nginx (round-robin puro, sem lógica), 0.45/170MB por API. TOKIO_WORKER_THREADS=1 porque com 0.45 de CPU não há núcleo pra mais de uma worker thread — mais threads só pagam context switch. MALLOC_ARENA_MAX=2 limita a fragmentação de arena do glibc, que com muitas threads infla RSS sem dó. O condition: service_healthy segura o nginx até as duas APIs responderem /ready — o HEALTHCHECK do Dockerfile faz um GET cru via std::net::TcpStream, sem dependência extra na imagem distroless.
A nota
Imagem pública no GHCR, submission apontando pra v0.4.0, compose válido. Abri a issue rinha/test e a Engine rodou o test/test.js completo no Mac Mini oficial. Saiu o -6000 do post anterior — issue #4586.
O ciclo “do código à nota oficial” fechou. E ele revela uma coisa que o algoritmo sozinho não conta: metade do trabalho de uma submissão da Rinha não é a solução — é fazer a solução chegar inteira até a Engine. Multi-stage build, target-cpu certo, segfault de emulação, visibilidade de registry. Nenhum desses tem a ver com busca vetorial. Todos podem zerar sua nota.

Próximo
Agora a parte que a série inteira estava montando. O -6000 provou que o classificador está correto e que o problema é puramente o relógio. O próximo post ataca o relógio de frente: um VP-Tree exato que devolve byte a byte o mesmo resultado do brute force, mas sem escanear 3 milhões por request — e a poda que parecia exata e não era, até virar matemática inteira.
O gancho continua o mesmo: o algoritmo estava certo o tempo todo. Faltava caber no relógio. No próximo, ele cabe.
Código: github.com/obrunogonzaga/rinha-backend-2026-rust. Hotfix QEMU na PR #9, visibilidade GHCR na PR #10; submissão
v0.4.0medida na issue #4586 da Rinha.