科学计算中的 Rust 和 Julia,谁才是开发者的最佳选择?

【CSDN 编者按】本文作者非常喜欢 Rust,同时又 Julia 所吸引,当他体验完 Julia 编程语言后,他觉得 Julia 并未像对外宣传的那样,解决了双语言问题,并且在科学计算方面,他反而更加推荐 Rust,这是为何?

原文链接:https://mo8it.com/blog/rust-vs-julia/

未经允许,禁止转载!

作者 | Mo   译者 | 弯月

责编 | 夏萌

出品 | CSDN(ID:CSDNnews)

Julia 的主要目标之一是解决双语言问题。这意味着,在使用 Julia 的时候,你不必为了灵活性而使用 Python 等动态语言构建原型,然后再为了性能而使用 C/C++ 等编译语言重写代码。

在为大学论文选择编程语言时,Julia 的这个目标确实吸引了我。但在使用了一段时间 Julie,并负责教授 Julia 之后,我仍然认为 Julia 解决了双语言问题吗?

为什么我认为在某些情况下 Rust 才是实际的解决方案?

警告

我必须声明,我非常喜欢 Rust!

因此,在本文中我会格外偏袒 Rust。但我确实经常使用 Julia,还担当过 Julia 假期课程的讲师,而且我很高兴在大学里传播这门语言。但我认为 Julia 的承诺可能会产生误导,而且在有些情况下,Rust 确实比 Julia 更适用。详情参见下文。

文中的示例代码使用 Rust 1.71.0 和 Julia 1.9.2 进行了测试。

并发

在 Julia 中,多线程的使用非常简单,你只需要在 for 循环前面加上 @threads 宏!

虽然多线程的使用非常简单,但 Julia 并没有确保安全性。下面,我们来看一个众所周知的例子:

如果你不熟悉多线程,那么肯定会认为结果应该是 10_000。但试着运行几次,你就会发现得到的结果大致如下:

由于数据争用,所以输出是随机的。

发生这种数据争用是因为线程会读取 counter 的当前值,将其加 1,然后将结果存储在同一个变量中。如果两个线程同时读取该变量,然后加 1,则最后的结果相同,而且都会被存储到同一个变量中,这意味着少了一次加法运算。

下面演示了没有数据争用的情况:

发生数据争用时,加法运算会少一次:

下面,我们将 Julia 转化为 Rust:

此处,我们使用了 rayon,这个 crate 通过迭代器提供了简单的多线程处理。

幸运的是,上面的 Rust 代码无法编译!

在 Rust 中,你只能有一个可变引用但没有不可变引用,或者任意数量的不可变引用但没有可变引用。

数据争用的发生条件是有多个可变引用。例如,在上面的 Julia 代码中,每个线程都有自己的 counter 变量的可变引用!上面的借用规则使得 Rust 中的数据争用变得不可能!

为了让上述 Rust 代码通过编译,我们需要使用 Mutex 或原子。原子可以从硬件级别上确保支持的操作以原子方式完成,也就是说只有一步操作。由于原子比 Mutex 的性能更好,因此我们将使用 AtomicU64(64 位无符号整数):

请注意,此处的 counter 不再是可变的!let 后面没有 mut。由于原子类型的操作不会引入数据争用,因此它们都是不可变引用 &self(而不是可变引用 &mut self)。这样,我们就可以在多个线程中使用了(因为 Rust 允许有多个不可变引用)。

上述 Rust 代码将返回结果 10_000。

如果编译成功,就不会出现数据争用的问题。

正确的 Julia 代码与 Rust 版非常相似:

这意味着 Julia 也有原子,但它无法检测到可能出现的数据争用,无法给出建议或警告。

Julia 的多线程文档指出:“确保程序不存在数据争用由您全权负责。”

如今摩尔定律几乎已失效,至少对于单核性能而言是如此。因此,我们需要一种语言,降低使用并发的难度,同时确保正确性。

项目的可扩展性

随着项目的发展,维护、扩展和推理 Julia 代码的正确性有多困难?

静态分析

由于即时(JIT)编译,高度优化的 Julia 代码的性能可以与 Rust 很接近。

但生成优化的机器代码并不是编译器的唯一目标。Julia 缺少编译器的一个非常重要的优点:静态分析!

我们来看看如下 Julia 代码示例:

你发现问题了吗?Julia 会逐行运行代码,直到遇到有问题的那一行。

运行上述代码,你会看到屏幕上输出 OK,然后才能收到错误,因为 pop 的语法有错,这是 Rust 的写法,在 Julia 中我们应该使用 pop!(v)。

你可能觉得这不是什么大问题,简单地运行一下测试就会发现这个 bug。

但是,如果错误代码背后的某些条件取决于程序输入或只是随机的(如蒙特卡罗模拟),该怎么办?下面是一段演示代码:

运行上述 Julia 代码,你应该有大约 50% 的机会顺利通过有 bug 的代码,并输出 No Problem!。

这是一个很严重的问题。这种类型错误可以通过简单的具有静态分析的类型系统来防止。

为什么我要在可扩展性的主题下谈论静态分析?

假设我们正在编写一些分子动力学模拟。如下例所示:

为了创建一些粒子,我们需要将它们的位置存档到向量中。在本示例中,我们来计算它们到原点的距离和质心(假设它们的质量均为 1)。

假设稍后我们需要考虑粒子的电荷,以获得更好的模拟精度。为此,我们创建一个名为 Particle 的结构来存储位置和电荷:

如上所示,particles 向量保存的不再是位置,而是 Particle 实例。

现在还没有使用引入的电荷。我们只是想确保没有破坏其他代码。

运行代码,我们会收到一个错误,因为我们在算到原点的距离时尝试索引到 Particle 结构而不是位置向量。

你可能认为,这不是什么大问题。我们只是忘了修改那行代码。下面,我们来修改代码:

修改好代码,再次运行,我们又遇到了另一个错误。我们还需要修改计算质心的那行代码。

你可能会觉得问题不大,我们可以像下面这样轻松修复代码:

如果是修改一个大型程序,你需要花费多少时间来反复修改和运行代码?

如果代码正常运行,没有任何错误,你能确保没有漏掉任何需要修改的代码吗?

这种影响大面积代码库的变更叫做“重构”。

Rust 中的重构是一个非常丝滑的编译器驱动过程。编译器会抛出所有你尚未修改完成的代码。你只需要按照编译器的错误列表逐个修改即可。解决这些难题后,程序就能通过编译,而且你基本可以确定没有遗漏。

运行时不会出错!

当然,这并不意味着你不会忘记与程序逻辑相关的修改,为此你应该运行一些测试。

用 Rust 编写测试时,你测试的是程序的逻辑。你应该确保仍然能够获得特定输入的预期输出。但是你不需要测试代码是否存在系统错误或可能出现的崩溃。

我们可以为 Julia 构建一个 linter,就像 Python 的许多 linter 一样。动态类型语言的 linter 之类的工具在功能性和正确性上永远无法企及基于良好类型语言构建的静态分析。这就像在脆弱的地基上抹水泥,只是为了让它更加安全一点。

错误处理

上述,我们讨论了可以通过静态分析检测到的系统错误。

那么,编译时无法直接检测到的错误怎么办?

Julia 提供了处理此类情况的例外。那么 Rust 呢?

Option:存在或不存在,这是一个问题

在Julia 中运行以下代码,结果会怎样?

由于只有一个值,因此第二个 pop! 会失败。但结果如何呢?

Julia 会在运行时出错。

Rust 可以防止这种情况发生吗?我们来看看在 Rust 中 Vec(Vec 是向量,T 是泛型)的 pop 签名:

上述代码接受保存 T 类型值的向量的可变引用,并返回 Option

此处的 Option 只是一个枚举,一个非常简单但非常强大的枚举!

标准库中 Option 的定义如下:

这意味着,Option 可以是 None 或 Some(T 类型的 some 值)。

我们再来看看上面的 Julia 代码在 Rust 中是什么样子:

尝试编译,你会收到一条可爱的错误消息,如下所示(Rust 拥有最优秀的错误消息):

最简单的处理 Option 方法是使用 unwrap:

unwrap None 的行为就跟 Julia 一样,会在运行时引发错误。

你不应该在生产代码中使用 unwrap。在 Rust 中,你应该使用正确的枚举处理:

我们使用模式匹配来处理Option, 如果 Option 为 None,则我们使用 1 作为乘法的中性元素。

你可能会认为上述代码包含很多样板代码。没错,但这只是模式匹配的演示,为的是说明处理 Option 的工作原理。

上面的代码可以简化如下:

Option 的 unwrap_or 实现如下:

你可能会认为如果向量为空,那么不应该是 1。你可以用不同的方式处理。但是,你只需考虑如何正确处理某些代码未按预期工作的情况。

失败不是一种选择,而是一种结果!

假设你想使用 Julia 编写长时间模拟的结果:

如果 Julia 无法打开文件,例如目录 results/ 不存在,结果会怎样?

你可能已经猜到了:运行时错误。

这意味着结果会丢失,你必须修复错误,然后重新运行模拟。

你可以将上面的代码包装在 try/catch 语句中,然后将结果转储到 /tmp 中并告诉用户。

但首先,Julia 不会强迫你处理异常。该语言本身甚至不会告诉你可能出现的异常,使用每个函数之前,你必须阅读相关文档,搞明白它是否可能引发异常。如果文档没有记载可能出现的异常,该怎么办?为了安全起见,你可以将所有代码包装在 try/catch 语句中。

还有比异常更好的方法吗?我们来看看上面代码的 Rust 版本:

在上面的代码中,open 会返回 Result。Result (带有泛型 T 和 E)是 Rust 中第二个重要的枚举:

open 会强制你处理可能出现的 IO 错误,就像 pop 会强制你处理 None 一样。

有了异常,你就知道函数会返回一些值,而且还可能会出现异常。但对于 Result 和 Option,函数签名的类型将告诉你是否有可能发生错误,不至于出现出人意料的结果。

Rust 可以确保不会漏掉任何一种情况,不会让程序因你的失误而崩溃。

你当然可以使用 unwrap 处理 Result,但它不是由于失误引发的崩溃,而是你故意使其崩溃。

同样,你需要重复运行多少次 Julia 代码才能消除所有错误?这个周期所需的时间将如何随着项目的复杂性而变化?

尽管你可以通过一些示例输入测试 Julia 代码,但你是否有信心它不会在某些时候崩溃?

而 Rust 可以给你足够的信心,确保你的代码是正确的。

接口

Julia 的多重调度和类型层次结构非常灵活。但是,我们假设一个库引入了一个抽象类型,你可以为其实现具体类型。

如果函数接受该抽象类型作为参数,那么我应该实现哪些方法呢?

由于 Julia 还没有接口,因此你可以通过以下三种方法来查找所需的方法:

  • 直接使用具体类型作为抽象类型,重复运行代码,同时逐个修复“未实现”的错误,然后祈祷最终你能涵盖所有情况……

  • 祈祷标准库中有这类的文档。

  • 逐层深入阅读源代码,并尝试找出函数内部使用了哪些方法。

公平地说,Julia 2.0 版本已经计划了接口。但事实上这不是 1.0 的优先事项,这证明我的看法没错:Julia 的设计主要面向交互式用例,而非大型项目。

另一方面,Rust 的 traits 展示了所有必需以及可选的方法!

例如,Iterator 有一个必需的方法,即 next。你只需实现这个方法,就可以免费获得所有其他方法!如果你愿意,还可以实现 size_hint 等可选方法。

无需尝试,无需搜索可能并不存在的隐藏文档,无需阅读源代码。Rust 可以在编译时确保你实现了所有必需的方法。

性能

如上所述,经过良好优化的 Julia 代码可以接近 Rust 的性能。但永远无法达到其性能。

这是因为 Julia 有垃圾收集器,而 Rust 没有!

Rust 还具有零成本抽象的原则。这意味着迭代器并不比旧的 for 循环慢。

实际上,迭代器甚至比 for 循环更快,因为它们避免了边界检查并允许编译器使用 SIMD。

另一方面,如果你想在 Julia 中获得最佳性能,则必须编写 for 循环。

最大的问题在于,Julia 有一个性能绊脚石。

例如,如果你想初始化一个 v = [] 之类的空向量,那么代码的性能就会降低到与 Python 同等水平,因为向量的类型为 Any,它可以存储任何值!因此,Julia 无法再优化这个向量。你必须使用 v = Float64[] 等具体类型来初始化空向量,或者使用至少一个值(如 v = [1.0] )来初始化。

Julia并不会告诉你这样的性能杀手!

我们都知道内存分配通常是一个瓶颈。Julia 提供及建议的预分配如下:

如果你不小心读取了 undef(未定义)字段,结果会怎样?v 可能的输出结果如下:

欢迎来到未初始化数据的未定义行为领域。

另一方面,在 Rust 中,你可以使用 with_capacity 初始化向量。但结果为空,长度为0。

容量不是长度。容量是向量可以容纳而无需再次重新分配的数据量。长度是向量存储的数据量。

容量总是大于或等于长度。你的目标是避免长度大于当前容量,因为当长度大于当前容量时,系统会重新给向量分配更大的容量。

抽象并去掉容量的概念,真的要比只提供和推荐可能导致未定义行为的方式更好吗?

Rust 不允许未定义的行为,但允许你深入底层。

如果你想使用 Julia 编写高度优化的代码,就必须严格遵循官方提供的性能提示。即便你漏掉某个提示,导致性能降级到 Python 的水平,也不会收到警告。

如果性能不是可有可无,如果每一个改进都可以节省数小时的昂贵计算,那么最好还是使用 Rust!

语言服务器

即使你像我一样使用编辑器,而不是 IDE,也应该使用语言服务器。

不幸的是,Julia 的语言服务器缺少很多功能。Rust-Analyzer 提供的功能更多,可以提高你的工作效率。

举个例子,“将鼠标悬停在变量上”,以查看其类型。

在 Julia 中,“悬停”会显示变量的声明。

另一方面,在 Rust 中,“悬停”会显示变量的类型。查看变量的类型可以帮助你了解该变量的实际含义以及如何使用。

在 Julia 中,你只能阅读返回该变量的源代码,并尝试推断出它的类型,或者运行程序并使用 typeof 来显示它的类型。

如果你不记得特定方法的名称,则可以浏览文档,但通常只需键入变量名称并在最后加上一个点(例如particles.),然后按 Tab 键就可以了。更多的输入会触发模糊搜索。接下来,你可以选择方法并输入参数,同时显示签名。

Julia 中的语言服务器可以显示签名,但由于动态调度,通常显示的签名是错误的。

至于 Rust 中的自动补齐和代码操作就无须多言了,你可以自行尝试。

许多问题都与 Julia 的动态类型有关,尽管人们认为动态类型比静态类型“更容易”。但在 Rust-Analyzer 的帮助下,我可以轻松地驾驭类型,从长远来看,我的工作效率会更高。

文档

你可以看看 Julia 官方文档中 Arrays 的介绍(https://docs.julialang.org/en/v1/base/arrays/)。

侧边栏有各个章节的链接,但导航也就仅限于此了。你可以在设置中更改主题!此外,还可以搜索,但是搜索速度比较慢,而且没有过滤选项。

难怪一些程序员热衷于 ChatGPT。也许是因为阅读文档也很痛苦吧。

我们来比较一下 Rust 文档中 Vec 的介绍(https://doc.rust-lang.org/stable/std/vec/struct.Vec.html)。

rustdoc 是一个被低估的完美文档!侧边栏中罗列了所有方法、实现的特征以及模块导航。

搜索栏会提示你按S进行搜索,按问号(?)可以显示更多选项,而且它还有键盘快捷键。

将鼠标悬停在代码示例上,右上角会显示一个按钮,点击就可以在 Rust Playground 上运行这段代码,从而方面用户快速实验。

代码示例会在发布之前自动测试。API 变更后不会出现不同步的示例。

你可以搜索、过滤结果、搜索函数参数或返回类型等等。

所有 crate 的文档都会自动发布在 docs.rs 上。你只需学会在 rustdoc 中导航,没必要通过 AI 从各处收集代码片段。

此外,下载 crate 就可以拥有离线文档。只需运行命令“cargo doc –open”即可。在没有网络的地方很方便。

Julia 的优点

说了一大堆缺点,下面我们来看看 Julia 的优点。

交互性

根据官网的介绍,除了性能之外,Julia 的第二大卖点是:

“Julia 是动态类型,使用感受很像一种脚本语言,而且能够很好地支持交互式使用。”

这就是 Julia 的强项。

虽然 Rust 有 evcxr 和 irust,但达不到 Julia REPL 的体验,因为 Rust 是静态类型。

Julia REPL非常强大。这是迄今为止我用过的最好的 REPL。尽管 Python 也是动态类型,但 Julia REPL 完胜 Python REPL。

你甚至可以使用 UnicodePlots 在 REPL 中绘图。我经常使用它来进行一些快速计算或生成一些绘图。

Rust 是 notebook?Rust 有一个 Jupyter 内核,但体验差的很远。当然 Rust 的设计初衷也不在于此。

另一方面,Julia 非常适合 Jupyter notebook。如果你想进行数据分析、绘制图表并展示结果,那么 Jupyter notebook 是不二之选。

许多人认为 Jupyter Notebook 是为 Python 发明的。但你知道“Jupyter”这个名字是由 Julia、Python 和 R 组成的吗?

你可能会问,为什么不直接使用 Python 来编写 notebook 呢?

“Julia vs Python”是另一个话题,我只挑要点说。Julia 的性能优于 Python,处理数组(向量、矩阵、张量)更加容易,而且 Julia 有一个以科学计算为中心的生态系统,其中包含许多独特的包(稍后会详细介绍)。

另外,Julia 就是用 Julia 编写的。因此阅读和贡献代码更加容易。而对于 Python,几乎所有性能良好的包都是用 C 编写的,所有你必须阅读 C 代码。

一般来说,如果你在科学背景下教授的编程课,请选择 Julia!对于大多数面向初学者的科学用例来说,Julia 更容易学习和使用。

我们也可以为科学领域想要编写大型项目(例如长时间模拟)的学生提供一门教授 Rust 的选修课程。但 Rust 不应该是入门语言,除非你的学生来自计算机科学。

许多科学计算都与线性代数、数据分析和绘图有关。我认为 Julia 的交互性和性能非常适合这一领域。

科学生态环境

Julia 拥有一个庞大的生态系统,其中包含许多科学包。

你可以获得开箱即用的数组,并且已预安装 LinearAlgebra.jl!

你可以使用 Plots.jl 甚至是 Makie 在 Julia 中绘制图形。Makie 是一个具有硬件加速功能的完整可视化生态系统。

Rust 有 Plotters,我也很喜欢,但它还有很长的路要走。目前,仍然需要大量样板代码以及许多手动调整。相较而言,Julia 提供的绘图体验更好。

此外,Julia 还拥有一些出色的软件包可用于求解微分方程、数值积分,以及新符号的计算。处理单位和测量误差也是 Julia 的梦想!

使用哪种语言

对于科学计算,我建议以下项目使用 Rust:

  • 需要大量并发;

  • 需要最大性能;

  • 代码量超出一个脚本;

  • 需要长时间运行,并且必须可靠;

  • 无法承受 Julia 的延迟;

  • 在集群上运行。

另一方面,Julia 则更适合以下项目:

  • 需要互动性;

  • 科学计算课程;

  • 时间限制为大约一周(例如学生交作业);

  • 使用绘图。

我个人的结论

回到最初的问题:Julia 解决了双语言问题吗?

在我看来,答案是否定的。

尽管 Julia 有一个可以使其非常高效的即时编译器,但它缺少静态类型语言编译器的优势。

人们选用 C/C++ 不仅仅是为了性能,也是为了提高项目的可扩展性,以及在编译时消除许多类别的错误。与 C/C++ 相比,Rust 消除的错误更多。对于科学计算来说,最重要的是数据争用和未捕获的异常。

即使你只关心性能,在避免 Julia 性能绊脚石的情况下获得最大性能,那么也请使用 Rust。

就个人而言,目前我会使用 Julia 来快速测试 REPL 中的一些数值想法,每周提交有关数值的讲座和绘图。而对于其他一切工作,包括非每周提交的成果和项目,我都会使用 Rust。

对于某些项目,我甚至会同时使用两者,导出 Rust 程序的结果,然后使用 Julia 实现可视化。

虽然双语言问题没有得到解决,但我很高兴在科学计算领域 Julia 取代了 Python,而 Rust 取代了 C/C++(不仅仅是在科学计算方面)。这是编程语言的必要演变。

这不是一场战争,两种语言应该共存。

本文文字及图片出自 CSDN

你也许感兴趣的:

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注