为了更好地方便各位开发者和用户了解并应用ECS倚天实例,由阿里云弹性计算联合基础软件团队 & 平头哥 & 安谋科技(Arm China)等十余位专家、架构师、开发工程师等,共同发起的【倚天实例迁移课程】正式上线,本次系列课程共计10节,共分为基础篇,架构迁移篇,性能优化篇等三个篇章,从不同角度为用户带来更加丰富和专业的讲解。
2023年9月22日,系列课程收官的最后一节《借助Arm SIMD指令提升Java应用性能》正式上线,由安谋科技(Arm China)高级工程师刘庆川主讲,内容涵盖:SIMD 指令及 Java VM介绍、如何在 Java 应用中使用 SIMD 指令、Java Vector API在 倚天上的案例分析。本期节目在阿里云官网、阿里云微信视频号、阿里云钉钉视频号、InfoQ 官网、阿里云开发者微信视频号、阿里云创新中心直播平台 & 微信视频号同步播出,同时可以点击【https://developer.aliyun.com/topic/ecs-yitian】进入【倚天实例迁移课程官网】了解更多内容。
以下内容根据刘庆川的分享整理而成,供读者阅览:
- SIMD 指令及 Java VM 介绍
SIMD 的概念就是一条指令处理多个数据,与之相对应的概念是 SISD,即一条指令只处理一个数据。上图中展示了一个拥有四个 lane 的 128-bit 的向量,其中的元素类型为 int。如果其中的元素为 byte 类型,则会有 16 个 lane。上图右边是 SIMD 与 SISD 的对比。同样是做四个 int 类型的加法,如果使用传统标量指令,需要四条指令。如果使用向量指令,则只需一条指令。
目前绝大多数的架构都有 SIMD 指令集。图中时间轴线下方是 x86 架 构SIMD 的发展历程,时间轴上方是 Arm 架构的。x86 最近的 AVX512 架构,可以支持 512-bit 的向量宽度。Arm 在 2017 年发布了可收缩矢量拓展 (Scalable Vector Extension, SVE) ,其向量宽度最大可以支持 2048-bit。
考虑到大多数读者已经在前面的基础篇课程了解过 Arm Neon™ 和 SVE 的特性,这里我们仅仅复习一下它们最主要的特征。
- Neon 提供了 32 个固定长度 (128-bit) 的向量寄存器。
- SVE 的向量宽度是可扩展的,使硬件可自定义向量宽度。它的宽度可以是 128-bit 到 2048-bit。
- SVE 提供了 16 个 Predicate 寄存器,其值为 0 或者 1。
对比 Neon 和 SVE,定长的 Neon 指令在做向量运算时,向量中所有的 lane 都要参与运算。在 SVE 中,通过 P 寄存器的值,我们是可以有选择地对其中某几个 lane 做运算。如上图所示,如果 P 寄存器中的值为 1,与之对应的向量的 lane 就会参与运算。反之,那么对应的 lane 不做运算。
- 如何在 Java 应用中使用 SIMD 指令
JVM 中很重要的一个组件就是 JIT 编译器(即时编译器) ,它里面其实提供了两个编译器,分别是 C1 和 C2 编译器。这两个编译器的功能就是把字节码实时编译成实际运行架构的汇编代码。C1 编译器的编译速度比较快,其中的优化流程相对 C2 少一些,因此它生成的代码虽然也是汇编代码,比解释执行快很多,但可能并不是最优的。C2 编译器的编译时间会长一点,其中的优化流程比较多,生成的代码是最优化的代码。
基于概率统计上的一些原则,JVM 将 C1 和 C2 两种模式混合在一起。代码可以先在解释模式运行,如果发现某一个函数被调用的次数比较多,值得花一些时间去生成更高效的代码,C1 编译器就会对它进行编译。之后如果 JVM 察觉这个函数被调用了更多次,这就说明这个函数是应用运行中的热点代码。C2 就会对它做进一步优化。
相信大部分同学或多或少都有 C/C++ 的开发经验。在介绍如何在 Java 中使用 SIMD 指令之前,先可以去类比 C/C++。上图我们总结了在 C/C++ 中使用 SIMD 的三种方法:
- 内联汇编。如果对硬件指令比较熟悉的开发者,可以使用 C 内联汇编,或者直接写 .S 文件就可以了。
- C intrinsics。因为直接写汇编对于大部分开发者不是特别的友好,所以编译器提供了一系列的内建的函数和结构,把对向量的操作做了一定的抽象。开发者可以通过调用这些内建的函数使编译器生成 SIMD 指令。
- Auto-vectorization,即自动向量化。这是编译器针对循环的诸多优化的其中一种。如图 Option 3 所示,在一个普通的 for 循环里面做一个数组的加法例子。在使用 GCC 编译的时候加入 “-O3” 选项,最后我们会发现生成的代码中也包含向量指令。
通过 JNI (Java Native Interface),开发者可以使用 C/C++ 生成 SIMD 指令。除此之外,开发者还有三种方法在 Java 中使用硬件 SIMD 指令。
- JDK core library intrinsics
- C2 Auto-vectorization
- Java Vector API
在 JDK 的源代码中,标记为 @IntrinsicCandidiate 的函数一般都是比较常用的函数。它们在运行时会被编译器利用 intrinsic 机制编译为汇编代码。
从 JDK18 开始,OpenJDK 社区就开始使用 SVE 的指令来优化已有的 Core library。比如 String.indexOf () 和 String.compareTo() 。上图中的示例代码为 String.indexOf 的源码,其功能为从一个给定的 byte 类型的数组中寻找目标字符,返回目标字符的下标。
在 JDK19中 ,由 于String.indexOf 使用了 SVE 指令进行比较操作,其性能在 micro-benchmark 中对比 JDK18 提升了 70%。
上图展示了 String.indexOf 在倚天710 和 IceLake 的性能对比。IceLake 使用的 SIMD 指令为 AVX-512,其向量宽度为 512-bit。测试结果均为单线程得分,使用的测试用例为 OpenJDK 官方的 micro-benchmark。
横坐标为 micro-benchmark 测试用例,OpenJDK 提供了多个用例测试 String.indexOf。纵坐标为 ns/op,表示执行一次或若干次 String.indexOf 所花的时间。如图所示,倚天710 在 latin1_mixed_char 和 utf16_Short_char 两个测试标准中性能高于 IceLake。
Core library提 供的方法一般都只是最常用的功能,因此为了能让更多代码被编译为 SIMD 指令,JVM 提供了 Auto-vectorization 功能。
上图展示了一个简单的 for 循环代码在 JVM 运行中各个阶段执行的具体代码。由于 JVM 使用了分层编译的策略,所以这段 for 循环代码的执行大致分为三个阶段。
- 解释执行。在此阶段 JVM 解释器解释运行字节码。
- C1 生成汇编代码。由图中展示的代码段我们可以看到此时生成的汇编代码仍然为标量代码。
- C2 生成 SIMD 代码。由于 Auto-vectorization 是 C2 编译器中 loop 优化的一部分,因此在 C2 编译后生成了 SIMD 指令。
实际中 Auto-vectorization生 成的代码可能并不像前文展示的一样只有 SIM D指令,可能是向量指令与标量指令混合的一种状态。这里以两个数组相加举例,如果数组为长度 31 byte 类型,在 128-bit 的向量上只能用一条 SIMD 指令处理前 16 个数据,后面的 15 个数据依然要用 15 条标量加法指令处理,即上图中的 Post-loop 部分。
我们通过一张图来形象地解释标量指令、Neon 指令和 SVE 指令三者 在Auto-vectorization 中的区别。
标量指令好比小号水杯,接一定量的水我们需要多个小水杯。
Neon 指令好比大号水杯,并且水杯要求其中如果有水必须是满的。因此接同等量的水,我们需要的水杯比标量指令少,但是在一些情况下仍然需要用小水杯处理不满一大杯的情况。
SVE 指令好比特大号水杯,跟 Neon 指令的大水杯不同的是特大号水杯允许其中的水不满。因此接同等量的水,我们可以用更少的特大号水杯,而不需要再使用额外的小水杯。
在目前的 OpenJDK (JDK21) 中,Post-loop 生成的代码使用的仍然是标量指令。在 Post-loop 中使用向量指令是 OpenJDK 社区计划长期支持的特性。
Auto-vectorization在 使用上对开发者是透明的。开发者不需要对代码进行修改,JVM 会负责将代码编译为 SIMD 指令。但任何事物都有两面性,Auto-vectorization也存在一些不足之处。
- Auto-vectorization 可以向量化的代码比较有限。上图中对本可以向量化的代码进行了一些修改,比如将循环步长 1 (i++) 改为 2 (i+=2) ,或者在循环内加入简单的 if 条件,在目前的 OpenJDK (JDK21) 上 Auto-vectorization就 会失败,这意味着 C2 对这样的代码最终只能生成标量指令。
- 目前除了用一些选项将运行时生成的指令打印出来之外,没有特别方便的方法能让开发者确认代码是否被成功向量化。
以上两点问题可能会使 Java 开发者在实际处理性能要求高的代码段时无法完全依赖编译器的 Auto-vectorization。
Java 有没有类似 C/C++ 一样 的SIMD intrinsic 呢?一种不需要 JNI 调用到原生代码,Java 实现的操作向量的接口。如果这样,编译器就可以通用过 intrinsic 机制,可靠地生成 SIMD 指令。
OpenJDK 在 JDK16 时加入了 Vector API,也就是我们期望的 Java SIMD intrinsic。与 Auto-vectorization 相比,使用 Vector API 在有硬件支持的平台上可靠地利用向量指令处理大量数据。
上图展示了如何使用 Vector API 对一个内部带有 if 条件的循环进行向量化。我们可以看到 Vector API 的定义跟 C/C++ 的 SIMD intrinsic 是比较类似的。
Vector API 内部的实现与 Core library 类似,也是利用了 JVM 的 intrinsic 机制。与 Core library 不同的是,编译器并没有直接为 Vector API 生成汇编代码,而是将其编译为编译器内部的 IR (Intermediate Representation) 。这样的好处是 IR 可以继续参与到 C2 后续的优化流程,有机会在更丰富的上下文中获得优化的代码。
为了保证 Java 跨平台的特性,所有的 Vector API 都提供了默认的 Java 实现。也就是说如果在一个没有 SIMD 指令支持的平台使用 Vector API,即使编译器没法生成 SIMD 指令,但它仍然可以运行默认的 Java 实现,并对其进行编译。
三、倚天710 案例分析
阿里巴巴和 Arm 在 JDK19 中向 OpenJDK 社区贡献了 SelectiveLoad/Store 两个新的 Vector API。这两个操作都是阿里巴巴在 In-memory Database 中实际的业务需求。上面两张图简单描述了它们的功能,如果需要详细了解这两个 API 的用法,可以参考 Vector API 的定义:
考虑到倚天710 拥有 SVE 特性的支持,Arm 在 JDK20 中使用 SVE 指令优化了 SelectiveStore(compress) 操作。上图中展示了编译器生成的汇编代码,其中复杂的 SelectiveStore 操作在倚天710 上只需要一条 COMPACT 指令即可完成。因此与 JDK19 的默认 Java 实现对比,使用 SVE 生成的代码在倚天710 上性能提升了大约 20 倍。
通过图中介绍的 COMPACT 指令的定义,我们可以了解到这条指令与 SelectiveStore 的语义是基本一致的。遗憾的是 SelectiveLoad 操作目前并没有直接的硬件指令支持,所以在大部分平台上仍默认使用 Java 实现。Arm 也在理解和分析合作伙伴的需求后,迅速设计和定义了新的指令。EXPAND 指令就是针对 SelectiveLoad 的需求而设计的,并将在未来的 Arm CPU 中会实现。
SelectiveLoad 操作目前虽然没有像 SelectiveStore 有直接的硬件指令支持,但由于倚天710 支持 SVE2 特性,最终两条指令也可高效实现该操作。这里不再赘述 SelectiveLoad 的实现细节,感兴趣的读者可以参考 OpenJDK 社区的相关资料:https://bugs.openjdk.org/browse/JDK-8285013
使用过 JDK19 的读者可能发现了,Integer 和 Long 这两个常用的类中加入了一个新的 API——compress。虽然跟前文介绍的 Vector.compress 名字相同,但这个 compress 是以 bit 为单位的。上图中展示了 Integer.compress 的 Java 实现。这样一段复杂的代码经过 C2 编译后,会生成大概 60 多条汇编指令。在 Vector API 中同样有向量版本的 COMPRESS_BIT,其默认 Java 实现就是对向量中的每个 lane 进行 compress 操作。可以想象如果对 N 个 lane 逐一进行 compress 操作,大概会使用 N*60 多条汇编指令。
OpenJDK 使用 SVE2 特性对 COMPRESS_BIT 操作进行了优化。编译器最终只生成一条 BEXT 指令即可在任意长度的向量上完成该操作。因此这个 API 在倚天710 上会有明显的性能优势。
这里我们使用 OpenJDK 官方的 micro-benchmark,对 Vector API 的 COMPRESS_BIT 操作在 IceLake 和倚天710 上进行性能对比。图中纵坐标进行了归一化处理,展示的是性能的倍数。我们能直观的看到倚天710 在 COMPRESS_BIT 上的性能表现比 IceLake 大概高出 13 倍。
最后我们对 Vector API 和 Auto-vectorization 在倚天710 上的性能进行了对比。使用的 JDK 为最新的 JDK21。测试用例为 OpenJDK 源代码中的 micro-benchmark。这个 micro-benchmark 中每一个用例都提供了两种实现,一是用普通的 Java 实现的,二是同样的功能使用 Vector API 实现的。
上图中横坐标为每一个测试用例的名称,纵坐标为性能对比的倍数。其中蓝色为 Java 实现,橙色为 Vector API 的实现。
由上图中的对比我们可以得到两个结论:
- 对于一些复杂操作,比如 COMPRESS、BIT_COUNTMasked 等等,这种操作是 Auto-vectorization 比较难处理的,而由于 Vector API 可以可靠地生成向量化的汇编代码,所以在图中我们可以看到这类操作使用 Vector API 有几倍到几十倍的性能提升。
- 对于一些简单的操作,比如 ADD、AND 等等,由于 Auto-vectorization 也可以进行向量化操作,所以跟使用了 Vector API 的代码对比性能几乎没有区别。
四、总结
最后我们回顾总结一下今天介绍的三种在 Java 中使用 SIMD 指令的方法,从不同的视角分析它们的优劣,供大家在实际应用时作为参考:
- 用户透明。Vector API 需要开发者更改现有的代码,并且 Vector API 本身也是有学习成本的。使用 Core library 和依靠编译器的 Auto-vectorization 优化则不需要开发者更改太多的代码。
- 可靠性。由于使用了 intrinsic 机制,Core library 和 Vector API 可以在有指令支持的架构可靠地生成 SIMD 指令。而由于 Auto-vectorization 本身的复杂性,很多情况下无法保证将代码编译为 SIMD 指令。
- 灵活性。Core library 由于只包含一些最常用的功能,所以无法满足开发者所有的业务逻辑需求。Auto-vectorization 针对的是普通的 Java 代码,所以最灵活。Vector API 目前定义的方法也满足了用户在向量操作上的需求。此外,Vector API 也可以根据用户实际的业务需求更新,加入新的 API。OpenJDK 社区也十分欢迎广大开发者参与到 JDK 的讨论和开发中。
以上就是本次课程的全部内容,想要回看【倚天实例迁移课程】的全部内容,欢迎点击下方链接进入活动官网查看。
课程传送门:https://developer.aliyun.com/topic/ecs-yitian