第六章 组织Cython代码

组织Cython代码

Python提供了包和模块来帮我们组织项目,让项目代码更加容易理解和重用,在Python中我们可以使用import语句来访问函数,对象,类等其他包和模块中的内容。Cython也允许我们这样做,他完全的支持import语句,它允许我们在运行时访问定义在外部的纯Python模块中的对象或者定义在其他扩展模块中的Python可访问对象。但是Cython不允许Cython模块彼此其他的cdef或者cpdef函数,ctypedef或者struct,而且不允许C级别的代码访问其他的扩展模块。
为了解决这个问题,Cython提供了三种文件类型帮助组织包含部分Cython和C级别代码的项目。前面我们知道了Cython的源代码文件.pyx,它是实现文件(implementation files);下面我们将看到这些文件如何和一个新的Cython文件类型一起工作,这些文件称为定义文件(definition files),后缀是.pyd;第三种Cython文件类型被称为包含文件(include files),后缀名是.pyi。
除了这三种文件类型,Cython有一个cimport语句,提供编译时访问C层面的结构的功能,它们会在定义文件(.pyd)中寻找这些结构。
本章我们将详细学习Cython的cimport语句和三种文件类型之间的关系。

Cython的实现文件(.pyx)和定义文件(.pyd)

前面我们一直在使用Cython的实现文件(.pyx),里面的内容是Cython的源代码,但是如果我们需要共享其他的C级别的结构,我们需要创建一个定义文件(.pyd)。
有下面一个实现文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#simulator.pyx
ctypedef double real_t
cdef class State:
cdef:
unsigned int n_particles
real_t *x
real_t *vx
def __cinit__(...):
# ...
def __dealloc__(...):
# ...
cpdef real_t momentum(self):
# ...
def setup(input_fname):
# ...
cpdef run(State st):
# ...calls step function repeatedly...
cpdef int step(State st, real_t timestep):
# ...advance st one time step...
def output(State st):
# ...

随着simulator.pyx的代码量的增加,维护将越来越困来,为了易于维护和模块化,我们需要将他拆分为逻辑组件。
我们首先创建一个simulator.pxd定义文件,在里面定义我们需要共享的C级别的结构,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#simulator.pxd
ctypedef double real_t
cdef class State:
cdef:
unsigned int n_particles
real_t *x
real_t *vx
cpdef real_t momentum(self)
cpdef run(State st)
cpdef int step(State st, real_t timestep)

因为定义文件是在编译时访问的,所以只能将C级别的声明放在里面。此时我们还需要修改实现文件simulator.pyx,因为他们在Cython的命名空间中有相同的名称,我们不能在实现文件中重复定义文件中的内容,这样做编译时会报错。我们的实现文件现在是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#simulator.pyx
cdef class State:
def __cinit__(...):
# ...
def __dealloc__(...):
# ...
cpdef real_t momentum(self):
# ...
def setup(input_fname):
# ...
cpdef run(State st):
# ...calls step function repeatedly...
cpdef int step(State st, real_t timestep):
# ...advance st one time step...
def output(State st):
# ...

ctypedef、cpdef函数和State的属性移动到了定义文件中,def函数和方法照旧。当编译simulator.pyx时,cython编译器会自动寻找simulator.pxd文件,使用里面的声明。
什么内容应该放在定义文件中?本质上讲,任何可以被其他Cython模块公开访问的C级别内容都应该放在定义文件中。
定义文件中包含下面内容:

  • C类型的声明:ctypedef、struct、union或者enum
  • 外部的C或者C++库的声明:cdef extern代码块
  • 模块级别的cdef和cpdef函数的声明
  • cdef calss扩展类型的声明
  • 扩展类型的cdef属性
  • cdef和cpdef方法的声明
  • C级别inline函数和方法的实现

定义文件中不能包含的内容:

  • Python的实现或者不是inline的C的方法和函数的实现
  • Python类的声明
  • 除IF和DEF宏之外的Python可执行代码

cimport语句

假设现在有一个代码需要重用前面实现的simulator.pyx,我们可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from simulator cimport State, step, real_t
from simulator import setup as sim_setup
cdef class NewState(State):
cdef:
# ...extra attributes...
def __cinit__(self, ...):
# ...
def __dealloc__(self):
# ...
def setup(fname):
# ...call sim_setup and tweak things slightly...
cpdef run(State st):
# ...improved run that uses simulator.step...

第一句我们使用cimport语句导入扩展类型State,step cpdef函数和real_t ctypedef。在编译时访问C级别的代码,cimport会在simulator.pxd定义文件中寻找要导入的对象。然后第二行使用import语句访问setup def函数,import语句访问的Python级别的代码,在运行时导入。
cimport语句跟import语句的语法一样,可以使用cimport语句导入.pxd定义文件,使用它当做模块命名空间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
cimport simulator
# ...
cdef simulator.State st = simulator.State(params)
cdef simulator.real_t dt = 0.01
simulator.step(st, dt)
#或者
cimport simulator as sim
# ...
cdef sim.State st = sim.State(params)
cdef sim.real_t dt = 0.01
sim.step(st, dt)
#或者
from simulator cimport State as sim_state, step as sim_step

定义文件也可以包含cdef extern代码块,也可以只用cimport导入,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#_mersenne_twister.pxd
cdef extern from "mt19937ar.h":
# initializes mt[N] with a seed
void init_genrand(unsigned long s)
# generates a random number on [0,0xffffffff]-interval
unsigned long genrand_int32()
# generates a random number on [0,0x7fffffff]-interval
long genrand_int31()
# generates a random number on [0,1]-real-interval
double genrand_real1()
# generates a random number on [0,1)-real-interval
double genrand_real2()
# generates a random number on (0,1)-real-interval
double genrand_real3()
# generates a random number on [0,1) with 53-bit resolution
double genrand_res53()
#可以用下面方式导入使用
from _mersenne_twister cimport init_genrand, genrand_real3
#或者下面方式导入使用
cimport _mersenne_twister as mt
mt.init_genrand(42)
for i in range(len(x)):
x[i] = mt.genrand_real1()

预定义的定义文件

Cython预先定义了一些常用的C、C++和Python头文件,他们位于Cython源文件的Includes目录。其中C语言的标准库包libc,它包含stdlib、stdio、math、string和stdint的头文件的.pxd文件,还有常用的C++的标准模板库(STL)的定义文件包libcpp,比如string、vector、list、map、pair、set。Python方面,Cython源文件中有C头文件的.pxd,提供了从Cython简单的访问Python/C API的函数。最后一个声明包是numpy,它提供了访问Numpy/C的API。
具体看一些例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#通过cimport使用C标准库
from libc cimport math
math.sin(3.14)
#通过cimport导入具体的函数
from libc.math cimport sin
sin(3.14)
from libc.stdlib cimport rand, srand, qsort, malloc, free
cdef int *a = <int*>malloc(10 * sizeof(int))
from libc.string cimport memcpy as c_memcpy
#通过cimport导入C++的STL模板类
from libcpp.vector cimport vector
cdef vector[int] *vi = new vector[int](10)
#通过cimport和import导入相同的函数名,会产生编译错误,如下
from libc.math cimport sin
from math import sin
#可修改成下面这样
from libc.math cimport sin as csin
from math import sin as pysin
#但是导入相同名称的模块名是允许的,结果会调用C标准库的sin函数,所以通常还是建议将模块名通过as重命名
# compile-time access to functions from math.h
from libc cimport math
# runtime access to the math module
import math
def call_sin(x):
# which `sin()` does this call?
return math.sin(x)

定义文件和C/C++的头文件有很多相似之处:

  • 他们都为了使用外部代码定义了C级别的结构
  • 他们都允许我们将一个大文件拆分为多个组件
  • 他们都定义了公共的C级别接口
    C和C++访问头文件通过#include预处理指令,本质是对头文件包含文件的包含。Cython的cimport语句更加智能不容易出错,我们可以将它看做是编译时的import语句,和命名空间一起工作。
    Cython的前身Pyrex,没有cimport语句,使用的是include实现对外部扩展源文件的引用,Cython目前也支持include语句,在一些项目中也有使用。

包含文件和包含语句

假设我们有一个扩展类型,想在所有的主流平台上使用,但是不同平台上的实现方式不同,可能是平台文件系统不兼容或者是平台的API不兼容等原因导致的,我们的目标是抽象这些差异,以透明的方式提供统一的接口。包含文件和include语句提供了一种方法来完成我们的平台兼容性问题的目标。
我们放置了三种不同平台的扩展类型的实现在三个.pxi文件中:linux.pxi、darwin.pxi和windows.pxi。三个文件中的一个将在编译中使用,为了将所有的都放在一起,另外一个interface.pyx文件有下面代码,使用IF编译时语句:

1
2
3
4
5
6
IF UNAME_SYSNAME == "Linux":
include "linux.pxi"
ELIF UNAME_SYSNAME == "Darwin":
include "darwin.pxi"
ELIF UNAME_SYSNAME == "Windows":
include "windows.pxi"

注意:同一个源文件include两次,可能会因为重复定义导致编译错误,所以要小心正确的使用include。
一些老的代码使用include,新的代码建议使用cimport语句和定义文件,除非是源码级别的包含。
使用定义文件,包含文件和实现文件,我们可以使Cython适配任何C或者Python代码。

在Python包中组织和编译Cython模块

Cython一个显著的特点是允许我们根据性能和分析结果逐步将Python代码转化为Cython代码,从而保持外部接口不变,但是性能得到显著提升。
假设我们有下列包结构的项目:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
pysimulator
├── __init__.py
├── main.py
├── core
│ ├── __init__.py
│ ├── core.py
│ └── sim_state.py
├── plugins
│ ├── __init__.py
│ ├── plugin0.py
│ └── plugin1.py
└── utils
├── __init__.py
├── config.py
└── output.py

本实例的重点不是实现细节,而是Cython和Python如何在一个框架中共同工作。
假设我们分析发现core.py、sim_state.py和plugin0.py需要转换成Cython扩展模块来提升性能,其他的模块还是纯Python保持灵活性。
第一步是将.py模块转换到实现文件.pyx并且将其中公共的Cython声明提取到定义文件.pxd中。因为文件分布在不同的包和子包中,我们必须要使用适当的名称来进行导入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#sim_state.pxd
ctypedef double real_t
cdef class State:
cdef:
unsigned int n_particles
real_t *x
real_t *vx
cpdef real_t momentum(self)
#core.pxd
from simulator.core.sim_state cimport State, real_t
cpdef int run(State, list plugins=None)
cpdef step(State st, real_t dt)
#plugin0.pxd
from simulator.core.sim_state cimport State
cpdef run(State st)
#main.py
from simulator.utils.config import setup_params
from simulator.utils.output import output_state
from simulator.core.sim_state import State
from simulator.core.core import run
from simulator.plugins import plugin0
def main(fname):
params = setup_params(fname)
state = State(params)
output_state(state)
run(state, plugins=[plugin0.run])
output_state(state)

经过我们的转换后,main.py还是保持原来的纯Python代码没有变。
为了便于运行转换后的项目,我们可以使用pyximport或者将项目构建成一个包,如下:

1
2
3
4
5
6
7
8
#setup.py
from distutils.core import setup
from Cython.Build import cythonize
setup(name="simulator",
packages=["simulator", "simulator.core", "simulator.utils", "simulator.plugins"],
ext_modules=cythonize("**/*.pyx"),
)

使用cythonize和distutils进行打包非常强大和灵活,当.pyx改变时他会自动检测并根据需要重新编译,它还会检测实现文件和定义文件之间的相互依存关系并重新编译所有相关的实现文件。

文章目录
  1. 1. Cython的实现文件(.pyx)和定义文件(.pyd)
  2. 2. cimport语句
  3. 3. 预定义的定义文件
  4. 4. 包含文件和包含语句
  5. 5. 在Python包中组织和编译Cython模块
|