fizzbuzz - SIMD风格

2021-03-11 15:47:02

Java 16在拐角处,所以没有比现在更多关于新版本所带来的功能的更好的时间。遍布遍历UNIX域套接的支持,我最近对孵化的向量非常好奇API,如19ep 338所规定的,在Panama项目的伞下,它针对"互连JVM和原生代码"

vectors?当然,这不是续订古代java收集类型,如java.util.vector(<此处插入一些关于此>),而是关于一个API,它可以让Java开发人员利用矢量计算能力您可以在大多数CPU中找到这些日子。现在我绝不是低级编程专家利用特定的CPU指令,但这就是为什么我希望通过这篇文章来实现新的矢量API使这些能力实现这些功能的原因对Java程序员的广泛受众。

在潜入一个具体示例之前,值得指出为什么API是如此有趣,以及它可以使用的东西。在x86或AARCH64这样的CPU架构中可以为其指令集提供扩展,允许您应用单一操作一次到多个数据项一次(SIMD - 单指令,多个数据)。如果可以使用将自身归因于这种并行化的算法来解决特定计算问题,可以获得实质性的性能改进。此类SIMD指令集扩展的示例包括SSE和AVX for x64,以及AARCH64(ARM)的霓虹灯。

因此,它们补充了其他计算并行化的方法:跨越组合在集群中的多个机器中进行缩放,而多线程编程。如此,在单个方法的范围内完成矢量化计算。立即在数组的多个元素上运行。

到目前为止,Java开发人员无法直接使用此类SIMD指令。当您可以更接近C / C ++等金属的语言中的SIMD内在函数,并且在Java中,迄今为止不会使用此类选项。注意事项不会平均Java根本不会利用SIMD:JIT编译器可以在特定情况下自动矢量化代码,即将代码从循环转换为向量化代码。尽管如此,但不容易确定的是;对编译器之前能够矢量化的循环的小的更改可能导致标量执行,从而导致性能回归。

JEP 338旨在改善这种情况:引入便携式矢量计算API,它允许Java开发人员通过明确的矢量化算法从SIMD执行中受益.unlike C / C ++样式内在机构,该API将由C2 JIT编译器自动映射到相应的指令集的底层平台,如果平台没有提供所需的能力,则倒回标量的执行。如果你问我,那么甜蜜的交易!

现在,你为什么对此感兴趣?没有"矢量计算"听起来很像数学 - 沉重的低级算法,你倾向于在典型的java企业应用程序中找到这么多?我会说,是和编号,这可能不是那么有益CRUD应用程序从左到右复制一些数据。但是在图像处理,AI,解析等领域存在许多有趣的应用程序,(基于SIMD的JSON解析是突出讨论的示例),文本处理,数据类型转换和许多其他。在这方面,我期望Jep 338将在许多有趣的用例中铺平了使用Java的路径,在那里它可能不是今天的首选。

要了解矢量API如何帮助提高某些计算的性能,让我们考虑FizzBu​​zz.originally,FizzBu​​zz是一个帮助教授儿童部门的游戏;但有趣的是,它也是招聘软件工程师的入门面试问题地点。在任何情况下,探索一些计算如何从矢量化受益的一个很好的例子。FIZZBUZZ规则很简单:

由于矢量API与数值而不是字符串,而不是字符串,而不是" fizz&#34 ;," buzz&#34 ;,和#34; fizzbuzz"我们将发出 - 图1,-2和-3。程序的输入将是一个数组,其中数字为0 ... 256,输出带有fizzBu​​zz序列的数组:

1,2,-1,4,-2,-1,7,8,-1,-2,11,-1,13,14,-3,16,......

使用普通的循环处理标量值逐个来轻松解决任务:

私人静态int fizt = - 1;私人静态Final int Buzz = - 2;私人静态Final Int Fizz_Buzz = - 3; public int [] scalarfizzbuzz(int []值){int []结果= new int [值。长度 ]; for(int i = 0; i<值。长度; i ++){int值=值[i]; if(值%3 == 0){if(值%5 == 0){(1)结果[i] = fizz_buzz; }否则{结果[i] = fizz; (2)}}如果(值%5 == 0){结果[i] = buzz; (3)}否则{结果[i] =值; (4)}}返回结果; }

作为基线,可以在MacBook Pro 2019上运行的简单JMH基准下执行此实现,每秒执行〜2.2米次,使用2.6 GHz 6-Core Intel Core I7 CPU:

现在让我们看看该计算如何被矢量化,并且通过这样做可以获得哪些性能改进。当看着孵化的向量API时,您可能首先被其大型API表面淹没。一旦你意识到所有的所有类型的类型,像Intvector,longVector等。基本上公开了相同的方法,仅针对每个受支持的数据类型(并且确实,根据javadoc,所有这些类都没有由一些可怜的灵魂写成,但生成,来自某种参数化模板备注)。

在过多的API方法中,不存在模数操作(这是有意义的,例如在X86 SIMD扩展中的任何一个中没有这样的指示)。搜索可以解决的是解决FIZZBUZZ任务?撇去后通过API一段时间,方法混合(向量<整数> v,vectormask< integer> m)引起了我的注意:

取代该矢量的选定泳道,其中来自掩码控制下的第二个输入向量的相应通道。 [...]

对于在掩码中设置的任何车道,新的车道值取自第二输入向量,并取代该载体的该通道中的任何值。

对于掩模中的任何车道未设置,抑制替换,并且该向量保留存储在该通道中的原始值。

这听起来很有用;预期-1,-2和-3值的模式每15个输入值重复。所以我们可以"预计"该模式一次并持续到vector()方法的vector和掩码的形式。当踩过输入阵列,基于当前位置获得右向量和掩模,并与Blend()一起使用,以便标记值可分离为3,5和15(另一个选项可以是MIN(向量<整数&gt),但我决定反对它,因为我们需要一些魔法值来代表应按原样发出的数字)。

以下是这种方法的可视化,假设八个元素的矢量长度("车道"):

因此,让我们看看我们如何使用矢量API来实现这一点。掩码和第二输入向量重复每120个元素(最小公共倍数为8和15),因此需要确定15个掩码和向量。可以创建这样的掩码和向量。

公共类FIZZBUZZ {私有静态最终vectorspecies<整数>物种= INTVector。物种_256; (1)私人最终列表< Vectormask<整数>>结果阵列=新的ArrayList<(15);私人最终的IntVector []结果vectors =新的IntVector [15]; public fizzbuzz(){list< Vectormask<整数>> Threes =阵列。 aslist((2)vectormask。<整数> vectlong(0b00100100),Vectormask。< integer> vectormask。< integer> fromlong(种,0b10010010));列表< Vectormask<整数>> fives =阵列。 aslist((3)vectormask。<整数> vectormask。< integer> vectormask。< integer>< fronlong(种,0b00001000),Vectormask。&lt ;整数>从隆(物种,0b001001),Vectormask。<整数> fromlong(物种,0b10000100)); for(int i = 0; i< 15; i ++){(4)vectormask<整数> Threemask = Three。得到(i%3); Vectormask<整数> Fivemask = fives。得到(i%5);结果掩码。添加(Threemask。或(fivemask)); (5)结果vectors [i] = Intvector。零(物种)(6)。混合(FIZE,THREEMASK)。混合(Buzz,FiveMask)。混合(FIAMP_BUZZ,THREEMASK。和(FIVEMASK)); }}}

向量物种描述了矢量元素类型(在这种情况下整数)和矢量形状的组合(在这种情况下256位);即,我们将处理包含8 32位int值的向量

矢量掩模描述数字已划分三个(从右读取比特值)

如果它已被三到五个定位,则输出阵列中的值应设置为另一个值

将值设置为-1,-2,或-3,具体取决于其可分隔的三,五,或十五个;否则将其设置为从输入数组到相应的值

使用此基础架构到位,我们可以实现用于计算任意长输入阵列的FIZZBUZZ值的实际方法:

public int [] simdfizzbuzz(int []值){int []结果= new int [值。长度 ]; INT i = 0; int上行=物种。 LoopBound(值。长度); (1)对于(;我<上行; i + =物种。length()){(2)IntVector Chunk = IntVector。 fromArray(物种,价值观,i); (3)int maskidx =(I /物种。长度())%15; (4)INTVector FizzBu​​zz =块。混合(结果值[maskidx],结果掩码[maskidx]); (5)FIZZBUZZ。陷阱(结果,i); (6)} for(; i<值。长度; i ++){(7)int值=值[i]; if(值%3 == 0){if(值%5 == 0){结果[i] = fizz_buzz; }否则{结果[i] = fizz; }}否则如果(值%5 == 0){结果[i] = buzz; }否则{结果[i] =值; }}返回结果; }

确定阵列中的最大索引由物种长度可分离;例如如果输入阵列长为100个元素,则在八个元素的vectors的情况下,该值为96

确定当前块的FIZZBUZZ编号(即,这是实际的SIMD指令,一次处理当前块的所有八个元素)

使用传统的标量方法处理任何剩余部分(例如,在输入数组的情况下,使用100个元素的剩余元素),因为这些值无法填充另一个矢量实例

重申这里发生的事情:而不是一个接一个地处理输入数组的值,而是通过Blend()矢量操作在八个元素的块中处理它们,其可以映射到CPU的等效SIMD指令。在案例中输入阵列没有长度是向量长度的倍数,其余部分以传统的标量方式处理。结果逻辑的重复似乎是一个难以置信的,我们将在一点讨论什么可以完成这一点。

现在,让我们看看我们的努力是否还清;即。这种矢量化方法实际上是更快的基本标量实现吗?结果是!这是我从我的机器上的JMH获得的号码,显示通过因数3增加而增加:

基准(ArrayLength)模式CNT评分误差UnionFizzBu​​zzBenchmark.scalarfizzbuzz256 Thrpt 5 2204774,792±76581,374 Ops / s fizzbuzzbenchmark.simdfizzbuzz 256 Thrpt 5 6748723,261±34725,507 Ops / s

有什么可以进一步改善的吗?我很确定,但据说我不是在这里的专家,所以我会把它弄得更聪明,以指出更多的评论中的更有效的实现。我想的是我所在的东西用于获取当前掩码索引的划分和模数操作不是理想的。在达到15后,将重置为0的单独环路变量证明是相当更快的:

public int [] simdfizzbuzz(int []值){int []结果= new int [值。长度 ]; INT i = 0; int j = 0; int上行=物种。 LoopBound(值。长度); for(; i<上行; i + =物种。length()){intvector chunk = IntVector。 fromArray(物种,价值观,i); Intvector fizzbuzz =块。混合(结果值[J],结果膜[J]); fizzbuzz。陷阱(结果,i); J ++; if(j == 15){j = 0; }} //剩余的处理...}

基准(ArrayLength)模式CNT分数误差UnionFizzBu​​zzBenchmark.scalarfizzbuzzmark.scalarfizzbuzz 256 Thrpt 5 2204774,792±76581,374 Ops / Sfizzbuzzbenchmark.simdfizzbuzzbenchmark.simdfizzbuzzbuzzmark.simdfizzbuzzbenchmark.simdfizzbuzz 256 thrpt 5 6748723,261±34725,507 Ops / s fizzbuzzbenchmark.simdfizzbuzzseparatemaskIndex 256 Thrpt 5 8830433,250±69955 ,161 ops / s

这使得另一个很好的改进,产生了4倍的原始标量实现的吞吐量。要使这个真正的Apple-to-Apple比较,基于掩码的方法也可以应用于纯粹的标量实现,只有每个值需要单独抬头:

Private Int [] SerialMask = New int [] {0,0, - 1,0, - 2, - 1,0,0, - 1, - 10,0, - 1,0,0, - 3}; public int [] serialfizzbuzzmasked(int []值){int []结果= new int [值。长度 ]; int j = 0; for(int i = 0; i<值。长度; i ++){int res = serialmask [J];结果[i] = res == 0?值[i]:res; J ++; if(j == 15){j = 0; }}返回结果; }

实际上,这种实现比原来的一个更好,但仍然基于SIMD的方法是快速的两倍多:

基准(ArrayLength)模式CNT评分错误UnipFizzBu​​zzBenchmark.scalarfizzbuzz 256 Thrpt 5 2204774,792±76581,374 Ops / s fizzbuzzbenchmark.scalarfizzbuzzmasked 256 Thrpt 5 4156751,424±2366751,424±2366751,424±23668,949 Ops / sfizzbuzzbenchmark.simdfizzbuzz 256 Thrpt 5 6748723,261±34725 ,507 ops / sfizzbuzzbenchmark.simdfizzbuzzseparatemaskindex 256 thrpt 5 8830433,250±69955,161 ops / s

这一切都很酷,但我们可以相信在引擎盖上的事情实际上是我们希望他们发生的方式发生的事情?为了验证,让我们来看看由JIT编译器为此实现生成的本机程序集合代码。这要求您使用HSDIS插件运行JVM;有关如何构建和安装HSDIS.Let的创建一个简单主类的说明,请参阅此帖子,该类在循环中执行问题中的方法,以确保该方法实际上得到了JIT编译:

公共类主要{公共静态int []黑洞;公共静态void main(String [] args){fizzbuzz fizzbuzz = new fizzbuzz(); var值= IntStream。范围(1,257)。 toarray(); for(int i = 0; i< 5_000_000; i ++){blackhole = fizzbuzz。 simdfizzbuzz(值); }}}

运行程序,启用程序集的输出,并将其输出输出到日志文件中:

打开fizzBu​​zz.log文件并查找SimdfizzBu​​zz方法的C2编译的NMethod块。在方法的本机代码中,您应该找到VPBlendvB指令(稍微调整的输出以获得更好的可读性):

... =========================== C2编译nmethod ================ ============ ------------------------------------ ------------------------------编译方法(c2)... dev.morling.demos.simdfizzbuzz.fizzbuzz ::↩simdfizzbuzz (161字节)... 0x000000011895E18D:vpmovsxbd%xmm7,%ymm7↩; * incokestatic store {reexecute = 0 rethrow = 0 retch_oop = 0}; - jdk.incubator.vector.intvector ::陷阱@ 42(第2962行); - dev.morling.demos.simdfizzbuzz.fizzbuzz :: simdfizzbuzz @ 76(第92行)0x000000011895C192:vpblendvb%ymm7,%ymm5,%ymm8,%ymm0↩; * incokestatic blend {reexecute = 0 rethrow = 0 retch_oop = 0}; - jdk.incubator.vector.intvector :: blendtemplate @ 26(第1895行); - jdk.incubator.vector.int256向量:: Blend @ 11(第376行); - jdk.incubator.vector.int256Vector :: Blend @ 3(第41行); - dev.morling.demos.simdfizzbuzz.fizzbuzz :: simdfizzbuzz @ 67(第91行)...

vpblendvb是x86 avx2指令集的一部分和#34;根据隐式第三寄存器参数&#34中定义的掩码比特,有条件地将字节元素(第一个操作数)从源操作数(第二操作数)复制到目标操作数(第一个操作数);,如如JEP 338 API中的Blend()方法完全对应。

一个细节对我来说不太清楚,是为什么VPMovsxbd将结果复制到输出阵列(陷阱()呼叫)在vpblendvb之前显示。如果您碰巧知道这个原因,我很乐意收到您并学习对这个。

让我们回到输入阵列的电位剩余时间的标量处理。这感觉到一点点"不干干"因为它要求算法实现两次,一旦以标准方式向两个算法和一次。

Vector API认识到避免此重复的愿望,并提供所有所需操作的屏蔽版本,因此在最后一次迭代期间不会发生超过数组长度的访问。使用此方法,SIMD FIZZBUZZ方法如下所示:

public int [] simdfizzbuzzmasked(int []值){int []结果= new int [值。长度 ]; int j = 0; for(int i = 0; i<值。长度; i + =物种。length()){var mask = speies。 IndexInRange(i,值。长度); (1)var chunk = Intvector。 fromArray(物种,价值观,i,面具); (2)var fizzbuzz =块。混合(结果值[J],结果掩码。得到(j)); fizzbuzz。陷阱(结果,我,面具); (2)J ++; if(j == 15){j = 0; }}返回结果; }

获取在最后一次迭代期间的掩码将具有用于未命令的泳道的位,这大于向量长度的最后遇到的倍数

执行与上面相同的操作,但使用掩码可防止任何超出数组长度的访问

实现看起来比具有剩余部分的显式标量处理的版本看起来很好。但对吞吐量的影响很大,结果非常令人失望:

基准(ArrayLength)模式CNT分数误差UnipFizzBu​​zzBenchmark.scalarfizzbuzz256 Thrpt 5 2204774,792±76581,374 Ops / Sfizzbuzzbenchmark.scalarfizzbuzzmasked 256 Thrpt 5 4156751,424±2366751,424±2366751,424±2366751,424±2366751,424±23668,949 Ops / sfizzbuzzbenchmark.simdfizzbuzz 256 Thrpt 5 6748723,261±34725, 507 OPS / SFizzBu​​zzBenchmark.simdfizzbuzzseparatemaskIndex 256 Thrpt 5 8830433,250±69955,161 Ops / s fizzbuzzbenchmark.simdfizzbuzzmasked 256 Thrpt 5 1204128,029±504128,029±5556,553 Ops / s

在其目前的形式中,这种方法甚至比纯标量实现慢。它仍然可以看出,以及如何以及如何在此处改进,因为矢量API日期.Ly,掩模必须仅在最后一次迭代期间应用这是我们可以做的事情 - 重新引入一些特殊的剩余处理,尽管与核心实现的不同之处,而不是与上面讨论的纯标量方法 - 或者也许也许是编译器本身可以应用这种转变。

一个重要的消除从此是基于SIMD的方法不一定比标量更快。因此,应在绘制任何结论之前用相应的基准验证每种算法调整 ......