目录
避免使用 Cython 的一些原因
如果你需要加速 Python 代码,Cython 是一个非常实用的工具。它允许你将 Python 语法与 C 或 C++ 代码无缝结合,使得编写具有丰富 Python 接口的高性能扩展变得非常容易。
然而,Cython 并非在所有情况下都是最佳工具。因此,在本文中,我将讨论 Cython 的一些局限性和问题,并提出一些替代方案。
Cython 的简要概述
如果你不熟悉 Cython,这里有一个简单的例子;技术上 Cython 预定义了 malloc
和 free
,但为了清晰起见,我显式地包含了它们:
cdef extern from "stdlib.h":
void *malloc(size_t size);
void free(void *ptr);
cdef struct Point:
double x, y
cdef class PointVec:
cdef Point* vec
cdef int length
def __init__(self, points: list[tuple[float, float]]):
self.vec = <Point*>malloc(
sizeof(Point) * len(points))
self.length = len(points)
for i, (x, y) in enumerate(points):
self.vec[i].x = x
self.vec[i].y = y
def __repr__(self):
result = []
for i in range(self.length):
p = self.vec[i]
result.append("({}, {})".format(p.x, p.y))
return "PointVec([{}])".format(", ".join(result))
def __setitem__(
self, index, point: tuple[float, float]
):
x, y = point
if index > self.length - 1:
raise IndexError("Index too large")
self.vec[index].x = x
self.vec[index].y = y
def __getitem__(self, index):
cdef Point p
if index > self.length - 1:
raise IndexError("Index too large")
p = self.vec[index]
return (p.x, p.y)
def __dealloc__(self):
free(self.vec)
我们在编写 Python 代码的同时,可以随时调用 C 代码,并与 C 变量、C 指针等 C 特性进行交互。当你不与 Python 对象交互时,它就是纯粹的 C 代码,具有相应的速度。
通常你会将编译添加到 setup.py
中,但为了测试目的,我们可以直接使用 cythonize
工具:
$ cythonize -i pointvec.pyx
...
$ python
>>> from pointvec import PointVec
>>> pv = PointVec([(1, 2), (3.5, 4)])
>>> pv
PointVec([(1.0, 2.0), (3.5, 4.0)])
>>> pv[1] = (3, 5)
>>> pv[1]
(3.0, 5.0)
>>> pv
PointVec([(1.0, 2.0), (3.0, 5.0)])
Cython 的工作原理
Cython 将 pyx
文件编译为 C 或 C++,然后正常编译为 Python 扩展。在这个例子中,它生成了 197KB 的 C 代码!
正如你所想象的,阅读生成的 C 代码并不有趣;这里有一个小片段:
/* "pointvec.pyx":16
* self.length = len(points)
*
* for i, (x, y) in enumerate(points): # <<<<<<<<<<<<<<
* self.vec[i].x = x
* self.vec[i].y = y
*/
__Pyx_INCREF(__pyx_int_0);
__pyx_t_2 = __pyx_int_0;
if (likely(PyList_CheckExact(__pyx_v_points)) || PyTuple_CheckExact(__pyx_v_points)) {
__pyx_t_3 = __pyx_v_points; __Pyx_INCREF(__pyx_t_3); __pyx_t_1 = 0;
__pyx_t_4 = NULL;
} else {
__pyx_t_1 = -1; __pyx_t_3 = PyObject_GetIter(__pyx_v_points); if (unlikely(!__pyx_t_3)) __PYX_ERR(0, 16, __pyx_L1_error)
__Pyx_GOTREF(__pyx_t_3);
__pyx_t_4 = Py_TYPE(__pyx_t_3)->tp_iternext; if (unlikely(!__pyx_t_4)) __PYX_ERR(0, 16, __pyx_L1_error)
}
这部分还不算太糟。后面的代码更难阅读。
不过,你可以使用 Cython 工具链中的注释选项来生成一个 HTML 文件,该文件将 Cython 代码映射到生成的 C 代码,以帮助匹配输入和输出。
为什么 Cython 如此吸引人
正如我们的示例所示,使用 Cython 创建 Python 的小型扩展非常容易。你可以使用 Python 语法与 Python 交互,但也可以编写一对一编译为 C 或 C++ 的代码,因此你可以轻松地编写与 Python 互操作的快速代码。
Cython 的一些缺点
不幸的是,由于 Cython 最终只是 C 或 C++ 的一层薄封装,它继承了这些语言的所有问题。然后它还引入了一些自己的问题。
问题 #1:内存不安全
看看上面的 PointVec
示例。你能发现内存安全漏洞吗?
大多数安全漏洞是由于内存不安全导致的,而使用 C 和 C++ 使得引入这些漏洞变得非常容易。Cython 继承了这个问题,这意味着使用 Cython 编写安全代码非常困难。即使安全性不是问题,内存损坏漏洞也很难调试。
问题 #2:两次编译器传递
当你编译 Cython 扩展时,它首先被编译为 C 或 C++,然后使用 C 或 C++ 编译器进行第二次编译。一些错误只有在第二次编译时才会被发现,此时 Cython 已经生成了数千行难以理解的代码。生成的错误可能会令人沮丧:
这是 data.h
:
#include <stdio.h>
struct X {
double* myvalue;
};
static inline void print_x(struct X x) {
printf("%f\n", *x.myvalue);
}
这是 typo.pyx
,其中有一个拼写错误(你能发现吗?):
cdef extern from "data.h":
cdef struct X:
double myvalue
void print_x(X x)
def go():
x = X()
x.myvalue = 123
print_x(x)
当我编译 typo.pyx
时,我得到以下错误:
typo.c: In function ‘__pyx_pf_4typo_go’:
typo.c:2174:23: error: incompatible types when assigning to type ‘double *’ from type ‘double’
2174 | __pyx_v_x.myvalue = 123.0;
| ^~~~~
注意,没有引用 .pyx
源代码中的原始位置。在这个例子中,由于我们只有一个赋值,所以问题很明显。对于更复杂的代码,需要更多的工作;我们可以使用上面提到的注释报告,但它是在相反的方向上进行的,因此需要一些点击/搜索。对于 C++,这可能会更加令人沮丧,因为语言更复杂,因此有更多失败的方式。
对于更有经验的开发者来说,这不是一个大问题,但良好的编译器错误的一个显著好处是帮助新手开发者。
问题 #3:没有标准化的依赖包或构建系统
一旦你的 Cython 代码库变得足够大,你可能希望添加一些功能而不必自己编写。如果你使用 C++,你可以访问 C++ 标准库,包括其数据结构。除此之外,你处于 C 和 C++ 的世界,实际上没有库的包管理器。
对于 Python,你可以使用 pip install
安装依赖项,或者使用 poetry
或 pipenv
将其添加到依赖文件中。对于 Rust,你可以使用 cargo add
添加依赖项。对于 C 和 C++,你没有特定于语言的工具。
这意味着在 Linux 上,你可以获取 Linux 发行版的流行库版本……但有 apt
和 dnf
等等。macOS 有 Brew,Windows 有自己的、更小的仓库,如 Choco。但每个平台都不同,许多库根本不会为你打包。然后,一旦你下载了 C 或 C++ 库,你可能需要处理自定义构建系统。
简而言之,除非你只是包装现有的库,否则所有的激励措施都会促使你在 Cython 中从头开始编写所有内容,而不是重用现有的库。
问题 #4:工具缺乏(Cython 3 部分解决)
由于用户基数小,以及 Cython 的工作方式复杂,它没有像其他语言那样多的工具。例如,大多数编辑器现在可以使用 LSP 语言服务器来获取语法检查和其他 IDE 功能,但据我所知,Cython 没有这样的语言服务器,尽管一些编辑器确实有插件。
Cython 3 有一个新的、与 Python 兼容的语法,这意味着你可以更容易地应用 Python 工具。并且有一个 Cython linter 可用。
问题 #5:仅限 Python
使用 Cython 会将你锁定在仅限 Python 的世界中:你编写的任何代码只对编写 Python 的人有帮助。这是一个遗憾,因为其他生态系统的人也可能从这些代码中受益。例如,Polars DataFrame 库可以从 Python 中使用,也可以从 Rust(它编写的语言)、JavaScript 中使用,并且正在为 R 进行开发。
Cython 的替代方案
那么你可以用什么来代替 Cython 呢?
- 如果你正在包装现有的 C 库,Cython 仍然是一个不错的选择。你只需要将 C 接口到 Python,这正是 Cython 擅长的——而且你已经在使用内存不安全的语言。
- 如果你正在包装现有的 C++ 库,像 pybind11 或更快的 nanobind 这样的原生 C++/Python 库可能会提供更愉快的开发体验。
- 如果你正在编写一个小型的独立扩展,并且你确定安全性永远不会成为问题,如果你已经知道如何使用它,Cython 可能仍然是一个合理的选择。
另一方面,如果你预计你将编写大量新代码,你会想要更好的东西。我的建议:Rust。
Rust 作为替代方案
Rust 是一种内存安全、高性能的语言,允许你使用 PyO3 轻松编写 Python 扩展。对于简单的情况,使用 Maturin 打包非常容易,否则你可以使用 setuptools-rust。你还可以轻松地 使用 NumPy 数组。
此外,Rust 克服了上述所有 Cython 问题:
- 内存安全: Rust 设计为默认内存安全,同时仍然具有与 C 或 C++ 相同的性能。
- 一次编译器传递: 与 Cython 不同,只有一次编译器传递。
- 集成的包存储库和构建系统: Rust 有一个不断增长的库生态系统,以及一个名为 Cargo 的包和构建管理器。添加依赖项快速、简单且可重复。
- 大量工具: Rust 有一个名为 clippy 的 linter,一个优秀的 LSP 服务器,一个自动格式化工具等等。
- 跨语言: 一个 Rust 库可以包装在 Python 中,但你也可以与其他语言互操作。
与 Cython 相比的缺点:
- 你不能内联使用 Python 语法,因此与 Python 接口需要更多的工作。
- 它是一种比 C 复杂得多的语言,因此学习时间要长得多,尽管它并不比 C++ 差。
一些现实世界的例子
Polars: 我们已经提到 Polars 是用 Rust 编写的;Python 版本只是围绕一个通用 Rust 库的包装器。
Py-Spy: py-spy 性能分析器是用 Rust 编写的,其性质非常特定于 Python。然而,它与 rb-spy Ruby 性能分析器共享一些通用依赖项,后者使用相同的操作系统机制。除此之外,它还使用了许多其他预先存在的 Rust 库——这就是使用具有包管理器和活跃的开源生态系统的语言的好处。
用 Rust 重写我们的示例
那么 Rust 与 Cython 相比是什么样子呢?
我用 Rust 重写了 PointVec
示例,偶尔使用稍微不那么惯用的代码以增加一些清晰度。当使用 Rust 的自动格式化工具格式化时,结果是 55 行代码,而 Cython 是 42 行:
use pyo3::exceptions::PyIndexError;
use pyo3::prelude::*;
struct Point {
x: f64,
y: f64,
}
#[pyclass]
struct PointVec {
vec: Vec<Point>,
}
#[pymethods]
impl PointVec {
#[new]
fn new(points: Vec<(f64, f64)>) -> Self {
Self {
vec: points.into_iter().map(
|(x, y)| Point { x, y }).collect(),
}
}
fn __getitem__(
&self, index: usize
) -> PyResult<(f64, f64)> {
if self.vec.len() <= index {
return Err(PyIndexError::new_err(
"Index out of bounds"));
}
return Ok((self.vec[index].x, self.vec[index].y));
}
fn __setitem__(
&mut self, index: usize, t: (f64, f64)
) -> PyResult<()> {
let (x, y) = t;
if self.vec.len() <= index {
return Err(PyIndexError::new_err(
"Index out of bounds"));
}
self.vec[index] = Point { x, y };
return Ok(());
}
fn __repr__(&self) -> String {
return format!(
"PointVec[{}]",
self.vec
.iter()
.map(|t| format!("({}, {})", t.x, t.y))
.collect::<Vec<String>>()
.join(", ")
);
}
}
#[pymodule]
fn rust_pointvec(_py: Python, m: &PyModule) -> PyResult<()> {
m.add_class::<PointVec>()?;
return Ok(())
}
新版本具有与 Cython 相同的功能,但没有内存安全漏洞;显式类型的要求迫使我们注意到我们可能需要正整数。
>>> from rust_pointvec import PointVec
>>> pv = PointVec([(1, 2), (3.5, 4)])
>>> pv
PointVec[(1, 2), (3.5, 4)]
>>> pv[0] = (17, 18)
>>> pv[0]
(17.0, 18.0)
>>> pv
PointVec[(17, 18), (3.5, 4)]
>>> pv[-200000] = (12, 15)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
OverflowError: can't convert negative int to unsigned
如果我们省略边界检查,会发生什么?
fn __setitem__(
&mut self, index: usize, t: (f64, f64)
) -> PyResult<()> {
let (x, y) = t;
// if self.vec.len() <= index {
// return Err(PyIndexError::new_err(
// "Index out of bounds"));
// }
self.vec[index] = Point { x, y };
return Ok(());
}
Rust 仍然保护我们:
>>> from rust_pointvec import PointVec
>>> pv = PointVec([(1, 2), (3.5, 4)])
>>> pv[200000] = (12, 15)
thread '<unnamed>' panicked at 'index out of bounds: the len is 2 but the index is 200000', src/lib.rs:35:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
pyo3_runtime.PanicException: index out of bounds: the len is 2 but the index is 200000
不要把自己逼入绝境
如果你正在编写一个小型扩展并且安全性不是问题,Cython 可能是一个不错的选择。然而,值得提前考虑项目的范围。
如果你预计你的代码库会显著增长,可能值得从一开始就投资于更好的语言。你不希望在编写了一大堆代码之后才开始遇到 Cython 的限制。