Java速度能追上Rust吗?2000万粒子对比性能测试

B站影视 内地电影 2025-10-26 11:35 1

摘要:跑起来了,但不够快:把所有新特性都丢进去,Java 能跑出一个 2000 万粒子的二维模拟,画面能动起来,但性能落后于 Rust,差不多慢一半。画面能动起来,但性能落后于 Rust,差不多慢一半。

跑起来了,但不够快:把所有新特性都丢进去,Java 能跑出一个 2000 万粒子的二维模拟,画面能动起来,但性能落后于 Rust,差不多慢一半。画面能动起来,但性能落后于 Rust,差不多慢一半。

这项目的出发点很直白:作者 David Gerrells 想看看老牌的 Java 能不能靠 CPU 玩出实时渲染这档事,把 SIMD(向量运算)、多线程、流式 API 都扔进来,尽量不动 GPU。设定也简单——二维世界、每个粒子当成一个像素,中间有个“重力点”吸引粒子,全部算力都交给 CPU 来扛。想法听着带劲,实际做起来就碰到一堆现实问题。

从窗口到像素的那一段,写法很复古。原本想用 JavaFX,但拉 jar、配 Gradle/Maven太折腾,最后退回到 Swing:创建 JFrame、定制 Panel、重写 paint,用 BufferedImage 当像素缓冲区,像素先在内存里写好,整张图一次性提交到屏幕。像素数据存在一块扁平的整型数组里,渲染逻辑尽量把工作放到这块内存上,避免频繁地去操作 UI 线程。这套做法就是老 Java 的味道,但好处是跨平台方便,缺点是要自己把各种细节处理好。

性能的关键落在 SIMD 上。Java 现在有个实验性的 Vector API,里面有个叫 species 的东西,用来表示向量的类型和宽度。不同 CPU 支持的向量宽度不一样,API 提供 preferred 和 MAX 两种选项,一般用 preferred 即可。作者在 M1 上发现 preferred 和 MAX 都等于 4 个 float(128 位),就按 preferred 来写,好处是同一份代码能跑在不同硬件上,不必为每种 CPU 单独写不同的指令。

要把 SIMD 和并发结合起来并不容易。第一是数据对齐问题,尾数(那些填不满向量寄存器的剩余元素)要单独处理;第二是线程开销不能忽视。作者一开始用 IntStream.range 的并行流,把粒子按向量通道数量切片并行处理,想法挺聪明,但并行流的调度成本高。性能分析显示,大概有 20% 的时间被 Java 的线程管理和并行迭代器内部开销吃掉了。换言之,很多时间花在分配、调度这些“小动作”上,而不是干真正的数值计算。后来改用工作线程池、复用线程,用 Future 做异步控制,效率明显上来。

在粒子更新的实现上,作者很注意“热路径”——也就是最频繁被执行的那段代码。为避免频繁加锁,更新前先拷贝出一份输入状态快照,后面的更新直接用快照,用户输入(像平移、缩放、减速)在主线程和事件分发线程间做同步,但这些同步都尽量放在非关键路径上。边界反弹的逻辑被简化,SIMD 部分把需要向量化的核心算子提取出来,尾部元素用标量循环单独收尾,代码更直白也更可靠。

渲染是最大的瓶颈,这一点很容易被忽视。粒子到像素的写入往往是随机访问,这会让缓存命中率暴跌,内存访问成为限制因素。作者把渲染从事件分发线程里抽出来,改为主动渲染:用了一个忙等(busy-wait)的主循环,间歇性休眠并在休眠间隙轮询事件,这样响应更及时。把 Swing 的被动绘制关掉,像素更新后直接把 BufferedImage 提交到窗口,控制权更明确。为了减少线程冲突,每个工作线程分配一块本地像素缓冲区,线程在各自缓冲区里画完后再合并到主缓冲区。合并策略采取“本地缓冲区优先写入者保留第一写入者”的折中,这样比所有线程乱写同一块内存表现要稳定,代价是内存占用上去了。

有些看起来应该很有效的优化反倒没起作用。作者尝试把像素索引计算和截断(clamping)也向量化,试过 fma 之类的复杂指令,结果比标量版本慢。用堆外内存(off-heap)在分配速度上确实快了 2–3 倍,但实际访问速度并没有比堆内好,甚至略慢几个百分点。这说明 Java 在某些低级内存访问路径上还没有把性能做到跟底层语言一样利落。

色彩和布局上作者也做了不少功夫。加了一个 colors 数组,用角度映射给粒子上色,模拟色轮的感觉。作者自己对颜色空间没太多研究,就让 AI 帮忙写了基于 OKLAB 的色调计算代码,最终视觉效果还行。除此之外还有圆形排列、若干点的距离映射、把一张图片映射到粒子上等玩法:把图片缩放到粒子数量后重复分布,视觉上挺有意思。交互也比较完整:右键拖动平移、WASD 移动视角、空格键减速、数字键换图片,这些都做了,操控起来顺手。

回到具体的数字。初版在大规模粒子下渲染帧率只有几十帧,某些极限情况下只有约 20 帧每秒。测试平台上,M1 Air 的对比显示 Rust 在大多数粒子规模下约是 Java 的两倍速;Java 在处理 100 万粒子时,大概要多付出 300ms 的基线开销;在更大规模上,两者的耗时会接近,但 Java 额外有大约 300MB 的基线内存开销需要算进去。有趣的是,在约 100 万粒子的某些测试里,Rust 反而偶尔更慢,作者反复复现但没能定位明确原因。总体看,Rust 在内存分配和低级访问上更有优势,Java 的堆管理和线程调度会带来“税”。

作者把代码和运行包开源到 GitHub,上面也写明运行时需要显式启用 Vector API,仓库里记录了各种优化尝试,哪些有效、哪些没用、为什么没用,都有注释。翻这些实现能学到不少实战经验,尤其是内存布局、多线程缓冲区设计和主动渲染那套套路,挺值得参考的。

来源:科学新鲜事

相关推荐