Netflix用它省了10%的CPU:JDK Vector API实战

Netflix在2026年3月公开了一个优化案例:他们的推荐服务Ranker,有一个计算视频新鲜度的功能,需要对候选视频和用户历史观看记录做大量的余弦相似度计算。这个功能原来占了单节点7.5%的CPU,用JDK的Vector API重写计算内核后,降到了1%左右。整体CPU利用率下降约7%,平均延迟下降约12%。

这个优化的关键技术就是JDK Vector API。它让Java代码能直接利用CPU的SIMD指令,把原本逐个执行的浮点运算变成批量并行处理。

下面从原理到实战,把Vector API拆开讲一遍。两个完整的实战案例,代码可以直接拿去跑。

SIMD和Vector API

SIMD的全称是Single Instruction Multiple Data,一条指令处理多个数据。

传统的标量计算方式,4个浮点数相加需要4条加法指令,CPU依次执行。SIMD方式下,CPU用一条指令就能同时完成4个(甚至8个、16个)浮点数的加法。

SIMD vs 标量计算

现代CPU都支持SIMD指令集。Java过去也能利用SIMD,但完全依赖JIT编译器的自动向量化。JIT会尝试把简单的循环自动编译成SIMD指令,但这个过程不可控,稍微复杂一点的循环结构就可能放弃向量化。开发者写代码的时候也不知道自己的循环到底有没有被向量化,只能事后看汇编输出来确认。

Vector API改变了这个局面。它提供了一套Java API,让开发者在代码层面明确表达数据并行的意图。JIT编译器拿到这些API调用后,会把它们映射到当前CPU支持的最优SIMD指令上。

写一次代码,自动适配不同CPU的SIMD能力,不需要JNI,不需要写平台相关的代码。 这是Vector API相比其他方案(比如通过JNI调BLAS库)最大的优势。Netflix在尝试BLAS方案时就遇到了JNI开销和内存布局不匹配的问题,最终选择了Vector API。

Vector API的核心概念

Vector API有四个核心概念需要理解,搞清楚它们之间的关系,后面看和写相关的代码就容易理解了。

Vector API核心概念

Species(向量种类)

Species定义了一个向量的两个属性:元素类型和向量位宽。FloatVector.SPECIES_256表示一个256位宽的float向量,能装8个float(每个float占32位,256÷32=8)。DoubleVector.SPECIES_256表示一个256位宽的double向量,能装4个double(每个double占64位,256÷64=4)。

实际使用中,推荐用SPECIES_PREFERRED,它会自动选择当前CPU支持的最大向量宽度。在支持AVX2的机器上它等价于SPECIES_256,在支持AVX-512的机器上等价于SPECIES_512

Vector(向量)

向量是一组同类型数据的容器。一个FloatVector里装着多个float值,对这个向量做加法,里面所有的float值同时参与运算。Vector API里的向量是不可变的,每次运算都返回一个新的向量对象。

Lane(通道)

向量里每个元素占一个通道。SPECIES_256的FloatVector有8个通道(编号0到7),每个通道装一个float。向量运算是按通道对齐进行的:两个向量相加,通道0和通道0相加,通道1和通道1相加,以此类推。

Mask(掩码)

掩码用来控制哪些通道参与运算。一个典型的场景是处理数组尾部:如果数组长度不是向量通道数的整数倍,最后一批数据不够填满一个完整的向量,这时就用掩码标记哪些通道有效、哪些通道忽略。

基础用法

用一个数组逐元素相加的例子,看看Vector API代码长什么样。

标量版本:

css 复制代码
static void addScalar(float[] a, float[] b, float[] c) {
    for (int i = 0; i < a.length; i++) {
        c[i] = a[i] + b[i];
    }
}

Vector API版本:

css 复制代码
// 选择当前CPU支持的最优向量宽度
static final VectorSpecies<Float> SPECIES = FloatVector.SPECIES_PREFERRED;

static void addVector(float[] a, float[] b, float[] c) {
    int i = 0;
    // loopBound计算出能被向量宽度整除的最大索引
    int upperBound = SPECIES.loopBound(a.length);

    // 每次循环处理一个向量宽度的数据
    for (; i < upperBound; i += SPECIES.length()) {
        FloatVector va = FloatVector.fromArray(SPECIES, a, i);
        FloatVector vb = FloatVector.fromArray(SPECIES, b, i);
        va.add(vb).intoArray(c, i);
    }

    // 处理尾部不足一个向量宽度的数据
    for (; i < a.length; i++) {
        c[i] = a[i] + b[i];
    }
}

代码结构可以拆成三步来理解:

  1. 定义SpeciesFloatVector.SPECIES_PREFERRED让JVM自动选最优宽度。如果CPU支持AVX2,SPECIES.length()返回8,意味着每次循环处理8个float。
  2. 主循环fromArray从数组中加载一个向量宽度的数据,add执行向量加法(8个float同时相加),intoArray把结果写回数组。循环步长是SPECIES.length()(而不是1),每次跳过一整个向量的宽度。
  3. 尾部处理loopBound返回的是能被向量宽度整除的最大索引。比如数组长度是35,向量宽度是8,loopBound返回32。索引32到34这3个元素用普通标量循环处理。

也可以用掩码来处理尾部,不需要额外的标量循环:

css 复制代码
static void addVectorWithMask(float[] a, float[] b, float[] c) {
    int i = 0;
    for (; i < a.length; i += SPECIES.length()) {
        // mask自动处理数组边界,超出范围的通道不参与运算
        VectorMask<Float> mask = SPECIES.indexInRange(i, a.length);
        FloatVector va = FloatVector.fromArray(SPECIES, a, i, mask);
        FloatVector vb = FloatVector.fromArray(SPECIES, b, i, mask);
        va.add(vb).intoArray(c, i, mask);
    }
}

掩码版本代码更简洁,但在主循环部分会有额外的掩码检查开销。性能敏感的场景,建议用第一种方式:主循环不带掩码,尾部单独处理。

实战:向量相似度搜索

AI应用中最常见的一个需求:给定一个查询向量,在一批候选向量中找出最相似的TopK个。推荐系统、RAG检索、以图搜图,底层都是这个计算。Lucene和Elasticsearch的向量搜索功能,内部也在用Vector API加速这类运算。

向量相似度搜索流程

相似度计算的核心是余弦相似度。两个向量A和B的余弦相似度公式:

css 复制代码
cosine(A, B) = (A · B) / (|A| × |B|)

其中A · B是点积,|A||B|是各自的模长。

这个公式有三个计算密集的部分:点积(逐元素相乘再求和)、模长(逐元素平方再求和再开方)。向量维度通常是128、256、768甚至更高,数据量一大,计算量很可观。

标量版本的余弦相似度

css 复制代码
static float cosineSimilarityScalar(float[] a, float[] b) {
    float dot = 0f;
    float normA = 0f;
    float normB = 0f;
    for (int i = 0; i < a.length; i++) {
        dot += a[i] * b[i];
        normA += a[i] * a[i];
        normB += b[i] * b[i];
    }
    return dot / (float) (Math.sqrt(normA) * Math.sqrt(normB));
}

每次循环做3次乘法、3次加法,循环次数等于向量维度。对于768维的向量,一次相似度计算就是2304次乘法和2304次加法。如果候选向量有10万个,总计算量就是4.6亿次浮点运算。

Vector API版本的余弦相似度

ini 复制代码
static final VectorSpecies<Float> SPECIES = FloatVector.SPECIES_PREFERRED;

static float cosineSimilarityVector(float[] a, float[] b) {
    FloatVector sumDot = FloatVector.zero(SPECIES);
    FloatVector sumNormA = FloatVector.zero(SPECIES);
    FloatVector sumNormB = FloatVector.zero(SPECIES);

    int i = 0;
    int upperBound = SPECIES.loopBound(a.length);

    for (; i < upperBound; i += SPECIES.length()) {
        FloatVector va = FloatVector.fromArray(SPECIES, a, i);
        FloatVector vb = FloatVector.fromArray(SPECIES, b, i);
        // fma: fused multiply-add,计算va*vb+sumDot,一条指令完成乘加
        sumDot = va.fma(vb, sumDot);
        sumNormA = va.fma(va, sumNormA);
        sumNormB = vb.fma(vb, sumNormB);
    }

    // 水平归约:把向量内所有通道的值加起来
    float dot = sumDot.reduceLanes(VectorOperators.ADD);
    float normA = sumNormA.reduceLanes(VectorOperators.ADD);
    float normB = sumNormB.reduceLanes(VectorOperators.ADD);

    // 处理尾部
    for (; i < a.length; i++) {
        dot += a[i] * b[i];
        normA += a[i] * a[i];
        normB += b[i] * b[i];
    }

    return dot / (float) (Math.sqrt(normA) * Math.sqrt(normB));
}

和标量版本相比,有两个关键变化:

fma(融合乘加)va.fma(vb, sumDot)等价于va * vb + sumDot,但它用一条CPU指令完成乘法和加法两个操作,比先乘后加少一次舍入误差,精度更高,速度也更快。这和Netflix在他们的优化中使用的方式完全一致。

reduceLanes(水平归约) 。主循环结束后,sumDot向量里的8个通道(假设AVX2)分别累积了不同位置的部分和。reduceLanes(VectorOperators.ADD)把这8个部分和加起来,得到最终的点积结果。

在AVX2的机器上,主循环每次迭代处理8个float。768维的向量,标量版本循环768次,Vector API版本循环96次(768÷8),每次循环做3条fma指令。指令数从4608条降到288条。

构建向量搜索

有了快速的余弦相似度计算,构建一个向量搜索就是遍历所有候选向量、算相似度、取TopK:

ini 复制代码
static int[] searchTopK(float[][] vectors, float[] query, int k) {
    float[] scores = new float[vectors.length];
    for (int i = 0; i < vectors.length; i++) {
        scores[i] = cosineSimilarityVector(vectors[i], query);
    }

    // 用一个小顶堆取TopK
    // 这里用简单的排序代替,生产环境建议用堆
    Integer[] indices = new Integer[vectors.length];
    for (int i = 0; i < indices.length; i++) {
        indices[i] = i;
    }
    Arrays.sort(indices, (a, b) -> Float.compare(scores[b], scores[a]));
    int[] topK = new int[k];
    for (int i = 0; i < k; i++) {
        topK[i] = indices[i];
    }
    return topK;
}

这是一个暴力搜索,没有用ANN索引(如HNSW、IVF)。实际的向量数据库会用索引结构减少需要计算相似度的候选数量,但在索引内部的距离计算环节,用的还是SIMD加速的点积或余弦相似度。Lucene的HNSW实现里,底层的距离计算函数就是用Vector API写的。

预归一化

如果向量集合是固定的(比如已经建好的向量库),可以在入库时预先把每个向量归一化成单位向量(模长为1)。归一化后的向量,余弦相似度退化成点积,省掉了模长计算:

ini 复制代码
// 预归一化:把向量缩放到模长为1
static void normalize(float[] v) {
    float norm = 0f;
    for (int i = 0; i < v.length; i++) {
        norm += v[i] * v[i];
    }
    norm = (float) Math.sqrt(norm);
    for (int i = 0; i < v.length; i++) {
        v[i] /= norm;
    }
}

// 归一化后,余弦相似度等于点积
static float dotProductVector(float[] a, float[] b) {
    FloatVector sum = FloatVector.zero(SPECIES);
    int i = 0;
    int upperBound = SPECIES.loopBound(a.length);

    for (; i < upperBound; i += SPECIES.length()) {
        FloatVector va = FloatVector.fromArray(SPECIES, a, i);
        FloatVector vb = FloatVector.fromArray(SPECIES, b, i);
        sum = va.fma(vb, sum);
    }

    float result = sum.reduceLanes(VectorOperators.ADD);
    for (; i < a.length; i++) {
        result += a[i] * b[i];
    }
    return result;
}

Netflix的做法也是先归一化再算矩阵乘法,归一化后的矩阵乘法就是批量点积。预归一化可以让每次相似度计算的fma调用次数从3次降到1次,内层循环的计算量降为原来的三分之一。

实战:批量特征评分

推荐系统和风控系统里有一类常见的计算:用一组权重对用户特征打分。每个用户有一个特征向量,用同一个权重向量做点积,得到一个分数。当需要同时对大批用户打分时,计算量会很大。

标量版本:

ini 复制代码
static float[] batchScoreScalar(float[] weights, float[][] features) {
    float[] scores = new float[features.length];
    for (int i = 0; i < features.length; i++) {
        float score = 0f;
        for (int j = 0; j < weights.length; j++) {
            score += weights[j] * features[i][j];
        }
        scores[i] = score;
    }
    return scores;
}

Vector API版本:

ini 复制代码
static float[] batchScoreVector(float[] weights, float[][] features) {
    float[] scores = new float[features.length];
    int upperBound = SPECIES.loopBound(weights.length);

    for (int i = 0; i < features.length; i++) {
        FloatVector sum = FloatVector.zero(SPECIES);
        int j = 0;

        for (; j < upperBound; j += SPECIES.length()) {
            FloatVector vw = FloatVector.fromArray(SPECIES, weights, j);
            FloatVector vf = FloatVector.fromArray(SPECIES, features[i], j);
            sum = vw.fma(vf, sum);
        }

        float score = sum.reduceLanes(VectorOperators.ADD);
        for (; j < weights.length; j++) {
            score += weights[j] * features[i][j];
        }
        scores[i] = score;
    }
    return scores;
}

这里的内层循环和余弦相似度的点积部分结构一致,都是fromArray → fma → reduceLanes的模式。Vector API的使用场景有一个共同特征:对连续的float/double数组做逐元素的算术运算,然后归约或写回。只要你的计算符合这个模式,就可以考虑用Vector API加速。

Netflix的优化还有一个值得借鉴的做法:他们把二维数组float[][]拍平成一维的float[],用i*D + k做索引。连续的内存布局对CPU缓存更友好,配合Vector API的fromArray从连续地址加载数据,可以减少缓存未命中。对于性能要求高的场景,建议也用这种扁平化的数组布局。

性能对比

写一段简单的测试代码,对比标量版本和Vector API版本的余弦相似度计算性能:

ini 复制代码
public static void main(String[] args) {
    int dim = 768;
    int count = 100_000;
    Random random = new Random(42);

    // 准备数据
    float[] query = randomVector(dim, random);
    float[][] vectors = new float[count][];
    for (int i = 0; i < count; i++) {
        vectors[i] = randomVector(dim, random);
    }

    // 预热
    for (int i = 0; i < 1000; i++) {
        cosineSimilarityScalar(vectors[i % count], query);
        cosineSimilarityVector(vectors[i % count], query);
    }

    // 标量版本计时
    long start = System.nanoTime();
    for (int i = 0; i < count; i++) {
        cosineSimilarityScalar(vectors[i], query);
    }
    long scalarTime = System.nanoTime() - start;

    // Vector API版本计时
    start = System.nanoTime();
    for (int i = 0; i < count; i++) {
        cosineSimilarityVector(vectors[i], query);
    }
    long vectorTime = System.nanoTime() - start;

    System.out.printf("维度: %d, 向量数: %d%n", dim, count);
    System.out.printf("标量版本: %d ms%n", scalarTime / 1_000_000);
    System.out.printf("Vector API版本: %d ms%n", vectorTime / 1_000_000);
    System.out.printf("加速比: %.2fx%n", (double) scalarTime / vectorTime);
}

在支持AVX2的机器上,768维向量、10万次余弦相似度计算,实测Vector API版本的加速比在2到4倍之间。在支持AVX-512的机器上加速比会更高。具体数字取决于CPU型号、向量维度和JVM版本。

Netflix在生产环境的数据是:CPU利用率下降约7%,平均延迟下降约12%,CPU/RPS(每请求CPU消耗)改善约10%。他们的计算从函数级别看,CPU占用从7.5%降到了1%。

需要注意的是,这段测试代码用的是System.nanoTime做粗略计时,适合快速验证效果。严格的性能测试建议使用JMH框架,它能处理JIT预热、GC干扰、统计误差等问题。

在项目中使用Vector API

以下是在实际项目中引入Vector API需要了解的关键信息。

JDK版本要求

Vector API从JDK 16开始引入,以孵化模块的形式提供。截至JDK 26(2026年3月发布),它仍然在孵化中,已经经历了11轮孵化。API在这11轮迭代中基本保持稳定,没有大的破坏性变更。

孵化这么多轮的原因不是API本身有问题,而是在等值类型(value types)特性。值类型可以让Vector对象消除对象头的开销,成为真正的值语义类型,这对性能和内存布局都有影响。等Valhalla的相关特性成为预览版,Vector API就会从孵化升级到预览,最终成为正式API。

虽然还在孵化,Netflix和Lucene/Elasticsearch都已经在生产环境使用了。API本身是稳定的,可以放心使用,但升级JDK版本时需要关注是否有API变更。

编译和运行参数

编译时和运行时都需要加上模块声明:

csharp 复制代码
# 编译
javac --add-modules jdk.incubator.vector YourClass.java

# 运行
java --add-modules jdk.incubator.vector YourClass

Maven配置

pom.xml的编译插件中添加参数:

xml 复制代码
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <configuration>
        <compilerArgs>
            <arg>--add-modules</arg>
            <arg>jdk.incubator.vector</arg>
        </compilerArgs>
    </configuration>
</plugin>

运行时需要在JVM启动参数中加上--add-modules jdk.incubator.vector

Fallback策略

因为Vector API还在孵化,有些环境可能没有启用或者JDK版本不够。Netflix的做法是在启动时检测Vector API是否可用,可用就走SIMD路径,不可用就回退到标量实现:

arduino 复制代码
static final boolean VECTOR_API_AVAILABLE;

static {
    boolean available = false;
    try {
        Class.forName("jdk.incubator.vector.FloatVector");
        available = true;
    } catch (ClassNotFoundException e) {
        // Vector API不可用,使用标量回退
    }
    VECTOR_API_AVAILABLE = available;
}

static float dotProduct(float[] a, float[] b) {
    if (VECTOR_API_AVAILABLE) {
        return dotProductVector(a, b);
    }
    return dotProductScalar(a, b);
}

适用场景

Vector API适合加速的计算有几个特征:

  • 数据是连续存储的float或double数组
  • 计算是逐元素的算术运算(加减乘除、fma)
  • 数据量足够大,能让SIMD的并行优势覆盖API调用的开销
  • 计算密集型,CPU时间主要花在数值运算上

典型的适用场景包括:向量相似度计算(推荐、搜索)、矩阵运算、信号处理、图像像素处理、科学计算、金融风控评分。

不太适合的场景:数据量很小(比如只有几十个元素)、计算中有大量分支判断、数据不是连续存储的。

SPECIES选择

Species 位宽 float通道数 double通道数 适用场景
SPECIES_128 128位 4 2 所有x86和ARM处理器
SPECIES_256 256位 8 4 支持AVX2的处理器
SPECIES_512 512位 16 8 支持AVX-512的处理器
SPECIES_PREFERRED 自动 自动 自动 推荐使用,自动选最优

生产环境建议统一使用SPECIES_PREFERRED。硬编码SPECIES_512在不支持AVX-512的机器上不会报错,但会回退到标量模拟执行,性能反而变差。

小结

Vector API在JDK的孵化器里待了11轮,看起来是个缺乏推进力的项目。但实际情况恰好相反,它没毕业不是因为不成熟,而是因为它要等Valhalla的值类型。API本身早已稳定,Netflix和Lucene这样的项目已经把它用到了生产环境,并且拿到了实际的性能收益。

对于Java开发者来说,Vector API的意义在于:当你需要高性能数值计算时,不用再离开Java的生态。以前这类需求要么忍受纯Java的性能上限,要么通过JNI调C/C++库引入额外的复杂度。Vector API提供了一条纯Java的路径,代码可读性好,跨平台,性能接近手写SIMD。

Vector API的价值不在于它快了多少倍,而在于这些性能提升不需要你离开Java。

如果你的服务里有大量的向量运算、矩阵乘法、特征评分之类的数值计算,建议在测试环境先跑一下Vector API版本的benchmark。从Netflix的经验看,对这类计算密集型的热点函数,SIMD加速的收益是实实在在的。

希望这篇内容可以帮到你。

相关推荐
神奇小汤圆2 小时前
用300行代码手写SpringBoot核心原理
后端
chrislearn2 小时前
Salvo 与 Axum 的设计思想对比
后端
武子康2 小时前
大数据-262 实时数仓 - Canal 同步数据实战指南 实时统计
大数据·hadoop·后端
一定要AK2 小时前
Spring 核心容器从入门到精通
java·后端·spring
zzb15802 小时前
系统提示词-System Prompt 动态组装
人工智能·后端·python·prompt
RInk7oBjo2 小时前
spring boot3--自动配置与手动配置
java·spring boot·后端
MacroZheng2 小时前
又一款企业级文件管理系统诞生了!支持万能文件在线预览,太香了!
java·spring boot·后端
SimonKing2 小时前
免费!不限量!用opencode接入英伟达(NVIDIA)大模型,轻松打造你的 AI 编程助手
java·后端·程序员