首页
学习
活动
专区
圈层
工具
发布
社区首页 >问答首页 >为什么这个JOML (JVM)代码比等效的GSL (C)快得多?

为什么这个JOML (JVM)代码比等效的GSL (C)快得多?
EN

Stack Overflow用户
提问于 2022-10-09 01:23:33
回答 1查看 192关注 0票数 5

我正在尝试优化一个小型库,用于对向量进行算术。

为了大致检查我的进度,我决定对用两种不同语言编写的两个流行的向量算术库的性能进行基准测试,即GNU科学库(GSL,C)和Java OpenGL数学库(JOML,JVM)。我预计,GSL作为一个用C编写并提前编译的大型项目,将比JOML快得多,它包含了来自对象管理、方法调用和符合Java规范的额外负担。

令人惊讶的是,JOML (JVM)最终比GSL (C)快4倍左右。我想知道为何会这样。

我所做的基准是计算4,000,000次莱布尼茨公式的迭代来计算Pi,每次4块,通过四维向量计算。确切的算法并不重要,也不一定有意义。这只是我想到的第一件也是最简单的事情--让我每次迭代使用多个向量操作。

这是所讨论的C代码:

代码语言:javascript
复制
#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代码的:

代码语言:javascript
复制
$ 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编写):

代码语言:javascript
复制
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平台代码的:

代码语言:javascript
复制
$ 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-march=native编译了C,并根据Debian包中的库静态地链接。在另一种情况下,我甚至尝试了-floop/-ftree并行化标志。无论哪种方式,性能并没有真正改变。
  • GSL有额外的特性,在这个特定的测试中增加了开销。-我还有另一个版本,它通过Cython实现和使用向量类,它只执行基本操作(遍历指针),并大致相当于GSL (如预期的那样具有更多的开销)。因此,这似乎是本机代码的限制。
  • JOML实际上使用的是本机代码。README说它不进行JNI调用,我直接从我检查过的多平台.jar文件中导入,并且只包含.class文件,而且JNI在每个调用中都添加了~20 Java ops of overhead,所以即使它有神奇的本机代码,在这样的粒度级别上也不会有帮助。
  • 对于浮点算法有不同的细节。-我使用的JOML类接受并返回"doubles“,就像C代码一样。无论如何,必须模拟偏离硬件功能的规范可能不应该像这样提高性能。
  • --我的GSL代码中的指数倒数步骤--比我的JOML代码中的除法倒数步骤效率低。-虽然out确实使总的执行时间减少了大约25% (到0.045s),但与JVM代码(~0.015s)仍有很大的差距。

我所能想到的唯一剩下的解释是,在C中花费的大部分时间是执行函数调用的开销。这似乎与C和Cython中的实现执行类似的事实是一致的。然后,Java/Kotlin/JVM实现的性能优势来自于它的JIT能够通过有效地内联循环中的所有内容来优化函数调用。然而,考虑到JIT编译器在理论上的声誉,在有利的条件下比本机代码略快,这似乎仍然是一个巨大的加速,仅仅是有一个JIT。

我想如果是这样的话,那么一个后续的问题是,我是否能够现实地或可靠地预期这些性能特性将超越一个合成玩具基准,在可能有更多零散的数值调用而不是一个百万次迭代循环的应用程序中。

EN

回答 1

Stack Overflow用户

回答已采纳

发布于 2022-10-09 09:41:52

首先,免责声明:我是JOML的作者。

现在,你可能不是在比较苹果和这里的苹果。GSL是一个通用的线性代数库,支持许多不同的线性代数算法和数据结构。

另一方面,JOML不是一个通用的线性代数库,而是一个只包含计算图形用例的特殊用途库,因此它只包含非常具体的类,仅适用于2-、3-和4维向量以及2x2、3x3和4x4 (和非平方变量)矩阵。换句话说,即使您想分配一个5维向量,也不能使用JOML。

因此,JOML中的所有算法和数据结构都是在具有xyzw字段的类上显式设计的。没有任何循环。所以,一个四维向量加法实际上就是:

代码语言:javascript
复制
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。

票数 4
EN
页面原文内容由Stack Overflow提供。腾讯云小微IT领域专用引擎提供翻译支持
原文链接:

https://stackoverflow.com/questions/74001546

复制
相关文章

相似问题

领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档