Cython 是一个编程语言和编译器,旨在融合 Python 的易用性和 C 语言的高性能。它的主要功能是允许在 Python 代码中使用静态类型声明。它通过将 Python 代码转换为优化的 C / C ++代码,从而显著提升 Python 程序的运行速度。 —— 官方网站
安装 Cython
$ pip install cython |
在终端中输入 cython -V
查看是否安装成功。
编译与运行
Cython 有两种不同的语法变体:Cython 和 Pure Python ,代表了用C数据类型注释代码的不同方式。
-
Cython特定语法,旨在从C/C的角度使类型声明简洁且易于阅读。这种语法主要用于使用高级C或C功能。语法在Cython 源文件
.pyx
中使用。 -
纯Python语法允许在常规Python语法中进行静态Cython类型声明,遵循PEP-484类型提示和PEP 526变量注释。纯Python语代码作为正常的Python模块运行,但在编译时,Cython将它们解释为C数据类型,并使用它们生成优化的C/C++代码。
要在Python语法中使用C/C++数据类型,需要在要编译的Python模块中导入cython
模块。本文使用纯Python语法时,默认导入。import cython
Cython 代码需经过两个阶段生成 Python 扩展模块:
- Cython 编译器:将 Cython 源文件
.pyx
或.py
转换为优化的 C/C++ 代码。 - C/C++ 编译器:将生成的代码编译为共享库(
.so/.pyd
)。
使用 setuptools 包
编写 Cython 代码:先创建一个 Cython 源文件
# fib.py |
创建setup.py文件:用Python标准库编译 Cython 代码
# setup.py |
构建扩展模块的过程分为三步:
- 首先
Extension
对象负责配置:name 表示编译之后的文件名,sources 则是代表 pyx 和 C/C++ 源文件列表; - 然后
cythonize
负责将 Cython 代码转成 C 代码,参数language_level=3
表示只需要兼容 python3 即可,而默认是 2 和 3 都兼容;annotate
参数显示 Cython 的代码分析。 - 最后
setup
根据 C/C++ 代码生成扩展模块
编译 Cython 代码:在命令行中运行以下命令进行编译,即可生成相应的 Python 扩展模块
$ python setup.py build_ext --inplace |
可选的
--inplace
标志指示setup
将每个扩展模块放置在其各自的 Cython.pyx
源文件旁边。
编译后会多出一个 .pyd
文件或 .so
文件,这就是根据 fib.pyx
生成的扩展模块,至于其它的可以直接删掉了。
调用编译后的扩展模块:在 Python 中导入并使用该模块
# main.py |
注意:setuptools
74.1.0 版本增加了对 pyproject.toml
中扩展模块的实验性支持(而不是使用 setup.py
):
[build-system] |
在这种情况下,您可以使用任何构建前端,例如
$ python -m build |
使用 Jupyter Notebook
要启用对 Cython 编译的支持,需要先用魔法命令加载 Cython
扩展:
In [1]: %load_ext Cython |
然后,在单元格前加上 %%cython
来编译
In [2]: %%cython |
即时编译
Cython 提供的 pyximport
包改造了 import
语句,使其能够识别 .pyx
扩展模块。在导入 Cython 扩展模块之前调用 pyximport.install()
,自动编译。
In [1]: import pyximport; pyximport.install() |
这样可以省去编写 setup.py
脚本的需要。如果修改了 Cython 源文件,pyximport
会自动检测到修改,并在新的 Python 解释器会话中重新编译源文件。
性能分析
Cython 编译器有一个可选的 --annotate
选项(简写为 -a
),用于生成一个 HTML 代码注释,根据每行代码调用 Python/C API 的次数进行性能评估。
$ cython --annotate integrate.pyx |
在标准流程中通过设置扩展模块的 annotate
参数生成
setup(ext_modules=cythonize(ext, annotate=True, language_level=3)) |
在 IPython 魔法命令中添加 --annotate
选项(简写为 -a
)生成
In [1]: %%cython -a |
编译器指令
Cython 提供了编译器指令,用于控制 Cython 源代码的编译方式。
常用的编译器指令如下:
指令 | 说明 |
---|---|
language_level | 全局设置用于模块编译的 Python 语言级别,默认值为 None |
infer_types | 在函数体中推断未加类型注解的变量类型,默认值为 None |
annotation_typing | 是否使用函数参数注解编译,默认值为 True |
cdivision | 获得C语义的除法和模运算,默认值为 False |
boundscheck | 假设代码中的索引操作不会引发任何 IndexError ,默认值为 True |
wraparound | 是否支持负索引,默认值为 True |
nonecheck | 检查None值,默认值为 False |
overflowcheck | 整数溢出检查,默认值为 True |
initializedcheck | 检查内存视图是否已初始化,默认值为 True |
freethreading_compatible | 表明该模块可以在没有活动 GIL 的情况下安全运行,默认值为 False |
全局指令: 可以通过在文件顶部附近添加特殊的头注释来设置编译器指令,如下所示:
# cython: language_level=3, boundscheck=False |
或者分别写在不同的行上
# cython: language_level=3 |
该注释必须出现在任何代码之前(但可以在其他注释或空白之后)。
您也可以通过在命令行中使用 -X
或 --directive
选项来传递指令:
$ cython -X language_level=3 boundscheck=True main.pyx |
在命令行中使用 -X
选项设置的指令将覆盖头注释中设置的指令。
局部指令:某些指令支持通过装饰器进行局部控制
cimport cython |
还可以使用上下文管理器的形式,如关闭边界检查进一步优化循环。
cimport cython |
每次我们访问内存视图时,Cython 都会检查索引是否在范围内。如果索引超出范围,Cython 会引发一个 IndexError
。此外,Cython 允许我们使用负索引对内存视图进行索引(即索引环绕),就像 Python 列表一样。
如果我们事先知道我们永远不会使用超出范围的索引或负索引,因此我们可以指示 Cython 关闭这些检查以获得更好的性能。为此,我们使用 cython
特殊模块与 boundscheck
和 wraparound
编译器指令
无论是装饰器形式还是上下文管理器形式的指令,都不会受到注释或命令行指令的影响。
在 setup.py
中设置:也可以在 setup.py
文件中通过将关键字参数传递给 cythonize
来设置编译器指令:
from setuptools import setup |
这将覆盖在 compiler_directives
字典中指定的默认指令。注意,明确在文件中或局部设置的指令(如上所述)将优先于传递给 cythonize
的值。
已编译开关
compiled
是一个特殊变量,当编译器运行时,它被设置为 True
,在 CPython 解释器中则为 False
。因此,以下代码:
import cython |
根据代码是作为编译后的扩展模块(.so
/.pyd
)运行,还是作为普通的 .py
文件运行,其行为会有所不同。
静态类型声明
静态数据类型
静态变量可以通过以下方式声明
- 使用Cython特定的
cdef
语句, - 使用带有C数据类型的PEP-484/526类型注释或
- 使用函数
cython.declare()
cdef
语句和declare()
可以定义本地和模块级变量以及类中的属性,但类型注释只影响本地变量和属性,在模块级别被忽略。这是因为类型注释不是特定于Cython的,因此Cython将变量保留在模块字典中。
global_var = declare(cython.int, 42) |
还支持使用 cython.typedef()
函数为类型命名,这与C中的typedef
语句类似
ULong = cython.typedef(cython.ulong) |
在Cython中,通过cdef
关键字来声明静态变量,具有C类型的变量使用C语法
cdef int global_var = 42 |
也可以使用Python风格的缩进进行声明,这与C中的typedef
语句类似
cdef: |
还支持使用ctypedef
语句为类型命名
ctypedef unsigned long ULong |
使用 @cython.locals
装饰器指定函数体中的局部变量的类型(包括参数)。使用 @cython.returns
指定函数的返回类型
|
Cython支持所有标准的C类型以及它们的无符号版本:
Cython type | Pure Python type |
---|---|
bint |
cython.bint |
char |
cython.char |
signed char |
cython.schar |
unsigned char |
cython.uchar |
short |
cython.short |
unsigned short |
cython.ushort |
int |
cython.int |
unsigned int |
cython.uint |
long |
cython.long |
unsigned long |
cython.ulong |
long long |
cython.longlong |
unsigned long long |
cython.ulonglong |
float |
cython.float |
double |
cython.double |
long double |
cython.longdouble |
float complex |
cython.floatcomplex |
double complex |
cython.doublecomplex |
long double complex |
cython.longdoublecomplex |
size_t |
cython.size_t |
Py_ssize_t |
cython.Py_ssize_t |
Py_hash_t |
cython.Py_hash_t |
Py_UCS4 |
cython.Py_UCS4 |
C 派生数据类型
Cython 支持同样指针、数组、结构体、枚举等复杂类型
指针
纯python模式下,指针类型可以使用cython.pointer[]
构建
pi: cython.double = 3.14 |
简单的指针类型支持带有 p 前缀的快捷命名方案,如cython.p_int
等同于cython.pointer[cython.int]
。
Cython 构建指针的语法和C一致
cdef double pi = 3.14 |
注意:在Python 中,*
有特殊含义,指针无法像 C 中那样解引用。在 Cython 中通过数组索引 ptr[0]
的方式获取指针变量的值。
print(ptr[0]) |
或者使用cython.operator.dereference
函数式运算符来解引用指针
from cython cimport operator |
数组
C 数组可以通过添加 [ARRAY_SIZE]
来声明
def main(): |
def main(): |
注意:Cython 语法目前支持两种声明数组的方式:
cdef int arr1[4], arr2[4] # C style array declaration |
它们都生成相同的 C 代码,建议使用 Java 样式声明。
结构体
结构体定义如下
Point = cython.struct( |
cdef struct Point: |
我们还可以通过以下两种方式初始化结构体:
cdef Point point = Point(x=1.0, y=2.0) |
与C不同,Cython 使用点操作符来访问结构体成员
枚举
目前,纯Python模式不支持enum
。 Cython 支持使用 cdef
和 cpdef
定义,我们可以在单独的行上定义成员,或者在一行上用逗号分隔:
cpdef enum Color: |
注意:在Cython语法中,struct、union和enum 关键字仅在定义类型时使用,声明和引用时省略。
Python 内置类型
Cython 同样支持Python的数据类型进行静态声明。前提是它们必须是用C实现的,并且Cython必须能够访问到它们的声明。内置的Python类型(如list
、tuple
和dict
)已经满足这些要求。
a: str = "hello" |
cdef str a = "hello" |
Cython目前支持几种内置的可静态声明的Python类型,包括:
type
、object
bool
complex
basestring
、str
、unicode
、bytes
、bytearray
list
、tuple
、dict
、set
、frozenset
array
slice
date
、time
、datetime
、timedelta
、tzinfo
另外,Cython 提供了一个 Python 元组的有效替代品 ctuple
。 ctuple
由任何有效的 C 类型组装而成
def main(): |
cdef (double, int) bar |
静态和动态混合使用
Cython 同时允许静态 C 变量和 Python 动态类型变量的混合使用
def main(): |
def main(): |
Cython 允许静态 C 变量赋值给 Python 动态变量,同时会自动转换类型
num: cython.int = 6 |
cdef int num = 6 |
内置Python类型与C或C++类型的对应关系
C types | From Python types | To Python types |
---|---|---|
bint |
bool |
bool |
[unsigned] char [unsigned] short int long |
int , long |
int |
unsigned int unsigned long [unsigned] long long |
int , long |
long |
float double long double |
int , long , float |
float |
char * std::string (C++) |
str/bytes |
str/bytes |
array |
iterable |
list |
struct |
dict |
注意: 在Python 3中,所有的int
对象都具有无限精度。当将整数类型从Python转换为C时,Cython会生成检查溢出的代码。如果C类型无法表示Python整数,则会在运行时抛出OverflowError
。
使用 C 字符指针时,需要使用临时变量赋值,否则会编译错误
def main(): |
cdef char *s |
强制类型转换
Cython 支持类型转换,无论是内置类型还是自定义类型
在纯 python 模式下,使用 cast
函数,如果希望在转换前检查类型,我们可以设置参数 typecheck=True
def main(): |
Cython 语法的转换运算符与 C 类似,其中 C 使用 ()
,Cython 使用 <>
。如果希望在转换前检查类型,我们可以使用检查类型转换运算符 ?
cdef char *p |
融合类型
融合类型允许您定义一个类型,它可以指代多种类型。这使得您可以编写一个单一的静态类型化的 Cython 算法,能够操作多种类型的值。因此,融合类型允许泛型编程,类似于 C++ 中的模板或 Java/C# 中的泛型。
注意:融合类型目前不支持作为扩展类型的属性。只有变量和函数/方法的参数可以声明为合成类型。
声明融合类型
纯Python语法通过 cython.fused_type
函数自定义一个融合类型
int_or_float = cython.fused_type(cython.char, cython.double) |
Cython 支持通过 ctypedef fused
自定义一个融合类型,支持的类型可以写在块里面
ctypedef fused int_or_float: |
如果同一个融合类型在函数参数中多次出现,那么它们将具有相同的特化类型。在上述示例中,两个参数的类型要么是 int
,要么是 double
,因为它们使用了相同的融合类型名称。如果函数或方法使用了融合类型,则至少有一个参数必须声明为该融合类型,以便Cython能够在编译时或运行时确定实际的函数特化版本。
但是,我们不能混合同一融合类型的特化版本,这样做会产生编译时错误,因为Cython没有可以分派的特化版本,从而导致TypeError
。:
plus(cython.cast(float, 1), cython.cast(int, 2)) # not allowed |
选择特化
索引:可以通过对函数进行类型索引来获取某些特化,例如:
fused_type1 = cython.fused_type(cython.double, cython.float) |
cimport cython |
索引函数可以直接从 Python 中调用:
import cython |
如果合成类型被用作更复杂类型的组成部分(例如指向合成类型的指针,或合成类型的内存视图),则应对函数进行单个组成部分的索引,而不是完整的参数类型:
|
对于从 Python 空间进行内存视图索引,可以按以下方式操作:
import numpy as np |
cdef myfunc(A *x): |
对于从 Python 空间进行内存视图索引,可以按以下方式操作:
import numpy as np |
内置融合类型
为了方便使用,Cython 提供了一些内置的融合类型:
cython.integral
:将 C 的short
、int
和long
标量类型组合在一起cython.floating
:将float
和double
C类型组合在一起cython.numeric
:最通用的类型,将所有integral
和floating
类型以及float complex
和double complex
组合在一起
Cython 函数
Cython中的函数支持三种类型:Python 函数、C函数和混合函数
Python 函数
def
函数是经过编译的Python原生函数,他们把Python对象作为参数,并返回Python对象。支持外部文件通过 import 语句直接调用。
def func(x: cython.double) -> cython.double: |
def func(double x): |
C 函数
C函数的参数和返回值都要求指定明确的类型。它可以处理我们见过的任何静态类型,包括指针、结构体、C数组以及静态Python类型。我们也可以将返回类型声明为void
。如果省略返回类型,则默认为object
。
使用 @cfunc
装饰器
|
使用 Cython语法中的 cdef
语句
cdef int divide(int a, int b): |
C 函数可以被同一Cython源文件中的任何其他函数(无论是def
还是cdef
)调用,但不允许从外部代码调用 C 函数。由于这一限制,我们需要在 Cython 中通过 def
函数包装下才能被外部模块识别。
def wrap_divide(a, b): |
混合函数
混合函数是def
和cdef
的混合体,相当于定义了C版本的函数和一个Python包装器。当我们从Cython调用该函数时,我们调用仅C版本,当我们从Python调用该函数时,调用包装器。由于这一特性, 混合函数的参数和返回类型必须同时兼容 Python 和 C。
使用 @ccall
装饰器
|
使用 Cython语法中的 cpdef
语句
cpdef int divide(int a, int b): |
函数指针
纯Python模式目前不支持指向函数的指针
以下示例显示了声明ptr_add
函数指针并指向add
函数:
cdef int(*ptr_add)(int, int) |
struct
中声明的函数会自动转换为函数指针:
cdef struct Bar: |
异常捕获
C函数或混合函数通常通过返回代码或错误标志来传达错误状态,但都无法引发 Python 异常(比如 ZeroDivisionError),这导致程序不会报错停止。
使用 @cython.exceptval
装饰器将 C 异常自动转换为 Python 异常
|
这里把C的返回值 -1
作为可能的异常。值-1
是任意的,我们可以使用返回类型范围内的任意其他值。在这个例子中,我们使用关键字 check=True
,是因为-1
可能是divide
的有效结果。或者,为了使Cython检查是否发生了异常而不考虑返回值,我们可以用 check
参数,这将带来一些额外开销。
|
Cython提供了一个except
子句将 C 异常自动转换为 Python 异常
cpdef int divide(int a, int b) except? -1: |
这里把C的返回值 -1
作为可能的异常。值-1
是任意的,我们可以使用返回类型范围内的任意其他值。在这个例子中,我们在except? -1
子句中使用问号,因为-1
可能是divide
的有效结果。或者,为了使Cython检查是否发生了异常而不考虑返回值,我们可以使用except *
子句,这将带来一些额外开销。
cpdef int divide(int a, int b) except *: |
扩展类型
扩展类型
Cython 支持直接使用 Python/C API 定义一个C级别的类,称为扩展类。扩展类型是可以被外部文件访问。和Python 类的主要区别在于它们使用 C 结构体来存储属性和方法,而不是 Python dict。
通过 @cclass
装饰器创建
|
通过 cdef class
语句创建
cdef class Shrubbery: |
类属性和访问控制
注意,我们在 __init__
中实例化的属性,都必须在类中先声明。它们是C语言级别的实例属性,这种属性声明风格类似于C++和Java等语言。当扩展类型被实例化时,属性直接存储在对象的 C 结构体中。 在编译时需要知道该结构体的大小和字段,因此要声明所有属性。
扩展类型的属性默认是私有的,并且只能通过类的方法访问。若要想让外部 Python代码访问,可以声明为 readonly
或 public
。
|
cdef class Shrubbery: |
默认情况下,无法在运行时向扩展类型添加属性。这是因为,C语言结构体是固定的。
类方法
和函数一样,扩展类型同样支持 Python 方法、C 方法和混合方法,但 C 方法和混合方法只能用于 cdef class
或 @cython.cclass
定义的扩展类,不能用于普通 Python 类。
|
cdef class Shrubbery: |
Cython 目前不支持使用 @classmethod
装饰器声明为类方法,但支持使用 @staticmethod
装饰器声明为静态方法。这在构造接受非 Python 兼容类型的类时特别有用:
from cython.cimports.libc.stdlib import free |
from libc.stdlib cimport free |
继承
扩展类只能继承单个基类,并且继承的基类必须是直接指向 C 实现的类型,可以是使用扩展类型,也可以是内置类型,因为内置类型也是直接指向 C 一级的结构。
|
cdef class Parrot: |
扩展类不可以继承 Python 类,但 Python 类是可以继承扩展类的。此外,当使用 Python 类继承扩展类时,纯 C 函数可以被纯 C 函数 / 混合函数覆盖,但不能被 Python 函数覆盖。
初始化方法
Cython 支持初始化两个方法:普通的 Python __init__()
方法和新增的 __cinit__()
方法。__cinit__
和 __init__
只能通过 def
来定义。
__init__()
方法的工作方式与 Python 中完全相同。它是在对象分配和基本初始化之后被调用的,包括完整的继承链。如果通过直接调用对象的 __new__()
方法来创建对象(而不是调用类本身),那么任何 __init__()
方法都不会被调用。
而 __cinit__()
方法保证在对象分配时被调用,可以在其中执行基本的 C 结构初始化。我们实例化一个扩展类的时候,参数会先传递给__cinit__
,然后__cinit__
再将接收到的参数原封不动的传递给__init__
。
|
cdef class Penguin: |
请注意,通过 __new__()
不会调用类型的 __init__()
方法(这在 Python 中也是已知的)。因此,在上面的例子中,第一次实例化会打印 eating!,但第二次不会。这只是 __cinit__()
方法比普通的 __init__()
方法更安全的原因之一。
Cython保证__cinit__
只被调用一次,并且在__init__
、__new__
或 staticmethod
之前被调用。Cython将任何初始化参数传递给__cinit__
。
注意:所有构造函数参数都将作为 Python 对象传递。这意味着不能将不可转换的 C 类型(如指针或 C++ 对象)作为参数传递给构造函数,无论是从 Python 还是从 Cython 代码中。如果需要这样做,通常在该函数中直接调用 __new__()
方法,以明确绕过对 __init__()
构造函数的调用。
如果您的扩展类型有一个基类型,基类型层次结构中任何现有的 __cinit__()
方法都会在您的 __cinit__()
方法之前自动被调用。
内存分配和释放
Cython 添加了构造函数__cinit__
和析构函数 __dealloc__
,用于执行 C 级别的内存分配和释放。
相比 C 的动态内存管理函数,Python 在 malloc、realloc、free 基础上做了一些简单的封装,这些函数对较小的内存块进行了优化,通过避免昂贵的操作系统调用来加快分配速度。
from cython.cimports.cpython.mem import PyMem_Malloc, PyMem_Realloc, PyMem_Free |
from cpython.mem cimport PyMem_Malloc, PyMem_Realloc, PyMem_Free |
nonecheck
当使用注释语法时,行为遵循 PEP-484 的 Python 类型语义。当变量仅用其普通类型注解时,None 值是不允许的:
def widen_shrubbery(sh: Shrubbery, extra_width): |
当 sh
为 None
时会引发 TypeError
。要允许 None
,必须显式使用 typing.Optional[]
import typing |
对于默认参数为 None
时,也会自动允许。
def widen_shrubbery(Shrubbery sh, extra_width): |
如果我们传入一个非 Shrubbery
对象,我们会得到一个TypeError
。但是 Python的 None
对象本质上没有C接口,因此尝试在其上调用方法或访问属性会导致程序崩溃。为了使这些操作安全,可以在调用之前检查 sh
是否为 None
。这是一个非常常见的操作,因此 Cython为此提供了特殊的语法:
def widen_shrubbery(Shrubbery sh not None, extra_width): |
现在,该函数将自动检查 sh 是否为 not None,同时检查它是否具有正确的类型。
注意:not None
和 typing.Optional
只能在 Python 函数中使用,不能在 C 函数中使用。如果你需要检查 C 函数的参数是否为 None
,需要自行定义。
Cython还提供了一个nonecheck
编译器指令(默认关闭),它使所有函数和方法调用都对None
安全。
模块管理
实现和声明
Cython 也允许我们将项目拆分为多个 pyx
模块。但 import
语句无法让两个Cython模块访问彼此的cdef/cpdef
函数、ctypedef
、结构体等C级别构造。
类似于 C 中的头文件,Cython 提供了pxd
文件来组织 Cython 文件。pxd
文件用于存放供外部代码使用的C声明,而它们的具体实现是在同名的 pyx/py
文件中。外部 Cython 文件可以通过 cimport
语句将 pxd
文件导入使用。
一个 .pxd
声明文件可以包含:
- 任何类型的C型声明
- 外部C函数或变量声明
- 模块中定义的C函数声明
- 扩展类型的定义部分
它不能包含任何C或Python函数的实现,或任何Python类定义,或任何可执行语句的实现。
pxd
声明文件用于编译时访问,只允许在其中放置C级别声明,不允许放置Python的声明,比如def
函数。Python对象是在运行时是可访问的,因此它们仅在实现文件中。
扩展类型的定义部分只能声明 C 属性和 C 方法,不能声明 Python 方法,并且必须声明该类型的所有 C 属性和 C 方法。
假设我们有一个名为 shrubbing.py 的实现文件:
# shrubbing.py |
假设我们有一个名为 shrubbing.pyx 的实现文件:
# shrubbing.pyx |
为此,我们首先需要创建一个同名的 shrubbing.pxd 声明文件。在其中,我们放置希望共享的C级别构造的声明。
# shrubbing.pxd |
如果我们在 pxd
文件中声明了一个函数或者变量,那么在对应的实现文件中不可以再次声明,否则会发生编译错误。因此实现文件也需要更改
# shrubbing.py |
在编译 shrubbing.py 时,cython
编译器将自动检测到 shrubbing.pxd 文件,并使用其声明。
# shrubbing.pyx |
在编译 shrubbing.pyx 时,cython
编译器将自动检测到 shrubbing.pxd 文件,并使用其声明。
为了避免重复(以及潜在的未来不一致),默认参数值在声明中(.pxd
文件)中不可见,而仅在实现中可见。
cimport
语句
Cython提供了 cimport
语句,语法与import
一致。我们可以另一个 pyx
文件中,使用cimport
语句导入 pxd
文件中静态声明的对象。
使用纯 Python 语法时,可以通过从 cython.cimports
包中导入 pxd
文件
# main.py |
Cython 提供了 cimport
关键字用来导入 pxd
文件
# main.pyx |
注意,cimport
语句只能用于导入C数据类型、C函数和变量以及扩展类型,并且这种导入发生在编译时(扩展类型除外)。任何Python对象,只能使用import
语句在运行时导入。
最后,编译这两个模块
# setup.py |
# setup.py |
如果头文件不在同一个目录中,那么编译的时候还需要通过 include_dirs
参数指定头文件的所在目录。
外部库声明
Cython 提供了一个 extern
语句,可以直接调用 C/C++ 源码。一旦一个 C 函数在 extern
块中声明,它就可以像在 Cython 中定义的普通C函数一样被使用和调用。
Cython 目前不支持在纯 Python 模式中声明为 extern
。
例如,有一个头文件 mymodule.h
,里面是函数声明,源文件 mymodule.c
里面是函数实现
// mymodule.h |
通过 cdef extern from
声明在 pxd
文件中,然后 Cython 可以直接调用
# mymodule.pxd |
在前面的 extern
块中,我们为函数参数添加了变量名称。这是推荐的,但并非强制性的:这样做可以让我们使用关键字参数调用这些函数。
上例在 Cython 编译时,不会自动为声明的对象生成 Python 包装器,我们仍然需要在 Cython 中使用 def
、或者 cpdef
将 extern
块中声明的 C 级结构包装一下才能给 Python 调用。
或者,我们在导入的时候直接声明 cpdef
,这将生成一个 Python 包装器
# mymodule.pxd |
# main.py |
编译的时候,我们必须确保将 mymodule.c
源文件包含在 sources
列表中:
# setup.py |
还可以在 .pxd
文件中重命名外部函数,如下所示 sin
被重命名为 _sin
:
cdef extern from "math.h": |
在某些情况下,您可能不需要结构的任何成员,在这种情况下,您可以将pass放入结构声明的正文中,例如:
cdef extern from "foo.h": |
请注意,您只能在from
块的cdef extern
内执行此工作;其他地方的结构声明必须是非空的。
C/C++ 标准库
方便的是,Cython附带了常用的 C/C++ 标准库、Python/C API 和 Numpy包的 pxd
声明文件,位于主Cython源目录下的 Includes 目录中。
- C 标准库 libc:包含 stdlib、stdio、math、string 和 stdint 等头文件
- C++ 标准模板库(STL)libcpp:包含 string、vector、list、map、pair 和 set 等容器
使用 cimport
导入预定义模块
from libc.math cimport sin as csin |
通过设置编译器指令 language=c++
,Cython 可以编译为 C++ 代码,从而支持 C++ 标准库
# distutils: language=c++ |
初始化模块
Cython 同样支持初始化模块 __init__.pxd
,类似于Python 包中的 __init__.py
。
例如,树目录
CyIntegration/ |
在 __init__.pxd
中,用于声明任何 cimport
语法导入的C结构。
增强 .pxd
文件
增强 .pxd
文件可以在不改变原始 .py
文件的情况下实现静态声明。如果编译器找到与正在编译的 .py
文件同名的 .pxd
文件,编译器将查找 cdef
类和 cdef
/cpdef
函数及方法。然后,将 .py
文件中对应的类/函数/方法转换为声明的类型。
例如,如果有一个文件 A.py
:
def myfunction(x, y=2): |
并添加 A.pxd
:
cpdef int myfunction(int x, int y=*) |
那么 Cython 将编译 A.py
,就好像它是这样写的:
cpdef int myfunction(int x, int y=2): |
包装 C++ 库
声明 C++ 类
假设我们有一个简单C++头文件 Rectangle.h
|
以及在名为 Rectangle.cpp
的文件中的实现:
|
为了在 Cython 中声明此类接口,我们需要像之前一样使用 extern
块。这个 extern
块需要三个额外的元素来处理 C++ 的特性:
- 使用
cppclass
关键字声明 C++ 类 - 使用 Cython 的
namespace
子句声明 C++ 命名空间。如果没有命名空间,可以省略namespace
子句。如果有多个嵌套的命名空间,可以将它们声明为namespace "outer::inner"
。也可以声明类的静态成员,例如"namespace::MyClass"
。
接下来,我们将这些声明放在一个名为 Rectangle.pxd
的文件中。可以将其视为 Cython 可读的头文件:
cdef extern from "Rectangle.cpp": |
Cython 只能包装 public
方法和成员,任何 private
或 protected
方法或成员都无法访问,因此也无法包装。
注意:构造函数被声明为 "except +"
。如果 C++ 代码或初始内存分配由于失败而引发异常,这将允许 Cython 安全地引发适当的 Python 异常(见下文)。如果没有此声明,源自构造函数的 C++ 异常将不会被 Cython 处理。
现在我们可以在 .pyx
文件中使用 cdef
或 C++ 的 new
语句声明一个类的变量:
# distutils: language = c++ |
显然,使用默认构造函数的版本更加方便,消除了对 try
/finally
块的需求。
包装 C++ 类
若要从 Python 中访问 C++ 类,我们仍然需要编写可从 Python 访问的扩展类型来包装
# rect.pyx |
我们已经看到了如何将一个简单的 C++ 类包装在一个扩展类型中。Cython 将 new
操作符传递到生成的 C++ 代码中。new
操作符只能与 C++ 类一起使用。每次调用 new
都必须与一个 delete
调用匹配。
使用 C++ 编译
在编译 C++ 项目时,我们需要指定编译器指令 language=c++
,并将所有 C++ 源文件包含在 sources
列表参数中
from distutils.core import setup, Extension |
我们可以简化 setup
脚本。在 rect.pyx 的顶部,我们添加以下指令注释:
# distutils: language = c++ |
有了这些指令,cythonize
命令可以自动提取必要的信息,以正确构建扩展
from distutils.core import setup |
静态成员方法
如果 Rectangle
类有一个静态成员:
namespace shapes { |
可以使用 Python 的 @staticmethod
装饰器来声明它,即:
cdef extern from "Rectangle.h" namespace "shapes": |
重载方法和运算符
重载方法非常简单,只需声明具有不同参数的方法,并使用它们中的任何一个即可:
cdef extern from "Foo.h": |
Cython 使用 C++ 的命名方式来重载运算符:
cdef extern from "foo.h": |
模版
Cython 使用方括号语法来实现模板函数。模版参数列表跟在函数名之后,用方括号括起来:
# distutils: language = c++ |
可以定义多个模板参数,例如 [T, U, V]
或 [int, bool, char]
。可选的模板参数可以通过写 [T, U, V=*]
来表示。
类模板的定义方式与模板函数类似,一个简单的包装 C++ vector
的例子如下:
# distutils: language = c++ |
我们使用 T
作为模板类型,并声明了 vector
的四个构造函数以及一些更常见的方法。
假设我们想在一个包装函数中声明和使用一个 int
类型的 vector
。对于模板化类,我们需要在模板类名后用方括号指定一个具体的模板类型:
def wrapper_func(elts): |
这适用于栈分配的 vector
,但创建一个堆分配的 vector
需要使用 new
操作符:
def wrapper_func(elts): |
当使用 new
进行堆分配时,我们需要确保在使用完 vector
指针后调用 del
,以防止内存泄漏。
标准库
Cython 已经在 /Cython/Includes/libcpp
中的 .pxd
文件中声明了 C++ 标准模版库(STL)的大部分容器。这些容器包括:deque
、list
、map
、pair
、queue
、set
、stack
和 vector
。
# distutils: language = c++ |
Cython 支持自动将 STL 容器转换为对应的 Python 内置类型。
# cython: language_level=3 |
下表列出了当前支持的从 Python 到 C++ 容器的所有内置转换
Python type => | C++ type | => Python type |
---|---|---|
bytes | std::string | bytes |
iterable | std::vector | list |
iterable | std::list | list |
iterable | std::set | set |
iterable | std::unordered_set | set |
mapping | std::map | dict |
mapping | std::unordered_map | dict |
iterable (len 2) | std::pair | tuple (len 2) |
complex | std::complex | complex |
所有转换都会创建一个新的容器,并将数据复制到其中。容器中的项目会自动转换为对应类型,这包括递归转换容器内的容器,例如一个 C++ vector
的 map
的字符串。
这一强大特性允许我们直接从 def
或 cpdef
函数或方法返回一个支持的 C++ 容器,前提是该容器及其模板类型是受支持的。Cython 会自动将容器的内容转换为正确的 Python 容器。
from libcpp.string cimport string |
Cython 支持通过 for .. in
语法(包括在列表推导式中)迭代标准库容器(或任何具有返回支持递增、解引用和比较的对象的 begin()
和 end()
方法的类)。例如,可以编写如下代码:
# distutils: language = c++ |
尽管 Cython 没有 auto
关键字,但未显式使用 cdef
类型化的 Cython 局部变量会根据其所有赋值的右侧类型进行推导(参见 infer_types
编译器指令)。这在处理返回复杂、嵌套、模板化类型的函数时特别方便。
标准异常
Cython 不能抛出 C++ 异常,也不能使用 try-except
语句捕获它们。但Cython 具有检测它们何时发生并将它们自动转换为相应的 Python 异常的功能。要启用此功能,我们只需在可能引发 C++ 异常的函数或方法声明中添加一个 except +
子句。
cdef extern from "some_file.h": |
有了 except +
子句,Cython 会自动为我们进行检查,并将异常传播到 Python 代码中。
当前支持的异常及其 Python 对应如下表:
C++ ( std:: ) |
Python |
---|---|
bad_alloc |
MemoryError |
bad_cast |
TypeError |
bad_typeid |
TypeError |
domain_error |
ValueError |
invalid_argument |
ValueError |
ios_base::failure |
IOError |
out_of_range |
IndexError |
overflow_error |
OverflowError |
range_error |
ArithmeticError |
underflow_error |
ArithmeticError |
(all others) | RuntimeError |
如果有 what()
消息,将被保留。
为了指示 Cython 抛出特定类型的 Python 异常,我们可以在 except +
子句中添加 Python 异常类型:
cdef int bar() except +MemoryError |
这将捕获任何 C++ 错误,并用 Python MemoryError
替换它。(任何 Python 异常在这里都是有效的。)
还有一个特殊形式:
cdef int bar() except +* |
类型化内存视图
内存视图
Python 的缓冲协议(Buffer Protocol)是一种用于访问对象底层内存数据的机制,它允许 Python 对象将用于存储数据的一块连续内存区域(即缓冲区 Buffer)暴露出来,从而支持高效的内存操作和数据共享。
缓冲协议最重要的特性是其能够以不同的方式表示相同的底层数据。它允许支持缓冲区协议的对象共享相同的数据而无需复制,例如 numpy.ndarray
、Python 标准库中的 array.array
、cython.array
等。
内存视图(Memoryview)是 Python 提供的内置类型,用于访问支持缓冲协议的对象。
Cython 提供了C级别的类型化内存视图对象 memoryview
,它允许你以更高效的方式操作内存。内存视图使用 Python 切片语法,类似于 NumPy:
import numpy as np |
# Memoryview on a NumPy array |
import numpy as np |
在这里,NumPy 数组和 memoryview 共享内存数据。其中 int
指定了内存视图的底层数据类型。
函数参数
内存试图也可以方便地作为函数参数使用:
# A function using a memoryview does not usually need the GIL |
# A function using a memoryview does not usually need the GIL |
当我们从 Python 调用 sum2d
时,我们会传递一个 Python 对象,它在函数调用过程中被隐式地赋值给 memoryview 对象。当一个对象被赋值给类型化内存视图时,内存视图会尝试访问该对象的底层数据缓冲区。如果传递的对象无法提供缓冲区(即它不支持该协议),则会引发 ValueError
。如果它支持该协议,那么它会为内存视图提供一个 C 级别的缓冲区以供使用。
memoryview 对象既支持简单的标量类型,也支持用户定义的结构化类型。
import numpy as np |
注意:纯 Python 模式目前不支持打包结构体。
索引和切片
我们可以通过类似 NumPy 的方式对类型化内存视图进行索引,以访问和修改单个元素。在 Cython 中,对内存视图的索引访问会自动转换为内存地址。
mv3D[1, 2, 1] |
省略号(…)表示获得每个未指定维度的连续切片。
也可以用一个具有相同元素类型且形状正确的另一个内存视图修改。如果左右两侧的形状不匹配,将会引发 ValueError
。
复制数据
内存视图可以直接复制:
import numpy as np |
import numpy as np |
C 连续内存布局
最简单的数据布局可能是 C 连续数组。这是 NumPy 和 Cython 数组的默认布局。C 连续意味着数组数据在内存中是连续的,并且数组的第一个维度的相邻元素在内存中相距最远,而最后一个维度的相邻元素在内存中相距最近。例如,在 NumPy 中:
arr = np.array([['0', '1', '2'], ['3', '4', '5']], dtype='S1') |
这里,arr[0, 0]
和 arr[0, 1]
在内存中相距一个字节,而 arr[0, 0]
和 arr[1, 0]
在内存中相距 3 个字节。这引出了 步长 的概念。数组的每个轴都有一个步长,即从该轴的一个元素移动到下一个元素所需的字节数。在上面的例子中,轴 0 和轴 1 的步长分别为:
arr.strides # (3, 1) |
声明一个 C 连续的类型化内存视图只需要对最后一个维度使用切片语法 ::1
来指定。例如,声明一个二维 C 连续的类型化内存视图:
c_contig_mv: float[:, ::1] = np.ones((3, 4), dtype=np.float32) |
cdef float[:, ::1] c_contig_mv = np.ones((3, 4), dtype=np.float32) |
NumPy
Cython 内置了可以访问 C-level 接口 NumPy 包,通过 cimport
语句导入
import cython.cimport.numpy as np |
cimport numpy as np |
由于我们使用了 NumPy/C API,需要在编译时包含一些 NumPy 头文件。NumPy 提供了一个 get_include
函数,返回其头文件目录的完整路径。
from distutils.core import setup, Extension |
Cython 还可以通过使用装饰器 @cython.ufunc
将 C 函数来生成 NumPy ufunc 函数,输入和输出参数类型应该是标量变量。
import cython |
cimport cython |
还可以使用 ctuple 类型
import cython |
cimport cython |
多线程并行
Cython允许我们绕过CPython的全局解释器锁,只要我们清晰地将与Python交互的代码与独立于Python的代码分开。做到这一点后,我们可以通过Cython内置的prange
轻松实现基于线程的并行性。
nogil
在我们深入探讨prange
之前,我们必须首先理解CPython全局解释器锁(GIL),用于确保与 Python 解释器相关的数据不会被破坏。在 Cython 中,当不访问 Python 数据时,有时可以释放这个锁。
Cython提供了两种机制来管理 GIL:标记 nogil
函数属性和 with nogil
上下文管理器。
标记 nogil
函数属性
要在没有GIL的上下文中调用一个函数,该函数必须具有nogil
属性。这种函数必须是来自外部库的,或者是用C函数或混合函数。def
函数不能在没有GIL的情况下被调用,因为这些函数总是与Python对象交互。
使用 @cython.nogil
装饰器将整个函数(无论是 Cython 函数还是外部函数)标记为 nogil
:
|
通过在函数签名后添加 nogil
将整个函数(无论是 Cython 函数还是外部函数)标记为 nogil
:
cdef void kernel() noexcept nogil: |
在kernel
函数体中,我们不能创建或以其他方式与Python对象交互,包括静态类型的Python对象,如list
或dict
。
请注意,这并不会在调用函数时释放 GIL。它只是表明该函数适合在释放 GIL 的情况下使用。在持有 GIL 的情况下调用这些函数也是可以的。
在本例中,我们将函数标记为 noexcept
,以表明它不会引发 Python 异常。请注意,具有 except *
异常规范的函数(通常是返回 void
的函数)调用成本较高,因为 Cython 需要在每次调用后暂时重新获取 GIL 以检查异常状态。在 nogil
块中,大多数其他异常规范的处理成本较低,因为只有在实际抛出异常时才会获取 GIL。
上下文管理器
在 Cython 中可以通过 with nogil
上下文管理来实际释放 GIL。
cpdef int func(int a, int b) nogil except? -1: |
在这个代码片段中,我们使用 with nogil
上下文管理器在调用 func
之前释放GIL,并在退出上下文管理器块后重新获取它。
如果在 with nogil
里面如果出现了函数调用,那么该函数必须是使用 nogil
声明的函数。而使用 nogil
声明的函数,其内部必须是纯 C 操作、不涉及 Python。
通常,一个外部库根本不会与Python对象交互。在这种情况下,我们可以在cdef extern from
行中放置 nogil
声明,从而将 extern
块中的每个函数都声明为 nogil
:
cdef extern from "math.h" nogil: |
我们还可以在 with nogil
上下文中使用 with gil
子上下文暂时重新获取GIL。这允许一个nogil
函数重新获取GIL以执行涉及Python对象的操作。
with cython.nogil: |
也可以通过使用 @cython.with_gil
装饰器,确保在调用函数时立即获取 GIL。
|
with nogil: |
也可以通过将函数标记为 with gil
,确保在调用函数时立即获取 GIL。
cdef int some_func() with gil: |
条件性地获取 GIL
融合类型函数可能需要处理 Cython 原生类型(例如 cython.int
或 cython.double
)和 Python 类型(例如 object
或 bytes
)。条件性获取/释放 GIL 提供了一种方法,可以在运行相同的代码时,根据需要释放 GIL(针对 Cython 原生类型)或持有 GIL(针对 Python 类型):
import cython |
cimport cython |
异常和 GIL
在 nogil
块中可以执行少量的Python 操作,而无需显式使用 with gil
。主要例子是抛出异常。在这里,Cython 知道异常总是需要 GIL,因此会隐式地重新获取它。同样,如果一个 nogil
函数抛出异常,Cython 能够正确地传播它,而无需你编写显式的代码来处理它。在大多数情况下,这是高效的,因为 Cython 可以使用函数的异常规范来检查错误,然后只有在需要时才获取 GIL,但 except *
函数的效率较低,因为 Cython 必须始终重新获取 GIL。
prange
Cython通过OpenMP API实现prange
,用于原生并行化。prange
是一个仅在Cython中存在的特殊函数。它可以轻松地帮我们将普通的 for 循环转成使用多个线程的循环,接入所有可用的 CPU 核心。
cython.parallel.prange(start=0, stop=None, step=1, |
start, stop, step
参数和range
的用法一样nogil
用来打开 GIL。该函数只能在释放 GIL 的情况下使用。use_threads_if
是否启用并行schedule
:传递给 OpenMP,用于线程分配- static:整个循环在编译时会以一种固定的方式分配给多个线程,如果 chunksize 没有指定,那么会分成 num_threads 个连续块,一个线程一个块。如果指定了 chunksize,那么每一块会以轮询调度算法(Round Robin)交给线程进行处理,适用于任务均匀分布的情况。
- dynamic:线程在运行时动态地向调度器申请下一个块,chunksize 默认为 1,当任务负载不均时,动态调度是最佳的选择。
- guided:块是动态分布的,但与 dynamic 不同,chunksize 的比例不是固定的,而是和 剩余迭代次数 / 线程数 成比例关系。
- runtime:调度策略和块大小将从运行时调度变量中获取,该变量可以通过
openmp.omp_set_schedule()
函数调用或OMP_SCHEDULE
环境变量设置。这允许在不重新编译的情况下探索不同的schedule
和chunksize
,但可能会由于没有编译时优化而导致整体性能较差。
prange
只能与 for 循环搭配使用,不能独立存在。变量的线程局部性和归约操作会自动推断。
规约并行
from cython.parallel import prange |
from cython.parallel import prange |
内存视图并行
from cython.parallel import prange |
from cython.parallel import prange |
条件并行
from cython.parallel import prange |
from cython.parallel import prange |
一旦使用了 prange
,那么必须确保在编译的时候启用 OpenMP。对于 gcc,可以在 setup.py
中如下操作:
from setuptools import Extension, setup |
而在 Cython 源文件中我们可以通过注释的方式指定
# distutils: extra_compile_args = -fopenmp |