使用SIMD+CriticalNative在Java中加速矩阵运算

对于游戏开发来说,一个健壮高效的数学库是必不可少的,特别是对于3D游戏而言,动作系统在计算骨骼动画时会进行数量可观的矩阵乘法或求逆运算;渲染系统也需要频繁计算变换矩阵.虽然一次矩阵运算消耗的时间可能不多,但对于分秒必争的游戏渲染来说想要力争60fps、死守30fps底线就势必不能放过任何一个免费提升性能的机会.

什么是SIMD?

时间回到上世纪的90年代,在CPU的主频只有百余MHz的时候,想要流畅地进行诸如视频音频解码这样的密集运算几乎是不可完成的事情,虽然这一类数据往往有一定的规律性,比如在视频解码中颜色往往是以3或4个字节为一组的形式出现,但在计算时CPU仍必须针对每个分量逐字节地计算,也就是一次运算处理一个数据流,这在计算机中被称为SISD(Single Instruction Single Data 单操作单数据),倘若可以针对这些有规律的数据,设计出能够单条指令操作多个数据流的指令集,那岂不是可以让效率翻倍? 事实上早在70年代便已经有人这样做了,那时已经有了被称为向量处理机的大型计算机(以那时的标准),能够一条指令处理复数个数据流,也就是本文标题中所提到的SIMD(Single Instruction Multiple Data 单操作多数据),这种计算机固然强大,然而SIMD也有它的局限性,就是它要求各个数据流处于相同的状态,这一点是显而易见的,显然你在执行整数乘法时数据流中不能混入一个浮点数,这种限制导致向量处理机只适合执行规律性的大规模数据处理,如果要像个人计算机那样随时执行各种各样的指令的话,它就失去了并行处理的优势,最终向量处理机没落了,但SIMD的思想却保留了下来,时间到了1993年,Intel发布了第一款奔腾处理器,虽然它的性能与老前辈相比有了不少的提升,但依然面临之前提到的密集运算能力不足的问题,这时有人搬出了SIMD,提议添置一个专门的执行SIMD操作的指令集,然而工程师们发难了,按照设计,新的指令集必须拥有一套独立的寄存器,但实际情况是芯片内部布线已经拥挤到没有空间再去放置寄存器组了,这时Intel在以色列的法海研究所给出了一个解决方案,最初的CPU是没有硬件支持的浮点运算功能的,为此Intel在80年代还专门开发了插在主板上的浮点运算协处理器,最终随着微处理器技术的发展,浮点运算单元(FPU)被直接集成在了CPU片上.而这一解决方案就是...将FPU上的8个64位寄存器盗过来作为新指令集的寄存器(黑枪「还真是颇具犹太人风格的方案呢」),这种解决方案简单粗暴,但显然当新指令集在执行SIMD指令时FPU是无法正常工作的,不过这也并不是一个大问题,因为新指令集的面向目标是多媒体数据,这类数据是以整数为主,因此新指令集只要支持整数运算就行了,3年之后,搭载着新指令集的下一代芯片诞生,这一套指令集也被命名为MMX,有趣的是,官方从未公布过它的正式名称,不过根据它的作用和受众对象,MMX很可能是MultiMedia eXtension的缩写.
MMX开启了x86架构上各种奇怪扩展指令集的大门,不过它却未能成为本篇的主角,原因不只是因为它只支持整数运算,而且在它出现不久之后,Intel推出了被称为SSE(Streaming SIMD Extensions)的另一套扩展指令集,这个指令集不但包含了MMX原有的功能,回避了它抢占FPU寄存器的缺陷,还提供了新的支持浮点运算的SIMD式指令,以及8个全新的128位寄存器,非常适合不复杂但计算量大的运算.除了SSE,Intel还开发了功能更为强大的AVX指令集,它提供了256位寄存器和更多的指令,显然也能够更快地执行各种操作.在后面会重点解释如何使用SSE加速运算.

数学库的设计

在继续解释SIMD在游戏开发中的应用之前,我们先要了解现有的数学库与它们的设计,这里我们重点讨论关于矩阵部分的设计.
各个数学库的矩阵在功能上大同小异,差别主要在于:
可变/不可变性:很多数值类在设计时都会采用不可变(Immutable)设计,也就是值一旦确定后就不可以改变了,如果想改变这个值,那就再去创建一个新的,这样的好处是使用时不用担心值污染,也就是不小心改变了其他对象的值,而缺点就是可能面临巨大的垃圾回收压力,对Java而言,桌面级JVM在回收新数据中的垃圾时多采用复制算法,也就是找出少数不是垃圾的数据将它们复制走,然后将原来这片内存区域直接释放掉,这是根据"在运行中有97%的对象都是活不过第一次垃圾回收的临时对象"的理论来的,这种垃圾回收机制在处理大量无用对象时显得得心应手,但对于Android上的Dalvik虚拟机却又是另一种情况,Dalvik虚拟机(至少是早期的Android,现在什么样不太清楚)使用的是标记-清除算法,也就是逐个对象地清除垃圾,这在面对采用不可变设计产出的大量垃圾对象时绝对是噩梦般的体验,因此面向Android的库很少采用不可变设计,事实上,我现在没找到哪个Java数学库将矩阵设计为了不可变...我记得我曾经见到过一个,但现在怎么也找不到了,是我穿越了,还是又遇到了什么曼德拉效应?
无堆开销/堆开销:有些运算需要一些临时变量,对C/C++这样直接支持分配栈内存的语言而言不是问题,但对于Java这样不支持将对象创建在栈内存上的语言来说就有些麻烦,因为在堆内存中创建临时对象就等同于又犯了上一条提到的大量内存垃圾的问题了,不过桌面级JVM对此还有一个"妙计"就是在JIT编译时进行逃逸分析+栈上分配优化,我们知道Java中的对象之所以要分配到堆内存上是因为它随时可能要被其他对象所引用,但如果判定一个对象的作用范围仅局限在一个方法内部,不会"逃逸出去"的话,就可以干脆将它分配到栈内存中,让它随着方法结束而自然被销毁,这个"妙计"之所以加引号是因为很多时候它并不是那么靠谱.对桌面JVM都是这样,对Dalvik而言更是如此,虽然Dalvik虚拟机也很早跟风引入了逃逸分析,但在很长一段时间基本上处于"// TODO Auto-generated method stub"的状态,不知道现在版本有没有改善(Google在Android4后开始大力推广ART,从5开始又大力改良JIT,据说在6里JIT已经很厉害了,让我们拭目以待吧),因此大多数数学库都会竭力避免在运算时产生堆内存上的临时数据,对此的解决方案可以是手动将临时对象分解为基本类型(基本类型毫无疑问是分配在栈上的),缺点是让代码不那么"优美";或者是学着C那样,大量依赖静态的全局对象作为"全局临时变量",而这种方法的缺点就是导致下文所说的非线程安全.
线程安全/非线程安全:"多线程的速度呵,用起来那般酸爽!" 在多核时代,多线程几乎又是一个能免费获得性能提升的要素,条件是你能正确地使用它,并且你是用的库还得是线程安全的.通常来说,数学库都应该是线程安全的,但也有例外,比如上文提到的依赖全局临时变量的方法.这种不重视线程安全的设计多见于一些Android库中,特别是那些从Android2时代一路走来的历史悠久的老库,考虑到当时手机还是以单核为主,线程安全显然是不被考虑的事,然而现在手机中多核也很常见了,因此有一个线程安全的基础库是很重要的.
存储顺序:我们都知道矩阵有两种形式:行矩阵和列矩阵,这决定它们的书写方式和计算顺序,此外在计算机中矩阵存储的方式也可以分为行优先和列优先,行优先是先存储第一行的4个分量,再存储第二行的分量...列优先显然就是先存储第一列的4个分量,再存储第二列...通常来说比较推崇的存储方式是和矩阵形式相同的方式,即行矩阵用行优先存储,列矩阵用列优先存储,对它我称其为同序存储,而另一种方式就是采用相反的存储顺序,比如用行优先存储列矩阵,这种方式我称为异序存储.关于矩阵形式和存储顺序的讨论可以见这个帖子.顺便一提存储顺序和下标规则是各种数学库中神特么乱的东西,我之前曾想统计一下各个数学库的存储顺序和下标规则,结果自己先被绕晕了...
内部格式:这一点主要是针对Java这种内存布局对开发者"不透明"的,在矩阵类中存储数据有两种方式,一种是16个分量每个分量都作为单独的一个float字段存放,另一种是全部存进一个float数组.在实际测试中同样的算法后者会比前者慢5%~10%,大概是由于数组存取时的开销,以及额外的寻址开销,众所周知Java的数组是与其所属的对象分开存储的,这就导致了在运算时还要对数组进行一次额外的寻址.此外后者还会有更大的内存开销(额外的数组对象头+长度字段)和垃圾回收压力.而如果说优点的话,就是后者在JNI操作上比较方便,这正好符合我们待会的需求...

目前最流行的Java数学库是JOML,它的设计风格是可变、无堆开销、线程安全、每个分量作为独立的字段来存储,性能可以称得上是标杆水准.其余的一些数学库包括Vecmath(好像是个Javax扩展...缺点是有堆开销)和LWJGL-Util(没什么缺点...但用的人不多?)等,这里就不一一列出了.

调用SSE

显然在Java中无法调用像SEE这样的底层指令,因此需要JNI的辅助,Java Native Interface是Java1.1时出现的功能,JNI可以让Java程序调用C/C++编写的库,最初是为了实现一些用纯Java无法做到的时,以及解决当时在没有JIT的时代Java性能不足的问题,这第二个目的在JIT出现、Java性能大幅提升后便被取消了,原因是JNI调用本身就很慢! 在Java设计之初,开发者希望将JVM打造成一个与外界互相隔离的铜墙铁壁(别忘天国的Java Applet),因此在JNI调用中会有大量的检查,栈爆上的这篇回复解释了JNI为何"如此之慢"的原因,在正式调用一个C/C++函数之前和之后共需要15步额外的工作! 而且这还不包括额外的数据校验工作,当你通过JNI向外传出对象参数或数组参数时Java会进行额外的检查,这又更进一步降低了性能,因此就目前的观点,如果要通过JNI来提高性能的话,必须要至少满足两个条件之一:纯Java版本过于慢(这也是为什么早期使用JNI实现的数学库在有了JIT后立刻被用纯Java重写了);或者运算量足够大,让运算时节省的开销足以填补JNI调用时的额外开销.

坐而言不如起而撸,先写一个适合JNI调用的矩阵类再说,由于在JNI中传递对象开销很大,因此最好的办法是把需要的数据按数组的方式传过去,因此就得先写一个采用数组来存储分量的矩阵类:

package jnimath;

public class ArrayMatrix4f {

	public static final int 
			M00 = 0, M01 = 4, M02 = 8,  M03 = 12,
			M10 = 1, M11 = 5, M12 = 9,  M13 = 13,
			M20 = 2, M21 = 6, M22 = 10, M23 = 14,
			M30 = 3, M31 = 7, M32 = 11, M33 = 15;
	
	public final float[] val = new float[16];
	
	public ArrayMatrix4f() {
		val[M00] = val[M11] = val[M22] = val[M33] = 1;
	}
	
	public ArrayMatrix4f mul(ArrayMatrix4f right) {
		mul(this.val, right.val);
		return this;
	}
	
	private static native void mul(float[] left, float[] right);

}

它的内容并不多,唯一一个功能性的方法是mul,也就是乘另一个矩阵,结果存在原矩阵中.它被设计为列矩阵、列优先存储(换句话说就是同序存储),下标规则采用数学中的定义,即Mrc,r代表第几行,或者说y坐标,c代表第几列,或者说x坐标.

然后是编写JNI部分的代码,通常来说这时候要靠javah来生成ArrayMatrix4f对应C代码头,不过如果已经熟悉了Java的native方法与JNI中的C函数名的对应关系的话,可以跳过这一步.不考虑重载时,JNI中的C函数命名格式是"Java_[用下划线代替点的包名]_[类名]_[方法名]",非静态方法的话头两个参数是JNIEnv*和代表调用对象的jobject,静态方法则是JNIEnv*和jclass,之后就是一一对应方法中的参数,数组使用jXXXArray,对象使用jobject,其余的基本类型都是名字前加个'j'.

#include <jni.h>
#include <immintrin.h>
#include <memory.h>

#ifdef __cplusplus
extern "C" {
#endif

JNIEXPORT void JNICALL Java_jnimath_ArrayMatrix4f_mul(JNIEnv *env, jclass klass, jfloatArray mat1, jfloatArray mat2)
{
}

#ifdef __cplusplus
}
#endif

现在已经有了一个函数体,但还没有SSE部分,过去要编写使用SSE指令集的东西需要汇编,不过现在头文件immintrin.h提供了一些常用的指令封装,包括寄存器分配都可以由编译器来完成.关于SSE的使用我确实不是很擅长...但显然网上早有人写过用SSE解矩阵乘法,这里我参照了Gist上的一篇:

#define DIFFERENT_ORDER 0

static inline void lincomb_SSE(const float *a, const __m128 b[4], float *out)
{
	__m128 result;
	__m128 column = _mm_load_ps(a);
	result = _mm_mul_ps(_mm_shuffle_ps(column, column, 0x00), b[0]);
	result = _mm_add_ps(result, _mm_mul_ps(_mm_shuffle_ps(column, column, 0x55), b[1]));
	result = _mm_add_ps(result, _mm_mul_ps(_mm_shuffle_ps(column, column, 0xaa), b[2]));
	result = _mm_add_ps(result, _mm_mul_ps(_mm_shuffle_ps(column, column, 0xff), b[3]));
	_mm_store_ps(out, result);
}

void matmult_SSE(float *A, const float *B)
{
	_MM_ALIGN16 float mA[16], mB[16];
#if DIFFERENT_ORDER
	float *out = mA;
	memcpy(mA, A, 16 * sizeof(float));
	memcpy(mB, B, 16 * sizeof(float));
#else
	_MM_ALIGN16 float out[16];
	memcpy(mB, A, 16 * sizeof(float));
	memcpy(mA, B, 16 * sizeof(float));
#endif
	__m128 Bcolumns[] = { 
		_mm_load_ps(mB + 0),
		_mm_load_ps(mB + 4),
		_mm_load_ps(mB + 8),
		_mm_load_ps(mB + 12)
	};
	lincomb_SSE(mA + 0,  Bcolumns, out + 0);
	lincomb_SSE(mA + 4,  Bcolumns, out + 4);
	lincomb_SSE(mA + 8,  Bcolumns, out + 8);
	lincomb_SSE(mA + 12, Bcolumns, out + 12);
	memcpy(A, out, 16 * sizeof(float));
}

宏DIFFERENT_ORDER定义矩阵是否是异序存储,如果不考虑计算结果该如何输出的话,同序和异序的计算其实是基本相似的,只要把两个矩阵交换一下就行,比如对应同序的算法Mul(left, right),在计算两个同序矩阵A和B相乘时可以直接带入Mul(A, B),如果要计算两个异序矩阵B和A相乘时代入Mul(B, A)就行.如果要考虑输出的话就稍微有点麻烦,这里最初是为异序矩阵的计算而设计的,计算结果无需额外的临时变量就可写回原矩阵中,在同序版本中还需要额外的临时变量.
此外SSE指令要求数据地址为16位对齐,而Java我忘了是8位对齐还是4位对齐来的,总之需要先复制入一段已经对齐的临时变量,然后才能使用.宏_MM_ALIGN16就可以实现内存对齐.
这里总共使用了5种SSE指令,_mm_load_ps和_mm_store_ps是将数据读入到128位宽的XMM寄存器和从中读出,_mm_add_ps和_mm_mul_ps分别是两组XMM寄存器的相加和相乘,比较特殊的指令是_mm_shuffle_ps,它的作用是对两个XMM寄存器进行混洗,从中选取特定的数据并存入另一个寄存器,不过这里用到的是它的另一种玩法:从一个XMM寄存器的4个32位分量中选出1个并填满另一个XMM寄存器.__m128代表一个XMM寄存器,它在这里只是一个占位符,而不代表一个实际的变量,然而不同的编译器对它的实现不同,在VCC中__m128似乎表现的像一个普通变量一样,你甚至可以对它memcpy...好吧不是很懂你们C,这里还是用最保险的做法,用_mm_load_ps和_mm_store_ps老老实实地向寄存器写入写出.
算法的核心部分在lincomb_SSE,它是计算新矩阵中的某一行,它的原理解释起来有点绕...我们先来解释一下最容易的异序版本,也就是行优先存储.
比如对于两个矩阵:
A B C D 1 2 3 4
E F G H 5 6 7 8
I J K L 9 10 11 12
M N O P 13 14 15 16
要计算它们相乘后的第一行,那就需要计算:
1A+5B+9C+13D
2A+6B+10C+14D
3A+7B+11C+15D
4A+8B+12C+16D
在SISD计算中,这是16个乘法和12个加法,然而你会注意到它的计算极为有规则,先竖着看第一列,它是左矩阵的第一行的第一个元素乘以右矩阵中的一行,再看第二列,是左矩阵第一行第二个元素乘以右矩阵中的第二行,以此类推,SSE可以一次处理4个数据的计算,那么这些计算将变成4个乘法和3个加法:
T=(A)*(1,2,3,4) ((A)为(A,A,A,A)的简写)
T+=(B)*(5,6,7,8)
T+=(C)*(9,10,11,12)
T+=(D)*(13,14,15,16)
最后结果就是:
T=(1A+5B+9C+13D, 2A+6B+10C+14D, 3A+7B+11C+15D, 4A+8B+12C+16D)
正好是第一行的计算结果!
这就是lincomb_SSE的计算原理,其中_mm_shuffle_ps是负责将(A, B, C, D)混洗为形如(A, A, A, A)的形式.此外你应该注意到了左矩阵中每一行只使用一次,这也是为什么异序存储时数据可以直接写回到原矩阵当中.

然而对于同序存储就有些变化,上面的矩阵就会变成:
A E I M 1 5 9 13
B F J N 2 6 10 14
C G K O 3 7 11 15
D H L P 4 8 12 16
此时如果仍要计算第一行,那么需要计算的内容就会变成:
1A+2E+3I+4M
5A+6E+7I+8M
9A+10E+11I+12M
13A+14E+15I+16M
这种数据确实有规律,但不便于我们抓取,别忘了SSE是抓取连续的数据,对列矩阵而言,列优先存储意味着左右两个矩阵的存储顺序分别是ABCD和1234,AEIM和15913这样分开存储的数据想要抓取太难了,因此这里稍微变通一下,改去计算第一列:
1A+2E+3I+4M
1B+2F+3J+4N
1C+2G+3K+4O
1D+2H+3L+4P
这一会数据就变得好抓取了,仔细观察一下,会发现它的计算方式和之前异序计算时十分相似,就像只是颠倒了一下左右矩阵的抓取规则一样...这就是为什么上文提到要使用不同存储顺序的算法时只要颠倒传入参数的顺序就行.
1 5 9 13 A E I M
2 6 10 14 B F J N
3 7 11 15 C G K O
4 8 12 16 D H L P
(1)*(A, B, C, D)
(2)*(E, F, G, H)
(3)*(I, J, K, L)
(4)*(M, N, O, P)
T=(1A+2E+3I+4M, 1B+2F+3J+4N, 1C+2G+3K+4O, 1D+2H+3L+4P)
唯一的问题在于这样做的话数据会被写回到矩阵B而不是矩阵A,因此我之前定义在同序计算时会使用额外的一组临时变量来缓存计算结果.

接下来就该把SSE计算应用在mul函数中了,在正常的流程当中,JNI访问数组是通过GetXXXArrayElements.因此这里我们的代码是:

JNIEXPORT void JNICALL Java_jnimath_ArrayMatrix4f_mul(JNIEnv *env, jclass klass, jfloatArray mat1, jfloatArray mat2)
{
	jboolean isCopyA, isCopyB;
	float *A = env->GetFloatArrayElements(mat1, &isCopyA);
	float *B = env->GetFloatArrayElements(mat2, &isCopyB);
	matmult_SSE(A, B);
	env->ReleaseFloatArrayElements(mat1, A, 0);
	env->ReleaseFloatArrayElements(mat2, B, JNI_ABORT);
}

(如果在env->那有报错的话,将编译模式设为C++)

接下来写个什幺小程序测试一下吧,这里我用JOML作为测试的对比对象,需要注意的是,JOML的下标比较奇特,采用的是Mxy的格式,或者说是Mcr,与数学上的顺序正好相反,我差点被这个特性坑了...

(此处脑补一个测试程序★)

.
..
...
....
.....
......

最终的测试结果:

SSE版本:189ns
JOML版本:26ns

......



!
(









)

这结果有点猎奇啊! 我确实没打错,SSE版本整整慢了一位数!这是因为JNI调用时的开销太大了,完全冲抵了SSE的优势,有没有降低JNI开销的办法?有,网上搜一下就会发现JNI中还有CriticalArray这种东西,简单地说,在进行JNI操作时,Java还会有很多检查,此外JNI也不能让它无限制地做下去,因为JVM在进行垃圾回收时会有Stop-the-world的操作,也就是暂停整个虚拟机以获取一个确定的当前状态,CriticalArray的原理就是跳过一些"多余"的检查,并进入一个禁止JVM进行垃圾回收的临界区,以此来获得性能上的提升.

启用CriticalArray十分简单,将GetFloatArrayElements和ReleaseFloatArrayElements换成GetPrimitiveArrayCritical和ReleasePrimitiveArrayCritical就行了.

JNIEXPORT void JNICALL Java_jnimath_ArrayMatrix4f_mul(JNIEnv *env, jclass klass, jfloatArray mat1, jfloatArray mat2)
{
	jboolean isCopyA, isCopyB;
	float *A = static_cast<float*>(env->GetPrimitiveArrayCritical(mat1, &isCopyA));
	float *B = static_cast<float*>(env->GetPrimitiveArrayCritical(mat2, &isCopyB));
	matmult_SSE(A, B);
	env->ReleasePrimitiveArrayCritical(mat1, A, 0);
	env->ReleasePrimitiveArrayCritical(mat2, B, JNI_ABORT);
}

接下来,带着满怀憧憬的心情,再次测试一下★

.
..
...
....
.....
......

最终的测试结果:

SSE版本:61.2ns
JOML版本:25.6ns

......

zzz

最终结论是JNI是扶不起的阿斗,SSE再怎么优化也敌不过50ns的调用开销,甲骨文你妈妈飞了(龟壳:尼玛这是Sun开发的这锅我不背啊!),全文完,QED.

20160410040105

.
..
...
....
.....
......
.....
....
...
..
.

(多年之后★)

显然进度条告诉你本文仍未结束,事实上,如果是在几年前,甚至是十几天前,如果有人做这方面的研究的话,那么他只能得出"甲骨文飞妈"的结论然后弃坑,其实当初Sun也觉得JNI调用有些慢的坑人,于是正如sun.misc.Unsafe这个"后门"大合集一样,Sun在JNI上也给自己开了个后门,它的名字叫做CriticalNative,它是一个内部API,没有任何官方文档描述它的作用,它的代码犹如在嘲笑Linus定律一样,大张旗鼓地四处穿插在JVM源码的最显眼的位置当中,但在数年间却没有任何人注意到,或者问到它的作用,它最正式的文档也不过是JDK Bug System中一条低调的记录,直到前几天在栈爆上有人爆料了这个彩蛋的存在.
CriticalNative是一种特殊的JNI函数,它整个是一个临界区,能够以牺牲JVM整体稳定性获取最大的性能;由于最初是被设计为JRE的加密模块使用,考虑到现在的加密算法大多以块为单位,换句话说大多数情况下需要在JNI中频繁传递小规模的数组,CriticalNative被专门设计对数组的传递进行优化,正好符合我们的需求.
想让一个JNI函数成为CriticalNative,需要如下修改/条件:

  • JRE7或更高版本下的Hotspot虚拟机
  • JNI函数的前缀由"Java_"改为"JavaCritical_"
  • 必须是static方法,不能有synchronized
  • 参数中不能有对象、对象数组或多维数组
  • 原先的普通版本("Java_"开头的)不能去掉

在成为CriticalNative后,函数有如下特性.

  • 参数中没有了JNIEnv*和jclass/jobject,基本类型参数不变,数组分成2个参数,头一个参数为jint,代表数组长度,后一个参数为jXXX*,即相应类型的指针
  • 由于没有了JNIEnv,显然函数中无法调用任何Java的东西
  • 整个函数成为临界区,会阻碍垃圾回收的进行

此外,CriticalNative还有个奇(keng)特(die)的特性,就是懒加载,在最初的一定次数调用中,JVM始终调用的是正常版本,只有达到一定阈值后,才会开始调用CriticalNative版本,这个特性当初也把我坑过几次.这个阈值和时间无关,只和调用次数有关.

给刚才的mul函数制作CriticalNative版本十分简单,只需要几行代码:

JNIEXPORT void JNICALL JavaCritical_jnimath_ArrayMatrix4f_mul(jint length1, jfloat* mat1, jint length2, jfloat* mat2)
{
	matmult_SSE(mat1, mat2);
}

多么简单! 就是这么简单的一个"JavaCritical_"前缀,当初难倒了多少英雄汉!

现在再测试一下,不知道结果会是什么样呢?

.
..
...
....
.....
......

最终的测试结果:

SSE版本:14.6ns
JOML版本:25.6ns

20160410042640

180%的效率! 这一次SSE终于显示出功效了! 在神奇的CriticalNative的帮助下,SSE版的矩阵乘法终于从180ns的调用事件蜕变成180%的执行效率.当年多少优化狂魔的SIMD梦想终于得以实现了!

不过,你显然也会想纯Java版本的执行速度已经相当快了,一个JNI版本还有必要吗?唔...其实有些跨平台游戏库是使用JNI来处理复杂的数学运算的(比如LibGDX,咳...) 主要的原因是Android端做数学运算实在是性能不咋样,只有靠JNI调用C版本才能勉强提升性能,比如7年前(哎,那时候我还不是车万厨(笑))Android的FloatMath库是使用JNI写的,官方称它是JDK的Math的3倍快,这说明了在早期的Android中JNI是多么有效率(如果你问它现在怎么样的话,1年半之前FloatMath被去掉了JNI部分,被改写成了JDK Math的简单封装,到了Android6则被彻底移除了...) 为此一些跨平台库就要连累桌面端一起用JNI. CriticalNative的出现使得桌面端获得一次免费的性能提升机会,甚至比纯Java版本还要快!

此外,JNI调用慢的问题龟壳也在解决,代号为Project Panama的Java扩展就是要解决JNI调用慢的问题,目前虽然进度良好(据说最新的原型已经可以在JIT中将JNI函数内联入JIT代码了),但似乎仍不够赶上Java9的特性结冻的Deadline,只有被推迟到Java10了,无论如何,祝它好运!