Optimizing Recommendation Systems with JDK’s Vector API (opens in new tab)
Netflix’s Ranker service used significant CPU for video serendipity scoring, which compares candidate-title embeddings with a member’s viewing history. The team reduced CPU usage by progressively replacing scalar dot products with batched computation, improving memory layout, reusing buffers, and investigating optimized matrix-multiplication libraries. The main lesson was that mathematical optimization alone is insufficient; allocation behavior, cache locality, SIMD support, and runtime overhead all matter.
The Serendipity Scoring Hotspot
- Each candidate title and history item is represented by a vector embedding.
- The service computes cosine similarity between every candidate and every history item.
- It selects the maximum similarity and converts it into a novelty score:
serendipity = 1.0 - maxSimilarity
- The original implementation performed
M × Nindividual dot products, creating:- Sequential computational work
- Repeated embedding lookups
- Scattered memory access
- Poor cache locality
- This logic consumed roughly 7.5% of CPU per Ranker node.
- Although 98% of requests contained one video, large batch requests represented about half of the total videos processed.
Batching Similarity Computations
- The team reorganized the calculation as matrix multiplication:
- Candidate embeddings form an
M × Dmatrix. - History embeddings form an
N × Dmatrix. - Rows are normalized to unit length.
- Similarities are computed as
C = A × Bᵀ.
- Candidate embeddings form an
- This replaces many separate dot products with one larger operation better suited to CPU-optimized kernels.
- The implementation added
batchEncode()while preserving the existingencode()path for single-video requests.
Why the First Batched Version Regressed
- Initial canary tests showed a 5% performance regression.
- The batched implementation created
double[][]arrays for candidates, history, and results on every request. - These allocations:
- Increased garbage-collection pressure
- Used non-contiguous memory
- Added pointer chasing and reduced cache efficiency
- The matrix multiplication itself was scalar Java code and did not exploit SIMD hardware.
- Batching therefore introduced overhead without delivering corresponding compute gains.
Flat Buffers and Thread-Local Reuse
- The team replaced multidimensional arrays with flat
double[]buffers in row-major order. - Contiguous storage improved predictability and cache locality.
- A
ThreadLocal<BufferHolder>was used to retain reusable candidate, history, and scratch buffers per thread. - Buffers grow when necessary but do not shrink, avoiding repeated allocations while preventing cross-thread contention.
- This reduced GC pressure and made batch performance more stable.
Evaluating BLAS
- BLAS appeared promising in isolated microbenchmarks but did not provide the expected production improvement.
- The default
netlib-javaconfiguration used F2J, a Java implementation rather than truly native BLAS. - Native BLAS introduced setup costs and JNI transition overhead.
- Java’s row-major data layout also created an impedance mismatch with common BLAS expectations.