O Custo Oculto de "Quão Diferente é Esse Título?"
Toda vez que você abre a Netflix, a página inicial é personalizada por um serviço chamado Ranker. Uma das features mais caras é a pontuação de serendipidade — uma pergunta simples: "Quão diferente é esse novo título do que você tem assistido?" Essa única feature consumia 7,5% do CPU total de cada nó.
Na escala da Netflix, isso é um custo operacional enorme. O time decidiu otimizá-la, e a jornada — de loops ingênuos a batching, buffers planos e finalmente a Vector API do JDK — é uma aula de engenharia de sistemas aplicada.
Insight: Como a Meta Escalou o FFmpeg para Processar Bilhões de Vídeos por Dia
O Ponto Quente: Loops Aninhados e Má Localidade de Cache
A implementação original era direta, mas cara:
for (Video candidate : candidates) {
Vector c = embedding(candidate);
double maxSim = -1.0;
for (Video h : history) {
Vector v = embedding(h);
double sim = cosine(c, v);
maxSim = Math.max(maxSim, sim);
}
double serendipity = 1.0 - maxSim;
emitFeature(candidate, serendipity);
}
Isso gera O(M×N) produtos escalares separados — um para cada par candidato-histórico. Cada chamada busca um embedding, faz um produto escalar escalar e armazena o resultado. O padrão de acesso à memória é espalhado, causando má localidade de cache. Um flamegraph confirmou que esse era o ponto mais quente.

Passo 1: Batching – De Loops Aninhados a Multiplicação de Matrizes
O primeiro insight: tratar o problema como uma única multiplicação de matrizes. Se D é a dimensão do embedding:
- Empacotar todos os embeddings dos candidatos na matriz A (M×D)
- Empacotar todos os embeddings do histórico na matriz B (N×D)
- Normalizar as linhas para comprimento unitário
- Calcular C = A × Bᵀ (similaridades de cosseno M×N)
// Construir matrizes
double[][] A = new double[M][D]; // candidatos
double[][] B = new double[N][D]; // histórico
for (int i = 0; i < M; i++) {
A[i] = embedding(candidates[i]).toArray();
}
for (int j = 0; j < N; j++) {
B[j] = embedding(history[j]).toArray();
}
// Normalizar linhas para vetores unitários
normalizeRows(A);
normalizeRows(B);
// Calcular C = A * B^T
double[][] C = matmul(A, B);
// Derivar serendipidade
for (int i = 0; i < M; i++) {
double maxSim = max(C[i][0..N-1]);
double serendipity = 1.0 - maxSim;
emitFeature(candidates[i], serendipity);
}
Isso transforma O(M×N) produtos escalares separados em uma única multiplicação de matrizes — exatamente para o que as CPUs são otimizadas. Mas a primeira implementação causou uma regressão de 5%. Por quê?

Passo 2: Quando Batching Não é Suficiente – O Layout de Memória Importa
O problema não era o algoritmo. Eram os detalhes de implementação:
double[][]é memória não contígua → ponteiros extras, comportamento de cache ruim- Grandes alocações por requisição → pressão no GC
- Multiplicação de matrizes Java escalar → sem SIMD
Lição: Melhorias algorítmicas não importam se o layout de memória e a estratégia de alocação trabalham contra você.
Passo 3: Buffers Planos e Reuso com ThreadLocal
Eles reformularam o layout de dados para buffers planos double[] em ordem row-major, e usaram ThreadLocal para reutilizar buffers entre requisições:
class BufferHolder {
double[] candidatesFlat = new double[0];
double[] historyFlat = new double[0];
double[] getCandidatesFlat(int required) {
if (candidatesFlat.length < required) {
candidatesFlat = new double[required];
}
return candidatesFlat;
}
double[] getHistoryFlat(int required) {
if (historyFlat.length < required) {
historyFlat = new double[required];
}
return historyFlat;
}
}
private static final ThreadLocal<BufferHolder> threadBuffers =
ThreadLocal.withInitial(BufferHolder::new);
Isso eliminou alocações por requisição e melhorou a localidade de cache.
Passo 4: BLAS – Ótimo em Testes, Nem Tanto em Produção
Eles tentaram BLAS (Basic Linear Algebra Subprograms). Microbenchmarks mostraram resultados promissores, mas em produção:
- O
netlib-javapadrão usava F2J (Fortran-to-Java) BLAS, não nativo - Transições JNI adicionaram overhead
- Row-major do Java vs column-major do BLAS exigiam conversões e buffers temporários
Resultado: Os ganhos não se materializaram.
Passo 5: Vector API do JDK – SIMD Puro em Java
A peça final: substituir BLAS por uma implementação SIMD pura em Java usando a Vector API do JDK (incubando). Isso permite escrever operações de dados paralelos que o JIT mapeia para instruções SSE/AVX2/AVX-512 — sem JNI, sem dependências nativas.
// Loop interno com Vector API (simplificado)
for (int i = 0; i < M; i++) {
for (int j = 0; j < N; j++) {
DoubleVector acc = DoubleVector.zero(SPECIES);
int k = 0;
for (; k + SPECIES.length() <= D; k += SPECIES.length()) {
DoubleVector a = DoubleVector.fromArray(SPECIES, candidatesFlat, i*D + k);
DoubleVector b = DoubleVector.fromArray(SPECIES, historyFlat, j*D + k);
acc = a.fma(b, acc); // multiply-add fundido
}
double dot = acc.reduceLanes(VectorOperators.ADD);
// tratar cauda k..D-1
similaritiesFlat[i*N + j] = dot;
}
}
No carregamento da classe, uma fábrica seleciona a melhor implementação:
- Vector API se disponível (precisa de
--add-modules=jdk.incubator.vector) - Caso contrário, uma implementação escalar altamente otimizada (inspirada no Lucene)
Resultados em Produção
| Métrica | Antes | Depois | Melhoria |
|---|---|---|---|
| CPU (feature) | 7,5% | ~1% | -87% |
| CPU/RPS | baseline | -10% | -10% |
| Latência média | baseline | -12% | -12% |
No nível de assembly, a mudança foi clara: de produtos escalares com loop desenrolado para multiplicação de matrizes vetorizada em AVX-512.
Limitações e Cuidados
- Vector API ainda está em incubação (requer flag de runtime). O caminho alternativo é essencial para segurança.
- Nem todas as cargas de trabalho se beneficiam. Essa otimização funciona porque o loop quente é dominado por um grande número de produtos escalares em buffers
double[]contíguos. - Benchmarking deve incluir contexto de produção. Microbenchmarks para BLAS pareciam ótimos, mas os ganhos reais dependiam do layout de memória e padrões de alocação.
Próximos Passos
Se você está considerando a Vector API para seu serviço:
- Profile primeiro – confirme que seu ponto quente é um loop de dados paralelos.
- Corrija o layout de memória antes de mexer nos kernels de computação. Buffers planos e reuso geralmente são 80% do ganho.
- Projete um fallback – a Vector API ainda não é estável em todas as versões da JVM.
- Meça no nível do sistema – CPU/RPS e latência, não apenas microbenchmarks.
Relacionado: StyleX – A Resposta do Meta para CSS em Escala e Por Que o Figma Adotou
Este artigo é baseado em um post do Netflix Tech Blog por Harshad Sane e o time de Performance Engineering.

Conclusão
Essa otimização não foi sobre encontrar a "biblioteca mais rápida". Foi sobre acertar os fundamentos:
- Forma algorítmica – batching transformou O(M×N) produtos escalares em uma única multiplicação de matrizes
- Layout de memória – buffers planos e reuso com ThreadLocal eliminaram pressão do GC e melhoraram localidade de cache
- Kernel de computação – a Vector API do JDK forneceu SIMD puro em Java sem overhead de JNI
Quando essas peças se alinharam, a Vector API se tornou uma escolha natural, entregando uma redução de 10% no footprint do cluster com código Java legível e de fácil manutenção.
Já experimentou a Vector API em um serviço real? Em quais cargas de trabalho ela ajudou (ou não)? Compartilhe sua experiência nos comentários.