使用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了,無論如何,祝它好運!