El Costo Oculto de "¿Qué Tan Diferente es Este Título?"
Cada vez que abres Netflix, la página principal se personaliza con un servicio llamado Ranker. Una de sus funciones más costosas es la puntuación de serendipia — una pregunta simple: "¿Qué tan diferente es este nuevo título de lo que has estado viendo?" Esa sola función consumía 7.5% del CPU total de cada nodo.
A la escala de Netflix, eso es un costo operativo enorme. El equipo se propuso optimizarla, y el viaje — desde loops ingenuos hasta batching, buffers planos y finalmente la Vector API de JDK — es una masterclass en ingeniería de sistemas aplicada.
Insight: Cómo Meta Escaló FFmpeg para Procesar Miles de Millones de Videos al Día
El Punto Caliente: Loops Anidados y Mala Localidad de Caché
La implementación original era directa pero costosa:
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);
}
Esto genera O(M×N) productos punto separados — uno por cada par candidato-historial. Cada llamada obtiene un embedding, hace un producto punto escalar y almacena el resultado. El patrón de acceso a memoria es disperso, causando mala localidad de caché. Un flamegraph confirmó que este era el punto más caliente.

Paso 1: Batching – De Loops Anidados a Multiplicación de Matrices
El primer insight: tratar el problema como una sola multiplicación de matrices. Si D es la dimensión del embedding:
- Empaquetar todos los embeddings de candidatos en la matriz A (M×D)
- Empaquetar todos los embeddings del historial en la matriz B (N×D)
- Normalizar las filas a longitud unitaria
- Calcular C = A × Bᵀ (similitudes de coseno M×N)
// Construir matrices
double[][] A = new double[M][D]; // candidatos
double[][] B = new double[N][D]; // historial
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 filas a vectores unitarios
normalizeRows(A);
normalizeRows(B);
// Calcular C = A * B^T
double[][] C = matmul(A, B);
// Derivar serendipia
for (int i = 0; i < M; i++) {
double maxSim = max(C[i][0..N-1]);
double serendipity = 1.0 - maxSim;
emitFeature(candidates[i], serendipity);
}
Esto convierte O(M×N) productos punto separados en una sola multiplicación de matrices — exactamente para lo que las CPUs están optimizadas. Pero la primera implementación causó una regresión del 5%. ¿Por qué?

Paso 2: Cuando el Batching No es Suficiente – El Layout de Memoria Importa
El problema no era el algoritmo. Eran los detalles de implementación:
double[][]es memoria no contigua → punteros extra, mal comportamiento de caché- Grandes asignaciones por solicitud → presión en el GC
- Multiplicación de matrices Java escalar → sin SIMD
Lección: Las mejoras algorítmicas no importan si el layout de memoria y la estrategia de asignación trabajan en tu contra.
Paso 3: Buffers Planos y Reúso con ThreadLocal
Rediseñaron el layout de datos a buffers planos double[] en orden row-major, y usaron ThreadLocal para reutilizar buffers entre solicitudes:
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);
Esto eliminó las asignaciones por solicitud y mejoró la localidad de caché.
Paso 4: BLAS – Genial en Pruebas, No Tanto en Producción
Probaron BLAS (Basic Linear Algebra Subprograms). Los microbenchmarks se veían prometedores, pero en producción:
- El
netlib-javapor defecto usaba F2J (Fortran-to-Java) BLAS, no nativo - Las transiciones JNI agregaban overhead
- Row-major de Java vs column-major de BLAS requerían conversiones y buffers temporales
Resultado: Las ganancias no se materializaron.
Paso 5: Vector API de JDK – SIMD Puro en Java
La pieza final: reemplazar BLAS con una implementación SIMD pura en Java usando la Vector API de JDK (en incubación). Esto permite escribir operaciones de datos paralelos que el JIT mapea a instrucciones SSE/AVX2/AVX-512 — sin JNI, sin dependencias nativas.
// Loop interno con 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);
// manejar cola k..D-1
similaritiesFlat[i*N + j] = dot;
}
}
Al cargar la clase, una fábrica selecciona la mejor implementación:
- Vector API si está disponible (necesita
--add-modules=jdk.incubator.vector) - De lo contrario, una implementación escalar altamente optimizada (inspirada en Lucene)
Resultados en Producción
| Métrica | Antes | Después | Mejora |
|---|---|---|---|
| CPU (función) | 7.5% | ~1% | -87% |
| CPU/RPS | baseline | -10% | -10% |
| Latencia promedio | baseline | -12% | -12% |
A nivel de ensamblador, el cambio fue claro: de productos escalares con loop desenrollado a multiplicación de matrices vectorizada en AVX-512.
Limitaciones y Precauciones
- Vector API aún está en incubación (requiere flag de runtime). El camino alternativo es esencial para seguridad.
- No todas las cargas de trabajo se benefician. Esta optimización funciona porque el loop caliente está dominado por una gran cantidad de productos punto en buffers
double[]contiguos. - El benchmarking debe incluir contexto de producción. Los microbenchmarks para BLAS se veían geniales, pero las ganancias reales dependían del layout de memoria y los patrones de asignación.
Próximos Pasos
Si estás considerando la Vector API para tu servicio:
- Haz profiling primero – confirma que tu punto caliente es un loop de datos paralelos.
- Corrige el layout de memoria antes de tocar los kernels de cómputo. Los buffers planos y el reúso suelen ser el 80% de la ganancia.
- Diseña un fallback – la Vector API aún no es estable en todas las versiones de la JVM.
- Mide a nivel de sistema – CPU/RPS y latencia, no solo microbenchmarks.
Relacionado: StyleX – La Respuesta de Meta para CSS a Escala y Por Qué Figma lo Adoptó
Este artículo está basado en un post del Netflix Tech Blog por Harshad Sane y el equipo de Performance Engineering.

Conclusión
Esta optimización no se trató de encontrar la "biblioteca más rápida". Se trató de acertar los fundamentos:
- Forma algorítmica – batching transformó O(M×N) productos punto en una sola multiplicación de matrices
- Layout de memoria – buffers planos y reúso con ThreadLocal eliminaron presión del GC y mejoraron localidad de caché
- Kernel de cómputo – la Vector API de JDK proporcionó SIMD puro en Java sin overhead de JNI
Cuando esas piezas se alinearon, la Vector API se convirtió en una elección natural, entregando una reducción del 10% en el footprint del clúster con código Java legible y fácil de mantener.
¿Ya probaste la Vector API en un servicio real? ¿En qué cargas de trabajo te ayudó (o no)? Comparte tu experiencia en los comentarios.