我正在尝试优化一个小型库,用于对向量进行算术。
为了大致检查我的进度,我决定对用两种不同语言编写的两个流行的向量算术库的性能进行基准测试,即GNU科学库(GSL,C)和Java OpenGL数学库(JOML,JVM)。我预计,GSL作为一个用C编写并提前编译的大型项目,将比JOML快得多,它包含了来自对象管理、方法调用和符合Java规范的额外负担。
令人惊讶的是,JOML (JVM)最终比GSL (C)快4倍左右。我想知道为何会这样。
我所做的基准是计算4,000,000次莱布尼茨公式的迭代来计算Pi,每次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代码快几倍。我不认为其中任何一项特别有说服力:
,
-O3和-march=native编译了C,并根据Debian包中的库静态地链接。在另一种情况下,我甚至尝试了-floop/-ftree并行化标志。无论哪种方式,性能并没有真正改变。.jar文件中导入,并且只包含.class文件,而且JNI在每个调用中都添加了~20 Java ops of overhead,所以即使它有神奇的本机代码,在这样的粒度级别上也不会有帮助。。
我所能想到的唯一剩下的解释是,在C中花费的大部分时间是执行函数调用的开销。这似乎与C和Cython中的实现执行类似的事实是一致的。然后,Java/Kotlin/JVM实现的性能优势来自于它的JIT能够通过有效地内联循环中的所有内容来优化函数调用。然而,考虑到JIT编译器在理论上的声誉,在有利的条件下比本机代码略快,这似乎仍然是一个巨大的加速,仅仅是有一个JIT。
我想如果是这样的话,那么一个后续的问题是,我是否能够现实地或可靠地预期这些性能特性将超越一个合成玩具基准,在可能有更多零散的数值调用而不是一个百万次迭代循环的应用程序中。
发布于 2022-10-09 09:41:52
首先,免责声明:我是JOML的作者。
现在,你可能不是在比较苹果和这里的苹果。GSL是一个通用的线性代数库,支持许多不同的线性代数算法和数据结构。
另一方面,JOML不是一个通用的线性代数库,而是一个只包含计算图形用例的特殊用途库,因此它只包含非常具体的类,仅适用于2-、3-和4维向量以及2x2、3x3和4x4 (和非平方变量)矩阵。换句话说,即使您想分配一个5维向量,也不能使用JOML。
因此,JOML中的所有算法和数据结构都是在具有x、y、z和w字段的类上显式设计的。没有任何循环。所以,一个四维向量加法实际上就是:
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实际上使用的是本机代码。README说它不进行JNI调用,我直接从我检查过的多平台.jar文件中导入,并且只包含.class文件,而且JNI为每个调用增加了大约20次Java操作的开销,所以即使它有神奇的本地代码,在这样的粒度级别上也不会有任何帮助。
JOML本身并不通过JNI接口定义和使用本机代码。当然,JOML内部使用的运算符和JRE方法将被插入到本机代码中,而不是通过JNI接口。相反,在JIT编译时,所有方法(如Math.fma())都会直接被插入到它们的机器代码等价物中。
现在,正如其他人在对您的问题的评论中指出的那样:您使用的是链接库(而不是像GLM这样只使用头库,这可能更适合您的C/C++代码)。因此,C/C++编译器可能无法“看穿”调用站点到被调用方,并根据调用站点上的静态信息(就像使用参数4调用4)在那里应用优化。因此,对GSL需要执行的参数进行每次运行时检查/分支,仍然必须在运行时进行。这与使用只使用标头库(如GLM)时非常不同,在这种情况下,任何半体面的C/C++都肯定会根据调用/代码的静态知识来优化所有内容。我假设,是的,一个等价的C/C++程序会在速度上击败Java/Scala/Kotlin/JVM程序。
因此,您对GSL和JOML的比较有点像将Microsoft与内容= 1 + 2计算单元格的性能进行比较,并编写有效输出printf("%f\n", 1.0 + 2.0);的C代码。前者(Microsoft Excel,这里是GSL)更为通用和通用,而后者(JOML)则是高度专业化的。
正因为如此,专门化现在正好适合您的具体用例,甚至有可能为此使用JOML。
https://stackoverflow.com/questions/74001546
复制相似问题