我为什么将机器学习的主力语言从Python转到Rust

写在前面

首先要声明一下:Python依然是我最喜欢的编程语言,也是我日常使用最多的编程语言。自从10年前我转向人工智能和机器学习领域以来,Python迅速战胜C++和Java,成为我的主力编程语音。因为用Python编程让我感受到前所未有的“自由”和高效。

另一方面,在机器学习领域,我需要用到大量与机器学习和数据分析相关的包,例如:pyTorch, numpy, pandas, scikit-learn, jupyter…等。这些包极大地简化了机器学习和数据挖掘的开发工作,非常方便好用。

然而,现在我却想从Python转向Rust,因为在大型项目中Python始终显得力不从心。
∗ ∗ ∗ \ast \ast \ast

Python的痛点

猴子补丁(Monkey Patch)

猴子补丁的意思是说动态语言可以在不改动源代码的情况下扩展或修改运行时代码。

Python的这一特性给与我们很大的自由和灵活性,让我们可以通过一些奇技淫巧来让程序跑起来。举个例子:很很多代码用到 import json,后来发现 ujson 性能更高,如果觉得把每个文件的 import json 改成 import ujson as json 成本较高,或者只是单纯地想测试一下用 ujson 替换json 是否符合预期,只需要在入口加上:

import json
import ujson
def monkey_patch_json():
  json.__name__ = 'ujson'
  json.dumps = ujson.dumps
  json.loads = ujson.loads
monkey_patch_json()

但是这个特性太灵活,一旦我们在编码过程中不仔细或协作过程中缺乏沟通就很容易带来未知错误,且这种错误往往还很难定位。例如:代码中同一变量赋2个不同类型的值。这是很糟糕的编程习惯,但是随着代码量的增长,项目中几乎不可避免地会出现,更糟糕地是没人记得住所有他们用过的变量是什么类型。当新人接手程序后,他们会疑惑“为什么这里要给变量赋一个不同类型的值”。

缺乏参数类型校验

这点跟上面的猴子补丁类似,都是由语言的动态性带来的。例如,我们有一个函数,将传入的两个整数相加:

def add(num1: int, num2: int) -> int:
    return num1 + num2

但当我们调用它时,Python解释器不会检查参数类型,typing hints形同虚设。我们可以这样调用上面的函数

add("2", 3)

很明显,执行上面的代码会报错。为了让程序更加健壮,我们需要在返回前加入额外的判断逻辑

def add(num1: int, num2: int) -> int:
    if type(num1) != int or type(num2) != int:
        # 参数类型不匹配就抛出错误!
        raise IntNumberError()
	return num1 + num2

现在代码看起来好很多。但是事情远没有结束–由于我们在代码里加入了if分支,这就意味着我们的测试用例也要随之更新,至少要加入2个测试函数,一个num1非整数,另一个num2非整数。这些工作后续工作经常被忘记,以至于测试覆盖不完整。

允许跨作用域访问

请看下面的代码

for i in range(3):
    pass
print(i)

运行后控制台会输出2。但变量i理应只在for循环作用域下有效。这种作用域错误让代码变得很难维护和debug。

Python的这个设计让我非常不理解,印象中没有任意一门其他语言会这样。在我10年的Python开发生涯中,经常见到有人在if子句中定义变量,然后在if-else子句外使用它。这种写法就让很多Python新手无法理解,增加了代码的阅读和维护成本。

运行缓慢

Python运行慢是公认的。尤其是当项目庞大且复杂时,Python明显比其他主流编程语言要慢。当然我们可以用PyPynumba的给工具提升Python程序的执行速度,但是相比起来还是杯水车薪。

太多隐含规则

Python中有很多反直觉的设定。比如下面的代码:

def change(lst, st):
    lst.append(4)
    st = "new string"
    
x = [1, 2, 3]
s = "old string"
print(x, s)
change(x, s)
print(x, s)

我们将一个list和一个string传入函数,但两个参数却有不同的行为。输出结果是:

[1, 2, 3] old string
[1, 2, 3, 4] old string

我们发现list的值变了,而str的却不变。这是因为Python在传递参数时隐性地传递了list的引用,而对于str传递的确实拷贝。这就是为什么两个参数在函数中的行为不同。

这些隐形的设定对一个Python新手来说太过隐晦,无形中增加了开发者的心智负担和dubug的成本。

Rust之剑

说完Python我们再来看看Rust。

Rust语言诞生于2010年,一种多范式、系统级、高级通用编程语言,旨在提高性能和安全性,特别是无畏并发。

Rust从语法和编译器层面帮我们消除了C/C++语言编程中常见的空指针和悬垂指针问题。编译器还会自动帮助我们检查变量生命周期和所有者。在大多数情况下,如果我们的Rust代码能够编译通过,那么我们的代码80%~90%不会存在内存安全问题。

除了内存安全外,Rust还解决了上面提到了Python的5个痛点。

猴子补丁

Rust是一门静态编程语言。这就决定了Rust不能使用猴子补丁。

  • 首先,Rust中的变量都有严格的数据类型,我们不能将不同数据类型的数据赋给同一变量。
  • 其次,变量默认是不可变的,如果我们想定义可变的变量,必须显式的用关键字mut声明。

参数类型

函数参数也跟上面保持一致的原则。Rust是静态语言,传递函数参数时也需要指明参数类型。如果传递的参数类型不匹配,编译器在编译时就会检查出来。这就意味着编译器会帮我们检查潜在的类型错误,我们再也不必写额外的if子句来做类型检查,相应的后续单元测试也可以省了。这让我们的测试可以聚焦于算法和逻辑,而不必为类型检测等细枝末节浪费时间。

作用域

Rust没有GC。当变量离开其作用域时,其生命周期结束,Rust会自动释放它。(这里的解释并不严谨,因为Rust中生命周期和作用域是两个概念,但在大多数代码中两者可以划等号。)

因此,上面Python的示例代码改写成Rust代码的话,在编译时就会报错。

for i in 0..3 {
    println!("{}", i);
}
println!("{}", i); // 编译时这里会报错

运行速度

我在上一篇文章Rust让科学计算速度提升200倍中详细对比了Python、Rust和C在科学计算上的效率。测试结果Rust比Python快200倍!类似性能比较的博文网上还有很多,这里我就不再深入比较了。结论是Rust几乎跟C/C++一样快。这里温馨提示一下,编译Rust程序时千万别忘了加--release,release编译和debug编译出来的程序性能差距很大。

隐含规则

Rust也有很多隐含规则,但是跟Python不同,这些隐含规则都是在编译器层面。相反Rust在语言层面一致性相当高。Rust的编译器非常的强大且对开发者友好,他被设计成开发者的伙伴,专门帮开发者发现潜在错误。

上面提到的Python语言的错误在Rust中绝对不会出现:

  1. 在定义Rust函数时我们就要声明参数的类型。这里的类型不仅指数据类型,还包括传递方式。如果我们想传指针(Rust中叫引用),我们需要在参数前加&
  2. 如果我们想让传递的参数可修改,就必须在显式地加上mut
  3. 如果我们传递给函数的不是引用,那么变量的所有权也会一同传递进函数,这意味着当函数结束,变量就会被丢弃,无法再使用。

总之,Python中的此类问题在Rust中都不存在。我么清晰地知道数据在函数间是如何传递和使用的,程序的一切都在我们的掌控之中。
∗ ∗ ∗ \ast \ast \ast

结论

与Python相比,Rust还年轻。很多库还在开发中,但Rust社区非常活跃并且增长迅猛。很多大厂都是Rust基金会的成员,都在积极地用Rust重构底层基础设施和关键系统应用。

我用Rust重写了马尔可夫链蒙特卡罗(MCMC)方法解结构方程的程序,整个过程让我非常享受。尤其是最好爆发出的性能提升让我深感惊喜。

今天,我依然会用Python或其他语言来完成某些工作,这主要是因为某些库Rust上还没有。不过机器学习常用的库比如numpypandasscikit-learn, pytorch在Rust上都有类似替代的库,可以说目前用Rust可以方便地完成80%Python的功能。后面几章我会注意介绍numpypandasscikit-learn, pytorch这4个库的Rust替代方案,敬请大家关注。

11-10 14:40