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.

Netflix server rack with CPU utilization heatmap showing optimization before and after Vector API Dev Environment Setup

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ê?

Matrix multiplication diagram with SIMD lanes processing double precision vectors Technical Structure Concept

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-java padrã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étricaAntesDepoisMelhoria
CPU (feature)7,5%~1%-87%
CPU/RPSbaseline-10%-10%
Latência médiabaseline-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:

  1. Profile primeiro – confirme que seu ponto quente é um loop de dados paralelos.
  2. Corrija o layout de memória antes de mexer nos kernels de computação. Buffers planos e reuso geralmente são 80% do ganho.
  3. Projete um fallback – a Vector API ainda não é estável em todas as versões da JVM.
  4. 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.

Cloud infrastructure diagram representing Netflix recommendation system cluster with reduced footprint Development Concept Image

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.

Este conteúdo foi elaborado com o auxílio de ferramentas de IA, com base em fontes confiáveis, e revisado pela nossa equipe editorial antes da publicação. Não substitui o aconselhamento de um profissional especializado.