Netflix 用 JDK Vector API 优化推荐系统:一次教科书级的性能工程实录
- 约 13 分钟读完 - 全文 5099 字Netflix 用 JDK Vector API 优化推荐系统:一次教科书级的性能工程实录
原文:Optimizing Recommendation Systems with JDK’s Vector API 目标读者:写 Java 后端、关心 JVM 性能工程的工程师;也适合任何在“要不要引入原生库/SIMD”上纠结过的人。 阅读目标:看清一次完整性能优化的正确顺序——先改计算形状,再改数据布局,最后才轮到换计算内核。以及理解为什么 BLAS 这种“标准答案”在真实生产路径里会失效。
一、背景:Ranker 里的 7.5% 热点
Ranker 是 Netflix 最大、最复杂的服务之一,首页那些个性化推荐行就是它算出来的。看 CPU profile 时有一个特征始终扎眼:视频惊喜度打分(video serendipity scoring)——回答一个简单问题:“这部新片和你最近看的东西有多不一样?”这个特征吃掉每个节点约 7.5% 的总 CPU。
算法本身不复杂:候选视频和用户历史里的每个视频都表示成 embedding 向量;对每个候选,算出它与所有历史向量的余弦相似度,取最大值,用 1 减去它得到“新颖度”分数,作为下游推荐逻辑的输入特征。原始实现非常直白:对每个候选取 embedding,遍历历史逐对算余弦,维护最大值。容易理解,但在 Ranker 的规模下意味着大量串行工作、重复查找、分散的内存访问和糟糕的缓存局部性。火焰图确认了这一点:服务里最热的热点之一就是惊喜度编码器里的 Java 点积。算法层面,这是一个 M 个候选 × N 个历史的嵌套循环,产生 O(M×N) 次独立点积。
一个容易被忽略但决定了整个优化方向的数据:他们埋点统计了流量形状,发现约 98% 的请求是单视频请求,剩下 2% 是大批量请求——但那 2% 的批次实在太大,最终处理的视频总量在单视频和批处理之间大约是 50:50。所以批处理值得做,即使它对中位数请求毫无帮助。
二、第一步:把嵌套循环看成矩阵乘
第一个想法是换“计算形状”:不再想成“很多次小点积”,而是当成一次矩阵运算。设 embedding 维度为 D:
- 把所有候选 embedding 装进矩阵 A(形状 M×D);
- 把所有历史 embedding 装进矩阵 B(形状 N×D);
- 所有行归一化成单位向量;
- 计算
C = A × Bᵀ,C 就是 M×N 的余弦相似度矩阵。
这样 M×N 次独立点积变成一次矩阵乘——而这正是 CPU 和优化过的计算内核最擅长的东西。工程上通过同时支持 encode()(单视频)和 batchEncode()(批量)接入现有框架,保持向后兼容。到这里看起来像“做完了”。但并没有。
三、第二步:批处理反而慢了 5%
上了批处理版本跑金丝雀,结果出人意料:性能回退约 5%。算法没问题——把点积归并成矩阵乘在数学上是等价的。问题出在第一版实现自己引入的开销:
- 每个批次都新建
double[][]矩阵装候选、历史和结果。这些又大又短命的对象制造了 GC 压力;而且double[][]在内存里本身就不连续,多一层指针跳转,缓存行为更差。 - 第一版 Java 矩阵乘是朴素的标量实现,用不上 SIMD。换句话说,付了批处理的成本,却没拿到批处理该有的计算效率。
教训来得很快:算法层面的改进,如果实现细节——内存布局、分配策略、计算内核——跟你作对,就一文不值。这一步定下了后面的路线:先把数据布局改成缓存友好的,消灭每批次分配,然后再回头解决矩阵乘内核。
四、第三步:扁平 buffer 加 ThreadLocal 复用
重做数据布局。double[m][n] 换成行主序的扁平 double[]:内存连续,访问模式可预测。然后引入 ThreadLocal<BufferHolder>,持有候选、历史等可复用 buffer——buffer 只增长不收缩,避免每请求分配,同时每个线程持有自己的一份,无锁无争用。核心逻辑就几行:
class BufferHolder {
double[] candidatesFlat = new double[0];
double[] getCandidatesFlat(int required) {
if (candidatesFlat.length < required) {
candidatesFlat = new double[required];
}
return candidatesFlat;
}
// historyFlat 同理
}
光是这一步,批处理路径就变得可预测多了:分配少了,GC 压力小了,缓存局部性好了。剩下的问题回到最初的那个:矩阵乘到底用什么算?
五、第四步:BLAS——测试里很好,生产里不行
显而易见的下一步是 BLAS(Basic Linear Algebra Subprograms)。隔离的微基准看起来很有希望,但一旦接进真实的批量打分路径,收益就没兑现。几个因素在作对:
- 默认的
netlib-java路径走的是 F2J(Fortran 转 Java)的 BLAS,不是真正的原生实现; - 就算用原生 BLAS,也要付 setup 和 JNI 边界切换的开销;
- Java 的行主序和很多 BLAS 例程期望的列主序对不上,会引入转换和临时 buffer;
- 这些额外的分配和拷贝在完整流水线里很扎眼,尤其是旁边还有 TensorFlow 的 embedding 计算。
BLAS 不是白试——它帮忙看清了时间到底花在哪。但它不是那种即插即用的胜利。他们需要的是:纯 Java、贴合扁平 buffer 架构、还能用上 SIMD 的东西。
六、第五步:JDK Vector API
先补一课:JDK Vector API 到底是什么
起点是 SIMD。 现代 x86 CPU 都有向量寄存器,一条指令可以同时算多个数:AVX2 的 256 位寄存器一次装 4 个 double,AVX-512 的 512 位一次装 8 个。一条向量乘加指令顶 4-8 条标量指令——这就是 SIMD(Single Instruction, Multiple Data,单指令多数据),点积、矩阵乘这类规则的数值计算是它的主场。
Java 的痛点是:这些指令一直在硬件里,Java 程序员却够不着。 JIT 有自动向量化,但只对形状简单、能被它证明安全的循环生效,代码稍微复杂一点就悄悄退回标量,而且没有任何方式强制它发生;想要确定性的 SIMD,只能走 JNI 调原生库——代价就是上一步 BLAS 踩过的那些坑。
Vector API 是 Project Panama 的一部分,从 JDK 16 起以孵化模块 jdk.incubator.vector 的形式提供,到原文写作时仍在孵化(所以运行时要显式 --add-modules)。它把 SIMD 变成了普通 Java 代码:
VectorSpecies描述「一次算几个 lane」(lane 就是向量寄存器里的一个槽位),SPECIES_PREFERRED让运行时按当前 CPU 挑最优宽度;DoubleVector.fromArray(...)从数组加载一组 lane,fma(b, acc)做向量融合乘加,reduceLanes(ADD)把各 lane 归约成一个标量;- 同一份代码的可移植性由 JIT 兜底:编译时映射到宿主机器最好的指令集,AVX-512 机器跑 8 lane,AVX2 机器跑 4 lane,都不支持就退回标量。
flowchart LR
subgraph S["标量路径 一条指令算一个数"]
A1["a0 乘 b0"] --> R1["acc 逐个累加"]
A2["a1 乘 b1"] --> R1
A3["a2 乘 b2"] --> R1
A4["a3 乘 b3"] --> R1
end
subgraph V["SIMD 路径 AVX2 一条指令算四个数"]
B1["向量寄存器加载<br/>a0 a1 a2 a3"] --> F["一条 fma 指令"]
B2["向量寄存器加载<br/>b0 b1 b2 b3"] --> F
F --> R2["acc 四个 lane 同时累加"]
end
style F fill:#e8f5e9
一句话总结:它把「手写 intrinsics 的活」变成了「写普通循环的活」——你不用知道目标机器是 SSE 还是 AVX-512,JIT 替你选。对他们来说更关键的是:纯 Java,没有原生依赖,没有 JNI 切换,写起来就是普通 Java 代码。
跟他们的负载是绝配:embedding 已经搬进了扁平连续的 double[] buffer,热循环被海量点积占满。最后一步就是用基于 Vector API 的纯 Java SIMD 实现换掉 BLAS——藏在一个小工厂后面,类加载时由 MatMulFactory 选最优实现:jdk.incubator.vector 可用就用 Vector API,否则退回一个高度优化的标量实现——循环展开的点积(同事 Patrick Strawderman 实现,灵感来自 Lucene 里的模式)。
Vector API 版本的内层循环:用 fma()(fused multiply-add)把 a * b 累加进向量累加器;DoubleVector.SPECIES_PREFERRED 让运行时自己挑适合机器的 lane 宽度(AVX2 上通常 4 个 double,AVX-512 上 8 个)。简化版:
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); // 融合乘加
}
double dot = acc.reduceLanes(VectorOperators.ADD);
// 处理 k..D-1 的尾部
similaritiesFlat[i*N + j] = dot;
}
}
原来的一长串标量乘加,变成少量向量 fma() 加一次归约——算法没变,CPU 向量单元的利用率完全不同。
flowchart TD
subgraph OLD["原始路径 标量"]
O1["候选 embedding"] --> O3["逐个算余弦相似度"]
O2["历史 embedding 逐个取"] --> O3
O3 --> O4["M 乘 N 次独立点积<br/>分散内存访问 缓存不友好"]
end
subgraph NEW["优化后路径 SIMD"]
N1["候选装入扁平 buffer A<br/>M×D 行主序"] --> N3["归一化为单位向量"]
N2["历史装入扁平 buffer B<br/>N×D 行主序"] --> N3
N3 --> N4["一次矩阵乘 C = A × Bᵀ<br/>Vector API fma 累加"]
N4 --> N5["每行取最大值<br/>novelty = 1 - maxSim"]
end
O4 -.->|"重构计算形状"| N1
style O4 fill:#ffcccc
style N4 fill:#e8f5e9
七、回退与安全:孵化特性的生产姿势
Vector API 还在孵化,运行时要加 --add-modules=jdk.incubator.vector 才能用。他们不想让正确性和可用性绑在这个 flag 上,所以把回退行为显式设计出来:启动时探测 Vector API 支持,有就走 SIMD 批量矩阵乘,没有就走优化过的标量路径,单视频请求继续用逐条实现。运营故事很干净:服务可以选择启用 Vector API 拿满性能,不启用系统也照样安全可预测。
八、生产结果
完整方案(批处理 + 扁平 buffer + ThreadLocal 复用 + Vector API)在跑生产流量的金丝雀上测得:CPU 利用率下降约 7%,平均延迟下降约 12%;为了归一化吞吐差异还跟踪了 CPU/RPS(每 RPS 消耗的 CPU),改善约 10%——同样的流量可以少用约 10% 的 CPU,全量上线后数据保持住了。在函数级别,这个特征占的 CPU 从最初的 7.5% 降到约 1%。汇编层面的变化也很直白:从循环展开的标量点积,变成 AVX-512 硬件上的向量化矩阵乘。
工程实践联想
这篇文章值得读两遍:第一遍看技术,第二遍看顺序。沉淀下来的启示:
- 优化顺序是:计算形状 → 数据布局 → 分配策略 → 计算内核。 他们最后换的才是内核(Vector API),但前面三步缺任何一步,换内核都拿不到收益。那个 5% 的回退也说明:跳过布局直接批处理,等于同时引入新开销和新瓶颈。做性能优化先问“形状对不对”,再问“数据在内存里躺得对不对”,最后才轮到“用什么算”。
- 微基准通过不等于生产有效。 BLAS 在隔离测试里很有希望,一进真实路径就被 JNI、布局转换、临时分配吃光收益。任何库的 benchmark 都只对它自己的测试负责;决策必须在真实流量路径里做,最好带着上下游(他们旁边还跑着 TensorFlow 的 embedding 计算)一起测。
double[][]到扁平double[]是 Java 性能工程的经典动作。 二维数组是“数组的数组”,行指针分散,缓存局部性差;扁平数组行主序连续、访问可预测,配合 ThreadLocal buffer 只增不缩,每请求分配降成零——这套组合在 Lucene、Netty 里到处都是,值得变成肌肉记忆。- 流量形状决定优化对象。 98% 的请求是单视频,只看中位数的话批处理毫无价值;但那 2% 的大批量扛了一半总计算量。看 p50 优化和看总量优化是两个问题,先埋点搞清楚自己的流量长什么样。
- 孵化/预览特性的正确姿势:工厂加探测加回退。 不赌
--add-modules一定加得上,启动时探测、随时退回标量实现,“试用新 JDK 特性”就从一次豪赌变成一个配置项。这个模式可以复用到任何“新能力可用性不确定”的场景。
顺带一个私货:这篇文章最好的地方是诚实——它把“我们以为做完了结果慢了 5%”和“BLAS 没成”都写了出来。性能工程的真实过程就是这样:每一步的正确性都要靠金丝雀和生产数据确认,不存在一步到位。那些只写成功结果的优化文章,参考价值反而低。
本篇小结
- 问题:Netflix Ranker 的视频惊喜度打分(候选与历史 embedding 的余弦相似度取 max)占单节点 CPU 约 7.5%,热点是 O(M×N) 的逐对点积;流量形状 98% 单视频、2% 大批量,但计算总量 50:50。
- 路径:M×N 次点积重构成矩阵乘
C = A × Bᵀ(行归一化后即余弦相似度矩阵)→ 金丝雀回退约 5%(double[][]分配与 GC、非连续布局、标量内核)→ 扁平double[]行主序加ThreadLocal<BufferHolder>复用(buffer 只增不缩)→ BLAS 因 F2J 非原生、JNI 开销、行列主序不匹配而放弃 → 终解 JDK Vector API(fma()累加、SPECIES_PREFERRED自适应 lane 宽度、JIT 映射 SSE/AVX2/AVX-512)。 - 安全姿势:
MatMulFactory启动时探测,不可用则回退到 Lucene 风格的循环展开标量实现,不依赖--add-modules一定在场。 - 生产结果:CPU 降约 7%、平均延迟降约 12%、CPU/RPS 改善约 10%、该特征的函数级 CPU 占比从 7.5% 降到约 1%;汇编从标量循环展开点积变成 AVX-512 向量化矩阵乘。
- 核心结论:优化拼的不是“最快的库”,而是把计算形状、内存布局、分配策略这些基本功做对——这些就位之后,Vector API 才成为那个“顺其自然的选择”。
参考资料
- Optimizing Recommendation Systems with JDK’s Vector API — Netflix Technology Blog(原文,作者 Harshad Sane,2026-03-03)
- JEP 338: Vector API (Incubator) — OpenJDK(Vector API 的 JEP 入口,含 SPECIES、fma、lane 等概念定义;后续孵化迭代见 JEP 417/426/448/460)
- netlib-java — GitHub(文中提到的 Java BLAS 库,默认 F2J 路径的出处)
- Apache Lucene(回退路径标量点积实现的灵感来源,Lucene 在向量检索里大量使用循环展开和 Panama Vector API)