我正在嘗試優化一個小型庫來對向量進行算術運算。
為了粗略檢查我的進度,我決定對用兩種不同語言撰寫的兩個流行向量算術庫的性能進行基準測驗,GNU 科學庫(GSL,C)和 Java OpenGL 數學庫(JOML,JVM)。我預計 GSL 作為一個用 C 語言撰寫并提前編譯的大型專案,比 JOML 快得多,并且具有來自物件管理、方法呼叫和符合 Java 規范的額外包袱。
令人驚訝的是,JOML (JVM) 最終比 GSL (C) 快了大約 4 倍。我想了解為什么會這樣。
我執行的基準測驗是計算萊布尼茨公式的 4,000,000 次迭代來計算 Pi,通過 4 維向量一次 4 個塊。確切的演算法無關緊要,也不必有意義。這只是我想到的第一個也是最簡單的事情,它可以讓我在每次迭代中使用多個向量操作。
這是有問題的C代碼:
#include <stdio.h>
#include <time.h>
#include <gsl/gsl_vector.h>
#include <unistd.h>
#include <math.h>
#include <string.h>
#define IT 1000000
double pibench_inplace(int it) {
gsl_vector* d = gsl_vector_calloc(4);
gsl_vector* w = gsl_vector_calloc(4);
for (int i=0; i<4; i ) {
gsl_vector_set(d, i, (double)i*2 1);
gsl_vector_set(w, i, (i%2==0) ? 1 : -1);
}
gsl_vector* b = gsl_vector_calloc(4);
double pi = 0.0;
for (int i=0; i<it; i ) {
gsl_vector_memcpy(b, d);
gsl_vector_add_constant(b, (double)i*8);
for (int i=0; i<4; i ) {
gsl_vector_set(b, i, pow(gsl_vector_get(b, i), -1.));
}
gsl_vector_mul(b, w);
pi = gsl_vector_sum(b);
}
return pi*4;
}
double pibench_fast(int it) {
double pi = 0;
int eq_it = it * 4;
for (int i=0; i<eq_it; i ) {
pi = (1 / ((double)i * 2 1) * ((i%2==0) ? 1 : -1));
}
return pi*4;
}
int main(int argc, char* argv[]) {
if (argc < 2) {
printf("Please specific a run mode.\n");
return 1;
}
double pi;
struct timespec start = {0,0}, end={0,0};
clock_gettime(CLOCK_MONOTONIC, &start);
if (strcmp(argv[1], "inplace") == 0) {
pi = pibench_inplace(IT);
} else if (strcmp(argv[1], "fast") == 0) {
pi = pibench_fast(IT);
} else {
sleep(1);
printf("Please specific a valid run mode.\n");
}
clock_gettime(CLOCK_MONOTONIC, &end);
printf("Pi: %f\n", pi);
printf("Time: %f\n", ((double)end.tv_sec 1.0e-9*end.tv_nsec) - ((double)start.tv_sec 1.0e-9*start.tv_nsec));
return 0;
}
這就是我構建和運行 C 代碼的方式:
$ gcc GSL_pi.c -O3 -march=native -static $(gsl-config --cflags --libs) -o GSL_pi && ./GSL_pi inplace
Pi: 3.141592
Time: 0.061561
這是有問題的 JVM 平臺代碼(用 Kotlin 撰寫):
package joml_pi
import org.joml.Vector4d
import kotlin.time.measureTimedValue
import kotlin.time.DurationUnit
fun pibench(count: Int=1000000): Double {
val d = Vector4d(1.0, 3.0, 5.0, 7.0)
val w = Vector4d(1.0, -1.0, 1.0, -1.0)
val c = Vector4d(1.0, 1.0, 1.0, 1.0)
val scratchpad = Vector4d()
var pi = 0.0
for (i in 0..count) {
scratchpad.set(i*8.0)
scratchpad.add(d)
c.div(scratchpad, scratchpad)
scratchpad.mul(w)
pi = scratchpad.x scratchpad.y scratchpad.z scratchpad.w
}
return pi * 4.0
}
@kotlin.time.ExperimentalTime
fun <T> benchmark(func: () -> T, name: String="", count: Int=5) {
val times = mutableListOf<Double>()
val results = mutableListOf<T>()
for (i in 0..count) {
val result = measureTimedValue<T>( { func() } )
results.add(result.value)
times.add(result.duration.toDouble(DurationUnit.SECONDS))
}
println(listOf<String>(
"",
name,
"Results:",
results.joinToString(", "),
"Times:",
times.joinToString(", ")
).joinToString("\n"))
}
@kotlin.time.ExperimentalTime
fun main(args: Array<String>) {
benchmark<Double>(::pibench, "pibench")
}
這就是我構建和運行 JVM 平臺代碼的方式:
$ kotlinc -classpath joml-1.10.5.jar JOML_pi.kt && kotlin -classpath joml-1.10.5.jar:. joml_pi/JOML_piKt.class
pibench
Results:
3.1415924035900464, 3.1415924035900464, 3.1415924035900464, 3.1415924035900464, 3.1415924035900464, 3.1415924035900464
Times:
0.026850784, 0.014998012, 0.013095291, 0.012805373, 0.012977388, 0.012948186
我考慮了多種可能性,為什么這個操作在 JVM 中運行顯然比等效的 C 代碼快幾倍。我不認為其中任何一個特別引人注目:
- 我在兩種語言中按數量級進行不同的迭代計數。— 可能我嚴重誤讀了代碼,但我很確定情況并非如此。
- 我已經捏造了演算法,并且在每種情況下都在做截然不同的事情。— 再次,也許我誤讀了它,但我不認為這種情況正在發生,而且這兩種情況都會產生數字上正確的結果。
- 我用于 C 的計時機制引入了很多開銷。— 我還測驗了更簡單和無操作的函式。它們在更短的時間內完成并按預期進行了測量。
- JVM 代碼在多個處理器內核上并行化——隨著更多的迭代,我觀察了我的 CPU 使用時間更長,它沒有超過一個內核。
- JVM 代碼更好地利用了 SIMD/向量化。
-O3— 我用and編譯了 C-march=native,靜態鏈接來自 Debian 包的庫。在另一種情況下,我什至嘗試了-floop/-ftree并行化標志。無論哪種方式,性能都沒有真正改變。 - GSL 有額外的特性,在這個特定的測驗中增加了開銷。— 我還有另一個版本,通過 Cython 實作和使用矢量類,它只做基礎(迭代指標),并且執行大致相當于 GSL(如預期的那樣,開銷稍大)。所以這似乎是本機代碼的限制。
- JOML 實際上是使用本機代碼。— 自述檔案說它不進行 JNI 呼叫,我直接從一個
.jar我檢查過且僅包含.class檔案的多平臺檔案匯入,并且 JNI 為每個呼叫增加了大約 20 個 Java 操作的開銷,所以即使它有魔法在如此精細的級別上無論如何都不應該提供幫助的本機代碼。 - JVM 對浮點運算有不同的細節。— 我使用的 JOML 類接受并回傳“雙精度”,就像 C 代碼一樣。無論如何,必須模擬偏離硬體功能的規范可能不應該像這樣提高性能。
- 我的 GSL 代碼中的指數倒數步驟的效率低于我的 JOML 代碼中的除法倒數步驟。— 雖然評論說它確實將總執行時間減少了大約 25%(約 0.045 秒),但這仍然與 JVM 代碼(約 0.015 秒)留下了 3 倍的巨大差距。
我能想到的唯一剩下的解釋是,花在 C 語言中的大部分時間都是函式呼叫的開銷。這似乎與 C 和 Cython 中的實作執行相似的事實一致。然后,Java/Kotlin/JVM 實作的性能優勢來自其 JIT 能夠通過有效行內回圈中的所有內容來優化函式呼叫。然而,鑒于 JIT 編譯器的聲譽僅在理論上,在有利條件下比本機代碼略快,這似乎仍然是由于擁有 JIT 帶來的巨大加速。
我想如果是這種情況,那么后續問題將是我是否可以現實或可靠地期望這些性能特征能夠在合成玩具基準測驗之外延續,在可能有更多分散的數字呼叫而不是單個的應用程式中百萬迭代回圈。
uj5u.com熱心網友回復:
首先,免責宣告:我是 JOML 的作者。
現在,您可能不會在這里將蘋果與蘋果進行比較。GSL 是一個通用線性代數庫,支持許多不同的線性代數演算法和資料結構。
另一方面,JOML不是通用線性代數庫,而是僅涵蓋計算圖形用例的專用庫,因此它僅包含非常具體的類,僅用于2 維、3 維和 4 維向量和2x2、3x3 和 4x4(以及非方形變體)矩陣。換句話說,即使你想分配一個 5 維向量,你也不能用 JOML。
因此,JOML 中的所有演算法和資料結構都明確地設計在具有x、y和欄位的類上。沒有任何回圈。因此,一個 4 維向量加法實際上就是:zw
dest.x = this.x v.x;
dest.y = this.y v.y;
dest.z = this.z v.z;
dest.w = this.w v.w;
甚至沒有任何 SIMD 參與其中,因為到目前為止,還沒有 JVM JIT 可以在類的不同欄位上自動矢量化。因此,現在的向量加法(或乘法;或任何通道)操作將準確地產生這些標量操作。
接下來,你說:
JOML 實際上是使用本機代碼。— 自述檔案說它不進行 JNI 呼叫,我直接從我檢查過的多平臺 .jar 檔案匯入,并且只包含 .class 檔案,并且 JNI 為每個呼叫增加了大約 20 個 Java 操作的開銷,所以即使如果它有神奇的本機代碼,無論如何都不應該在這樣的粒度級別上提供幫助。
JOML 本身并不通過 JNI 介面定義和使用本機代碼。當然,JOML 內部使用的運算子和 JRE 方法將被內置到本機代碼中,但不會通過 JNI 介面。相反,所有方法(例如Math.fma())都將在 JIT 編譯時直接內置到它們的機器代碼等效項中。
現在,正如其他人在對您的問題的評論中指出的那樣:您正在使用鏈接庫(而不是像 GLM 這樣的僅標頭庫,這可能更適合您的 C/C 代碼)。因此,C/C 編譯器可能無法將您的呼叫站點“透視”到被呼叫者,并根據它在呼叫站點的靜態資訊應用優化(就像您gsl_vector_calloc使用引數呼叫一樣4)。因此,對 GSL 需要做的引數的每次運行時檢查/分支仍然必須在運行時發生。這與使用僅包含標頭的庫(如 GLM)時完全不同,任何半體面的 C/C 肯定會根據您的呼叫/代碼的靜態知識優化所有內容。我會假設,是的,等效的 C/C 程式會在速度上擊敗 Java/Scala/Kotlin/JVM 程式。
因此,您對 GSL 和 JOML 的比較有點像比較 Microsoft Excel 評估具有內容的單元格的性能= 1 2與撰寫有效輸出的 C 代碼的性能printf("%f\n", 1.0 2.0);。前者(Microsoft Excel,這里是 GSL)更加通用和通用,而后者(JOML)則高度專業化。
碰巧該專業化現在適合您的確切用例,甚至可以為此使用 JOML。
轉載請註明出處,本文鏈接:https://www.uj5u.com/qiye/513600.html
標籤:爪哇C表现虚拟机基准测试
上一篇:車頂線模型的優化方法
