Java与Rust性能比较

12

我在Java和Rust上运行了一个相同的小基准测试。

Java:


Translated:

我在Java和Rust上运行了一个相同的小基准测试。

Java:

public class Main {
    private static final int NUM_ITERS = 100;

    public static void main(String[] args) {
        long tInit = System.nanoTime();
        int c = 0;

        for (int i = 0; i < NUM_ITERS; ++i) {
            for (int j = 0; j < NUM_ITERS; ++j) {
                for (int k = 0; k < NUM_ITERS; ++k) {
                    if (i*i + j*j == k*k) {
                        ++c;
                        System.out.println(i + " " + j + " " + k);
                    }
                }
            }
        }

        System.out.println(c);
        System.out.println(System.nanoTime() - tInit);
    }
}

Rust:

use std::time::SystemTime;

const NUM_ITERS: i32 = 100;

fn main() {
    let t_init = SystemTime::now();
    let mut c = 0;

    for i in 0..NUM_ITERS {
        for j in 0..NUM_ITERS {
            for k in 0..NUM_ITERS {
                if i*i + j*j == k*k {
                    c += 1;
                    println!("{} {} {}", i, j, k);
                }
            }
        }
    }

    println!("{}", c);
    println!("{}", t_init.elapsed().unwrap().as_nanos());
}

NUM_ITERS = 100时,如预期,Rust的表现优于Java。

Java: 59311348 ns
Rust: 29629242 ns

但对于NUM_ITERS = 1000,我发现Rust花费的时间更长,而Java则更快。

Java: 1585835361  ns
Rust: 28623818145 ns

这是什么原因呢?难道Rust在这种情况下表现不比Java更好吗?还是我的实现中出现了一些错误?

更新

我从代码中删除了System.out.println(i + " " + j + " " + k);println!("{} {} {}", i, j, k);这两行。这是输出结果:

NUM_ITERS = 100
Java: 3843114  ns
Rust: 29072345 ns


NUM_ITERS = 1000
Java: 1014829974  ns
Rust: 28402166953 ns
因此,在没有 println 语句的情况下,Java 在两种情况下的性能均优于 Rust。我只是想知道为什么会出现这种情况。Java 运行 Garbage Collector 和其他开销。我没有最佳地在 Rust 中实现循环吗?

4
你是在生产模式下还是调试模式下编译 Rust? - Netwave
4
没有一个基准测试可以输出每迭代的结果。你所测量的是I/O而不是CPU时间。 - user207421
3
虽然在测量过程中根本不应该打印任何内容,但我认为上面的评论者们忽略了一个事实,即在1百万次测试组合中仅有299次符合条件 a2 + b2 = c2(使用NUM_ITERS = 100)。 - Marko Topolnik
3
在IntelliJ中,运行 cargo run --release 以运行优化的代码。 - Deadbeef
3
如果重新开放,我很乐意分享一个改进版(在回答中),经过优化的 Rust 构建结果是每次迭代0.6纳秒,Java 的结果是每次迭代0.32纳秒。我并不觉得这个结果令人惊讶,因为此处没有分配任何内存,所以垃圾回收并不重要,而且Java的即时编译器非常擅长优化简单的代码。 - Marko Topolnik
显示剩余14条评论
2个回答

17

我修改了你的代码,消除了评论中提出的批评点。不将Rust编译用于生产是最大的问题,这会导致50倍的开销。此外,我在测量时消除了打印,并对Java代码进行了适当的预热。

经过这些更改,我认为Java和Rust是相当的,在这些更改后它们彼此之间的差距不超过2倍,而且每次迭代的成本都非常低(只是纳秒的一小部分)。

这是我的代码:

public class Testing {
    private static final int NUM_ITERS = 1_000;
    private static final int MEASURE_TIMES = 7;

    public static void main(String[] args) {
        for (int i = 0; i < MEASURE_TIMES; i++) {
            System.out.format("%.2f ns per iteration%n", benchmark());
        }
    }

    private static double benchmark() {
        long tInit = System.nanoTime();
        int c = 0;
        for (int i = 0; i < NUM_ITERS; ++i) {
            for (int j = 0; j < NUM_ITERS; ++j) {
                for (int k = 0; k < NUM_ITERS; ++k) {
                    if (i*i + j*j == k*k) {
                        ++c;
                    }
                }
            }
        }
        if (c % 137 == 0) {
            // Use c so its computation can't be elided
            System.out.println("Count is divisible by 13: " + c);
        }
        long tookNanos = System.nanoTime() - tInit;
        return tookNanos / ((double) NUM_ITERS * NUM_ITERS * NUM_ITERS);
    }
}

use std::time::SystemTime;

const NUM_ITERS: i32 = 1000;

fn main() {
    let mut c = 0;

    let t_init = SystemTime::now();
    for i in 0..NUM_ITERS {
        for j in 0..NUM_ITERS {
            for k in 0..NUM_ITERS {
                if i*i + j*j == k*k {
                    c += 1;
                }
            }
        }
    }
    let took_ns = t_init.elapsed().unwrap().as_nanos() as f64;

    let iters = NUM_ITERS as f64;
    println!("{} ns per iteration", took_ns / (iters * iters * iters));
    // Use c to ensure its computation can't be elided by the optimizer
    if c % 137 == 0 {
        println!("Count is divisible by 137: {}", c);
    }
}

我使用JDK 16在IntelliJ中运行Java。我使用命令行运行Rust,使用cargo run --release

Java的输出示例:

0.98 ns per iteration
0.93 ns per iteration
0.32 ns per iteration
0.34 ns per iteration
0.32 ns per iteration
0.33 ns per iteration
0.32 ns per iteration

Rust输出示例:

0.600314 ns per iteration

虽然我并不惊讶于看到Java给出了更好的结果(它的JIT编译器已经优化了20年,而且没有对象分配,因此没有GC),但我对迭代的总体低成本感到困惑。我们可以假设表达式i*i + j*j被提升出内部循环,这样只剩下k*k在其中。
我使用反汇编程序来检查Rust生成的代码。它明确涉及内部循环中的IMUL。我阅读了this答案,它说Intel的IMUL指令的延迟只有3个CPU周期。结合多个ALU和指令并行性,每次迭代1个周期的结果变得更加可信。
我发现的另一个有趣的事情是,如果我只检查c % 137 == 0但不在Rust的println!语句中打印实际值c(只打印“Count is divisible by 137”),迭代成本降至只有0.26 ns。所以当我没有要求c的确切值时,Rust能够从循环中消除大量工作。

更新

如与@trentci在评论中讨论的那样,我更完整地模拟了Java代码,添加了一个外部循环以重复测量,现在该循环在一个单独的函数中:

use std::time::SystemTime;

const NUM_ITERS: i32 = 1000;
const MEASURE_TIMES: i32 = 7;

fn main() {
    let total_iters: f64 = NUM_ITERS as f64 * NUM_ITERS as f64 * NUM_ITERS as f64;
    for _ in 0..MEASURE_TIMES {
        let took_ns = benchmark() as f64;
        println!("{} ns per iteration", took_ns / total_iters);
    }
}

fn benchmark() -> u128 {
    let mut c = 0;

    let t_init = SystemTime::now();
    for i in 0..NUM_ITERS {
        for j in 0..NUM_ITERS {
            for k in 0..NUM_ITERS {
                if i*i + j*j == k*k {
                    c += 1;
                }
            }
        }
    }
    // Use c to ensure its computation can't be elided by the optimizer
    if c % 137 == 0 {
        println!("Count is divisible by 137: {}", c);
    }
    return t_init.elapsed().unwrap().as_nanos();
}

现在我得到了这个输出:
0.781475 ns per iteration
0.760657 ns per iteration
0.783821 ns per iteration
0.777313 ns per iteration
0.766473 ns per iteration
0.774042 ns per iteration
0.766718 ns per iteration

另一处代码微小的变化导致了性能显著提升。然而,这也展示了 Rust 相对于 Java 的一个关键优势:无需热身即可获得最佳性能。


2
在我的机器上运行这两个程序,Java的最佳时间为0.38纳秒,Rust的最佳时间为0.10纳秒。考虑添加rustc标志“-C target-cpu=native”,让编译器使用主机的完整指令集。另外,你使用的是哪个JVM?JIT编译器可能很好,但我们正在测试它与LLVM的预先编译相比,后者通常更具攻击性。 - E net4
@MarkoTopolnik 是的,结果可能会有些令人惊讶。在 Rust 中进行微基准测试时,通常会使用统计驱动的工具,例如 Criterion.rs,它提供了 black_box() 的实现,这是一种旨在避免优化值的构造(否则,std 库中的 black_box 目前需要夜间工具链)。 - E net4
@E_net4thecopycat 我尝试了一下目标CPU标志,得到了更令人惊讶的结果。当打印c时,性能变差(0.67纳秒),但是当不打印它时,每次迭代的时间甚至比你发布的结果更好:仅为0.076纳秒!编译器肯定采取了某些捷径。 - Marko Topolnik
@trentcl 自适应分支预测在纳秒级别上运作,因为您只需要进行两次传递,它就可以完全倾向于一个方向。与频率缩放相反的显着效果是热限制,在笔记本电脑基准测试时我经常观察到这种情况。这在秒级别上工作,在单线程代码上并不是非常显著。至于多次运行,这是一种本能,直到结果至少与之前的结果相差3倍,我才认为它“完全实现”。但是我也可以在Rust中添加循环。 - Marko Topolnik
1
@DivyanshuPundir 是的,这就是我在代码中解释的:使用c来确保其计算不能被优化器省略 - Marko Topolnik
显示剩余8条评论

1

当Java虚拟机的内部循环超过一定次数时,可能会将其即时编译为本地汇编代码。我不是JVM专家,但这可能可以解释为什么更多的迭代次数会神奇地使Java代码运行更快。

这背后的技术被称为HotSpot,据我所知,在“客户端”和“服务器”VM上它的工作方式不同。它还取决于JVM供应商是否可用以及其行为如何。

https://en.wikipedia.org/wiki/HotSpot_(virtual_machine)


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接