编译Cython代码的方式

发布时间 2024-01-08 20:00:42作者: pomolnc
title: 
aliases: 
tags:
  - Python/Cython
category: 方法
stars: 
url: 
creation-time: 2024-01-08 16:12
modification-time:

因为 [[Cython]] 是 Python 的超集,所以 Python 解释器无法直接运行 Cython 的代码,那么如何才能将 Cython 代码变成 Python 解释器可以识别的有效代码呢?答案是通过 Cython 编译 Pipeline。

Pipeline 由两步组成:第一步是由 cython 编译器负责将 Cython 转换成经过优化并且依赖当前平台的 C、C++ 代码;第二步是使用标准的 C、C++ 编译器将第一步得到的 C、C++ 代码进行编译并生成标准的扩展模块,并且这个扩展模块是依赖特定的平台的。如果是在 Linux 或者 Mac OS,那么得到的扩展模块的后缀名为 .so,如果是在 Windows 平台,那么得到的扩展模块的后缀名为 .pyd(扩展模块 .pyd 本质上是一个 DLL 文件)。不管是什么平台,最终得到的都会是一个成熟的 Python 扩展模块,它是可以直接被 Python 解释器进行 import 的。
![[不同的cython编译路径.png]][1]
通常,一般由 [[Cython]] 编译器将 [[Cython]] 的代码转换为 C/C++ 代码,然后再使用 [[GCC]] 等编译工具编译成动态库,Python 代码中导入动态库后就能使用接口。

这个自动构建的方式主要有两种,独立编译以及导入时即时编译。

独立编译

一般使用 [[Python]] 的构建工具完成,用的比较多的是disutils[2]。Python 有一个标准库 disutils,可以用来构建、打包、分发 Python 工程。而其中一个对我们有用的特性就是它可以借助 C 编译器将 C 源码编译成扩展模块,并且这个模块是自带的、考虑了平台、架构、Python 版本等因素,因此我们在任意地方使用 disutils 都可以得到扩展模块。
整个过程是,cython 编译器将 [[Cython]] 代码转为 C/C++ 的代码,然后 disutils 中的 setup 功能完成后面的编译。[3]
以一个简单的例子说明

# fib.pyx
def fib(n):
    """这是一个扩展模块"""
    cdef int i
    cdef double a=0.0, b=1.0
    for i in range(n):
        a, b = a + b, a
    return a

编写一个 setup.py 文件

from distutils.core import setup
from Cython.Build import cythonize

# 我们说构建扩展模块的过程分为两步: 1. 将 Cython 代码翻译成 C 代码; 2. 根据 C 代码生成扩展模块
# 而第一步要由 cython 编译器完成, 通过 cythonize; 第二步要由 distutils 完成, 通过 distutils.core 下的 setup
setup(ext_modules=cythonize("fib.pyx", language_level=3))
# 里面的 language_level=3 表示只需要兼容 python3 即可, 而默认是 2 和 3 都兼容
# 强烈建议加上这个参数, 因为目前为止我们只需要考虑 python3 即可

# cythonize 负责将 Cython 代码转成 C 代码, 这里我们可以传入单个文件, 也可以是多个文件组成的列表
# 或者一个glob模式, 会匹配满足模式的所有 Cython 文件; 然后 setup 根据 C 代码生成扩展模块

然后运行命令

python setup.py build_ext --inplace

执行完成后会生成一个 build 文件夹,里面会有生成的库文件, .pyd 文件(Windows 系统)或 .so 文件(linux 系统),命名应该是 模块名.xx-xx_xx.pyd 这种形式。另外会自动拷贝一个库文件到 setup.py 同目录下。这个 模块名.xx-xx_xx.pyd 就是根据 setup.py 文件中使用的 .pyx 文件生成的。
在 python 中导入的时候直接使用

import fib
print(fib.fib(20))

这是使用 disutils 工具构建 python 扩展模块的最简单的方式,更多的使用方式可以查看更多 setup.py 文件的写法,比如与依赖的 C/C++ 一起编译。[4]

即时编译

使用 pyximport 可以在 .py 中动态地编译 [[Cython]] 代码。
假设有 .pyx 文件

# fib.pyx 
def foo(int a, int b): 
	return a + b

另外用 .py 文件导入它

import pyximport
# 这里同样指定 language_level=3, 则表示针对的是py3, 因为这种方式也是要编译的
pyximport.install(language_level=3)
# 执行完之后, Python 解释器在导包的时候就会识别 Cython 文件了, 当然会先进行编译

import fib
print(fib.foo(11, 22))  # 33

在导入 fib 之前先导入 pyximport ,并设置好编译选项。运行代码后就会在导入时对 .pyx 进行编译,并生成 .pyd 文件。
对于这种方式,因为没有 setup.py ,如果 .pyx 有依赖 C/C++ 文件的话,需要通过一种方式告诉 pyximport ,这里需要创建一个 .pyxbld 文件,且 .pyxbld 文件需要与 .pyx 同名。比如我们是为了指定 fib.pyx 文件的依赖,那么 .pyxbld 文件就应该叫做 fib.pyxbld,并且它们要位于同一目录中。[3:1]

# fib.pyxbld
from distutils.extension import Extension

def make_ext(modname, pyxfilename):
    """
    如果 .pyxbld 文件中定义了这个函数, 那么在编译之前会进行调用, 并自动往里面进行传参
    modname 是编译之后的扩展模块名, 显然这里就是 fib
    pyxfilename 是编译的 .pyx 文件, 显然是 fib.pyx, 注意: .pyx 和 .pyxbld 要具有相同的基名称
    然后它要返回一个我们之前说的 Extension 对象
    :param modname:
    :param pyxfilename:
    :return:
    """
    return Extension(modname,
                     sources=[pyxfilename, "cfib.c"],
                     # include_dir 表示在当前目录中寻找头文件
                     include_dirs=["."])
    # 我们看到整体还是类似的逻辑, 因为编译这一步是怎么也绕不过去的
    # 区别就是手动编译还是自动编译, 如果是自动编译, 显然限制会比较多
    # 如果想解除限制, 那么我们看到这和手动编译没啥区别了, 甚至还要更麻烦一些

这种方式里,无论是 .pyx 还是所依赖的 C/C++ 文件发生了修改,都会重新编译。

交互式窗口使用 [[Cython]]

[[IPython]] 也支持 cython 的使用。

# 我们在 IPython 上运行,执行 %load_ext cython 便会加载 Cython 的一些魔法函数
In [1]: %load_ext cython

# 然后神奇的一幕出现了,加上一个魔法命令,就可以直接写 Cython 代码
In [2]: %%cython
   ...: def fib(int n):
   ...:     """这是一个 Cython 函数,在 IPython 上编写"""
   ...:     cdef int i
   ...:     cdef double a = 0.0, b = 1.0
   ...:     for i in range(n):
   ...:         a, b = a + b, a
   ...:     return a

# 测试用时,平均花费82.6ns
In [6]: %timeit fib(50)
82.6 ns ± 0.677 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)

首先 IPython 中存在一些魔法命令,这些命令以一个或两个百分号开头,它们提供了普通 Python 解释器所不能提供的功能。%load_ext cython 会加载 cython 的一些魔法函数,如果执行成功将不会有任何的输出。然后重点来了,%%cython 允许我们在 IPython 解释器中直接编写 Cython 代码,当我们按下两次回车时,显然这个代码块就结束了。但是里面的 Cython 代码会被 copy 到名字唯一的 .pyx 文件中,并将其编译成扩展模块,编译成功之后 IPython 会再将该模块内的所有内容导入到当前的环境中,以便我们使用。
jupyter notebook 底层也是使用了 IPython,所以它的原理和 IPython 是等价的,会先将代码块 copy 到名字唯一的.pyx 文件中,然后进行编译。编译完毕之后再将里面的内容导入进来,而第二次编译的时候由于单元格里面的内容没有变化,所以不再进行编译了。

另外在编译的时候如果指定了 --annotate 选项,那么还可以看到对应的代码分析
![[Pasted image 20240108195338.png]]
关于 [[Cython]] 代码分析的内容可以参考[5]的解释,大体来说跟所编写的 [[Cython]] 代码与 python 对象交互的程度显示不同的颜色。

可以看到,无论是使用 pyximport 还是 IPython 的魔法命令,并不是没有进行 cython 的编译,只是把编译的过程放到了导入模块的时刻。

Reference

《Cython系列》2. 编译并运行 Cython 代码的几种方式 - 古明地盆 - 博客园 (cnblogs.com)


  1. Cython 的简要入门、编译及使用_cython编译-CSDN博客 ↩︎

  2. [[python库收藏]] ↩︎

  3. 《Cython系列》2. 编译并运行 Cython 代码的几种方式 - 古明地盆 - 博客园 (cnblogs.com) ↩︎ ↩︎

  4. 花了两天,终于把 Python 的 setup.py 给整明白了 - 知乎 (zhihu.com) ↩︎

  5. Tutorials - 基础教程 - 《Cython 3.0 中文文档》 - 书栈网 · BookStack ↩︎