思想实验:让 Python 拥有 C 的性能
# 1. 前言
最近我在想一个问题,既然语言的性能和它的编译器 / 解释器有很大的关系,那么是不是对于任何语言,经过适当优化,配上一个完美的编译器,都可以拥有 C 的性能?
所以我打算做一个,如何让 Python 拥有 C 的性能的思想实验。(口嗨罢了,事实上是有人做过这方面的努力的,而且是实际行动上)
# 2. 虚拟机开销
众所周知,Python 是一个脚本语言,脚本语言和物理机之间隔了一层虚拟机。那么如何去掉这层虚拟机呢?答案当然是用编译器了。(这里的编译器指编译到机器码的编译器)
# 3. 运行时类型信息
Python 是一个动态类型语言,我们可以任意修改变量的类型,这意味着我们可能无法在编译时确定变量的类型。
例如下面这段程序运行完后,鬼才知道 x 最终类型是 int
还是 float
:
x = 1
if input() == "1":
x = 0.1
那么不确定变量类型有什么问题呢?
第一个影响是,每个值都额外需要一个类型信息,读取这个类型信息是有开销的。
第二个影响是编译器 / 解释器做不到对类型未知的变量进行优化。一个简单的例子是,如果 x 是无符号整型,那么 x *= 2
可以优化为 x <<= 1
。
所以我的解决方法是,让编译器读取类型标注(typing),并对违反类型标注的行为报 warning。
x: int = 1
if input() == "1":
x = 0.1 # warning
# 4. 解引用开销
Python 的所有变量都是放在堆上的。解引用开销,其实就是每次访问一个变量都要通过引用(指针)间接地访问,相比于直接访问肯定是慢一点了。
我们知道,变量防在栈上的一个要求就是,这个变量是定长的(字节数固定)。这个好办,由于我们刚刚确定了变量的类型,定长也就呼之欲出了。(这里不包括 list
等不定长类型)
# 5. GC 开销
什么是 GC(Garbage Collection, 垃圾回收)?就是指一个变量诞生在堆上后,不需要人为把它释放掉,GC 来帮我们释放。
这个东西给程序员提供了很大的便利,只可惜我的目标是把 Python 优化成 C,因此这个“完美的编译器”必须在一定条件下编译出不带 GC 的程序。
受 C++ 的 RAII 和 Rust 所有权机制的影响,我觉得可以通过约束程序员的代码来实现所有权。
- 一个数据被一个变量独占(即所有权)
- 用弱引用(
weakref
)来实现借用 - 移动语义可以这么写:
a = b; del b;
- 一个数据离开作用域后被释放(在 Python 中是函数作用域)
我要求,如果一个程序严格遵循 RAII,那么编译器必须编译出无 GC 的程序;反之编译器编译出带 GC 的程序。(这个编译器太智能了)
# 6. 动态属性开销
Python 允许对象动态添加、删除属性。为了实现动态属性,其实对象内置了一个哈希表(__dict__
)。
哈希表的开销不言而喻。我们首先要声明对象的 __slots__
,这样禁止了添加属性的行为。其次只能用 foo.bar
的方式访问属性,这样给了编译器优化的空间,优化成 C 不是梦想。
既然说到对象,继承也很有问题,不过这方面我了解太少就略过了。(有一说一,C 语言没有继承,要不把这个功能砍了?)
# 7. 一些细节
之前说的其实都是“大道理”,在细节上还需要的优化太多了。
第一个是 int
是没有范围的,相比与 C 的 32 位整数来说,是有一定开销的。因此为了让 int
装作自己是 32 位整数,可以让它每次修改后断言一下。
x: int = int(input())
assert -(1 << 31) <= x < (1 << 31)
x += 10
assert -(1 << 31) <= x < (1 << 31)
这段话就基本等价于 C 语言的:
int32_t x;
scanf("%d", &x);
x += 10;
第二个是定长的数组,这个很好办,只要在初始化之后不出现修改数组长度的操作即可。
第三个是 Python 定义了很多异常,而在 C 里很多属于未定义行为(比如数组越界),不得不背离 Python 标准来提高性能了。
# 8. 总结
写了好多,大致理了一遍脚本语言和 C/C++ 的区别。
经过一系列改造,Python 也许就能有 C 的性能了。不过就算有这么一个“完美的编译器”,直接上 C++/Rust 等语言它不香吗?
以上。