《Cython系列》2. 编译并运行 Cython 代码的几种方式

楔子

Python 和 C、C++ 之间一个最重要的差异就是 Python 是解释型语言,而 C、C++ 是编译型语言。如果开发 Python 程序,那么在修改代码之后可以立刻运行,而 C、C++ 则需要一个编译步骤。编译一个规模比较大的 C、C++ 程序,那么可能会花费我们几个小时甚至几天的时间;而使用Python则可以让我们进行更敏捷的开发,从而更具有生产效率。

而 Cython 同 C、C++ 类似,在源代码运行之前也需要一个编译的步骤,不过这个编译可以是隐式的,也可以是显式的。而自动编译 Cython 的一个很棒的特性就是它使用起来和纯 Python 是差不多的,无论是显式还是隐式,我们都可以将 Python 的一部分(计算密集)使用 Cython 重写,因此 Cython 的编译需求可以达到最小化。因为没有必要将所有的代码都用 Cython 编写,而是将那些需要优化的代码使用 Cython 编写即可。

在这一篇博客中,我们将会介绍编译 Cython 代码的几种方式,并结合 Python 使用。因为我们说 Cython 是为 Python 提供扩展模块,最终还是要通过 Python 解释器来调用的。

而编译Cython有以下几个选择:

  • Cython 代码可以在 IPython 解释器中进行编译,并交互式运行。
  • Cython 代码可以在导入的时候自动编译。
  • Cython 代码可以通过类似于 Python 的 disutils 模块的编译工具进行独立编译。
  • Cython代码可以被继承到标准的编译系统,例如:make、CMake、SCons。

这些选择可以让我们在几个特定的场景应用 Cython,从一端的快速交互式探索到另一端的快速构建。

但无论是哪一种编译方式,从传递 Cython 代码到生成 Python 可以导入和使用的扩展模块都需要经历两个步骤。在我们讨论每种编译方式的细节之前,了解一下这两个步骤到底在做些什么是很有帮助的。

Cython编译 Pipeline

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

Pipeline 的职责就是将 Cython 代码转换成 Python 解释器可以直接导入并使用的 Python 扩展模块,这个 Pipeline 可以在不受用户干预的情况下自动运行(使 Cython 感觉像 Python 一样),也可以在需要更多控制时由用户显式的运行。

Pipeline 由两步组成:第一步是由 cython 编译器负责将 Cython 转换成经过优化并且依赖当前平台的 C、C++ 代码;第二步是使用标准的 C、C++ 编译器将第一步得到的 C、C++ 代码进行编译并生成标准的扩展模块,并且这个扩展模块是依赖特定的平台的。如果是在 Linux 或者 Mac OS,那么得到的扩展模块的后缀名为 .so,如果是在 Windows 平台,那么得到的扩展模块的后缀名为 .pyd(扩展模块 .pyd 本质上是一个 DLL 文件)。不管是什么平台,最终得到的都会是一个成熟的 Python 扩展模块,它是可以直接被 Python 解释器进行 import 的。

而工具在管理这几个步骤所面临的复杂性,我们都会在这一篇博客的结尾进行描述。尽管在编译 Pipeline 运行的时候我们很少去关注究竟发生了什么,但是将这些过程记在脑海总归是好的。

Cython编译器是一种 源到源 的编译器,并且生成的扩展模块也是经过高度优化的,因此 Cython 生成的 C 代码编译得到的扩展模块 比 手写的 C 代码编译得到的扩展模块 运行的要快并不是一件稀奇的事情。因为 Cython 生成的 C 代码是经过高度精炼,所以大部分情况下比手写所使用的算法更优,而且 Cython 生成的 C 代码支持所有的通用 C 编译器,生成的扩展模块同时支持许多不同的 Python 版本。

所以 Cython 和 C 扩展本质上干的事情是一样的,都是将符合 Python/C API 的 C 代码编译成 Python 扩展模块,只不过写 Cython 的话我们不需要直接面对 C,cython 编译器会自动将 Cython 代码翻译成 C 代码,然后我们再将其编译成扩展模块。所以两者本质是一样的,只不过 C 比较复杂,而且难编程;但是 Cython 简单,语法本来就和 Python 很相似,所以我们选择编写 Cython,然后让 cython 编译器帮我们把 Cython 代码翻译成 C 的代码。而且重点是,cython 编译器是经过优化的,如果我们能写出很棒的 Cython 代码,那么 cython 编译器在编译之后就会得到同样高质量的 C 代码。

安装

现在我们知道在编译 Pipeline 中有两个步骤,而实现这两个步骤需要我们确保机器上有 C、C++ 编译器以及 Cython 编译器,不同的平台有不同的选择。

C、C++编译器

Linux 和 Mac OS 无需多说,因为它们都自带 gcc,但是注意:如果是 Linux 的话,我们还需要 yum install python3-devel(以 CentOS 为例)。至于 Windows,可以下载一个 Visual Studio,但是那个玩意会比较大,如果不想下载 vs 的话,那么可以选择安装一个 MinGW 并设置到环境变量中,至于下载方式可以去 https://sourceforge.net/projects/mingw/files/ 进行下载。

当然环境什么的,这里就不细说了,如果这一点都无法解决的话,根本就入不了 Cython 的大门。

安装 cython 编译器

安装 cython 编译器的话,可以直接通过 pip install cython 即可。因此我们看到 cython 编译器只是 Python 的一个第三方包,因此运行 Cython 代码同样要借助 Python 解释器。

在终端中输入 cython -V,看看是否会提示 cython 的版本,如果正常显示,那么证明安装成功。

或者写代码查看:

from Cython import __version__

print(__version__)  # 0.29.14

如果代码正常执行,那么证明安装成功。

disutils

Python 有一个标准库 disutils,可以用来构建、打包、分发 Python 工程。而其中一个对我们有用的特性就是它可以借助 C 编译器将 C 源码编译成扩展模块,并且这个模块是自带的、考虑了平台、架构、Python 版本等因素,因此我们在任意地方使用disutils都可以得到扩展模块。

注意:上面 disutils 只是帮我们完成了 Pipeline 的第二步,那第一步呢?第一步则是需要 cython 来完成。

以我们之前说的斐波那契数列为栗:

# 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

然后我们对其进行编译:

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 代码生成扩展模块

这个文件叫做 1.py,这里只是做了准备,但是还没有进行编译。我们需要终端执行 python 1.py build 进行编译。

在我们执行命令之后,当前目录会多出一个 build 目录,里面的结构如下。重点是那个 fib.cp38-win_amd64.pyd 文件,该文件就是根据 fib.pyx 生成的扩展模块,至于其它的可以直接删掉了。我们把这个文件单独拿出来测试一下:

import fib
# 我们看到该 pyd 文件直接就被导入了, 至于中间的 cp38-win_amd64 指的是对应的解释器版本、操作系统等信息
print(fib)  # <module 'fib' from 'D:\\satori\\fib.cp38-win_amd64.pyd'>

try:
    # 我们在里面定义了一个 fib 函数, 在 fib.pyx 里面定义的函数在编译成扩展模块之后可以直接使用
    print(fib.fib("xx"))
except Exception:
    import traceback
    print(traceback.format_exc())
    """
    Traceback (most recent call last):
      File "D:/satori/1.py", line 7, in <module>
        print(fib.fib("xx"))
      File "fib.pyx", line 6, in fib.fib
        for i in range(n):
    TypeError: an integer is required
    """
# 因为我们定义的是fib(int n), 而传入的不是整型, 所以直接报错
print(fib.fib(20))  # 6765.0

# 我们的注释
print(fib.fib.__doc__)  # 这是一个扩展模块

我们在 Linux 上再测试一下,代码以及编译方式都不需要改变,并且生成的动态库的位置也不变。

>>> import fib
>>> fib
<module 'fib' from '/root/fib.cpython-36m-x86_64-linux-gnu.so'>
>>> exit()

我们看到依旧是可以导入的,只不过 Linux 上是 .so 的形式,Windows 上是 .pyd。因此我们可以看出,所谓 Python 的扩展模块,本质上就是当前操作系统上一个动态库,只不过生成该动态库对应的 C 源文件遵循标准的 Python/C API,所以它是可以被 Python 解释器识别、直接通过 import 语句进行导入的,就像导入普通的 py 文件一样。而对于其它的动态库,比如 Linux 中存在大量的动态库(.so文件),而它们则不一定是由遵循标准 Python/C API 的 C 文件生成的,所以此时再通过 import 进行导入的话解释器是无法识别的,如果 Python 想调用这样的动态库就需要使用 ctypes 模块了。

引入 C 源文件

除此之外我们还可以嵌入 C、C++ 的代码,我们来看一下。

// cfib.h
double cfib(int n);  // 定义一个函数声明



//cfib.c
double cfib(int n) {
    int i;
    double a=0.0, b=1.0, tmp;
    for (i=0; i<n; ++i) {
        tmp = a; a = a + b; b = tmp;
    }
   return a;
} // 函数体的实现

然后是 pyx 文件:

# 通过 cdef extern from 导入头文件, 写上里面的函数
cdef extern from "cfib.h":
    double cfib(int n)

# 然后 Cython 可以直接调用
def fib_with_c(n):
    """调用 C 编写的斐波那契数列"""
    return cfib(n)

最后是编译:

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

# 我们看到之前是直接往 cythonize 里面传入一个文件名即可
# 但是现在我们传入了一个 Extension 对象, 通过 Extension 对象的方式可以实现更多功能
# 这里指定的 name 表示编译之后的文件名, 显然编译之后会得到 wrapper_cfib.cp38-win_amd64.pyd
# 如果是之前的方式, 那么得到的就是 fib.cp38-win_amd64.pyd, 默认会和 .pyx 文件名保持一致, 这里我们可以自己指定
# sources 则是代表源文件, 这里我们只需要指定 pyx 和 c 源文件即可, 因为头文件也在同一个目录中
# 如果不在, 那么还需要通过 include_dirs 指定头文件的所在目录, 不然 extern from "cfib.h" 就报错了
ext = Extension(name="wrapper_cfib", sources=["fib.pyx", "cfib.c"])
setup(ext_modules=cythonize(ext))

然后我们来调用一下:

import wrapper_cfib

print(wrapper_cfib.fib_with_c(20))  # 6765.0
print(wrapper_cfib.fib_with_c.__doc__)  # 调用 C 编写的斐波那契数列

我们看到成功调用 C 编写的斐波那契数列,这里我们使用了一种新的创建扩展模块的方法,我们来总结一下。

  • 如果是单个 pyx 文件的话, 那么直接通过 cythonize("xxx.pyx") 即可
  • 如果 pyx 文件还引入了 C 文件, 那么通过 cythonize(Extension(name="xx", sources=["", ""])) 的方式即可; name 是编译之后的扩展模块的名字, sources 是你要编译的源文件, 我们这里是一个 pyx 文件一个 C 文件;

建议后续都使用第二种方式,可定制性更强,而且我们之前使用的 cythonize("fib.pyx") 完全可以用 cythonize(Extension("fib", ["fib.pyx"])) 进行替代。

关于使用 Cython 包装 C、C++ 代码的更详细细节,我们会在后续系列中详细介绍,总之我们编译的时候相应的源文件是不能少的。

通过 IPython 动态交互 Cython

使用 distutils 编译 Cython 代码可以让我们控制每一步的执行过程,当时也意味着我们在使用之前必须要先经过独立的编译,不涉及到交互式。而 Python 的一大特性就是交互式,比如 IPython,所以需要想个法子让 Cython 也支持交互式,而实现的办法就是使用魔法命令。

我们打开 IPython,在上面演示一下。

# 我们在 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 会再将该模块内的所有内容导入到当前的环境中,以便我们使用。

因此上述的编译过程、编译完成之后的导入过程,都是我们在按下两次回车键之后自动发生的。但是不管怎么样,它都涉及到编译成扩展模块的过程,包括后面要说的即时编译,只不过这一步不需要你手动做了。

当然相比 IPython,我们更常用 jupyter notbook,既然 Cython 在前者中可以使用,那么后者肯定也是可以的。

jupyter notebook 底层也是使用了 IPython,所以它的原理和 IPython 是等价的,会先将代码块 copy 到名字唯一的 .pyx 文件中,然后进行编译。编译完毕之后再将里面的内容导入进来,而第二次编译的时候由于单元格里面的内容没有变化,所以不再进行编译了。

另外在编译的时候如果指定了 --annotate 选项,那么还可以看到对应的代码分析。

可以看到还是非常强大的,尤其是在和 jupyter 结合之后,真的非常方便。

使用 pximport 即时编译

因为 Cython 是以 Python 为中心的,所以我们希望 Python 解释器在导包的时候能够自动识别 Cython 文件,导入 Cython 就像导入常规、动态的 Python 文件一样。但是不好意思,Python 在导包的时候并不会自动识别以 .pyx 结尾的文件,但是我们可以通过 pyximport 来改变这一点。

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

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

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

正如我们上面演示的那样,使用 pyximport 可以让我们省去 cythonize 和 distutils 这两个步骤(注意:这两个步骤还是存在的,只是不用我们做了)。另外, Cython 源文件不会立刻编译,只有当被导入的时候才会编译,并且即便后续 Cython 源文件被修改了,pyximport 也会自动检测,当重新导入的时候也会再度重新编译,机制就和 Python 的 pyc 文件是一个道理。

自动编译之后的 pyd 文件位于 ~/.pyxbld/lib.xxx 中。

但是这样有一个弊端,我们说 pyx 文件并不是直接导入的,而是在导入之前先有一个编译成扩展模块的步骤,然后导入的是这个扩展模块,只不过这一步骤不需要我们手动来做了。所以它要求你的当前环境中有一个 cython 编译器以及合适的 C 编译器,而这些环境是不受控制的,没准哪天就编译失败了。因此最保险的方式还是使用我们之前说的 distutils,先编译成扩展模块(.pyd 或者 .so),然后再放在生产模式中使用。

但是问题来了,如果包含 Cython 文件中还引入了其它的 C 文件该怎么办呢?还以我们之前的斐波那契数列数列为例:

// cfib.h
double cfib(int n);  // 定义一个函数声明



//cfib.c
double cfib(int n) {
    int i;
    double a=0.0, b=1.0, tmp;
    for (i=0; i<n; ++i) {
        tmp = a; a = a + b; b = tmp;
    }
   return a;
} // 函数体的实现

然后在 fib.pyx 中导入它:

cdef extern from "cfib.h":
    double cfib(int n)

def fib_with_c(n):
    return cfib(n)

那么问题来了,这个时候如果导入 fib 会发生什么后果呢?答案是报错,因为它不知道该去哪里寻找这些外部文件,而显然这些文件应该是要链接在一起的。那么要如何做呢?就是我们下面要说的问题了。

控制 pyximport 并管理依赖

我们说手动编译的时候,我们可以直接指定依赖的 C 文件的位置,但是直接导入 .pyx 文件的时候是并不知道这些依赖在哪里的。所以我们应该还要定义一个 .pyxbld 文件,.pyxbld 文件要和 .pyx 文件具有相同的基名,比如我们是为了指定 fib.pyx 文件的依赖,那么 .pyxbld 文件就应该叫做 fib.pyxbld,并且它们要位于同一目录中。

那么这个 .pyxbld 文件里面应该写什么内容呢?

# 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=["."])
    # 我们看到整体还是类似的逻辑, 因为编译这一步是怎么也绕不过去的
    # 区别就是手动编译还是自动编译, 如果是自动编译, 显然限制会比较多
    # 如果想解除限制, 那么我们看到这和手动编译没啥区别了, 甚至还要更麻烦一些

此时我们再来直接导入看看,会不会得到正确的结果。

import pyximport
pyximport.install(language_level=3)

import fib
print(fib.fib_with_c(50))  # 12586269025.0

.pyxbld 文件中除了通过定义 make_ext 函数的方式外,还可以定义 make_setup_args 函数。对于 make_ext 函数,我们说在编译的时候会自动传递两个参数:modname、pyxfilename。但如果定义的是 make_setup_args 函数,那么在编译时就不会传递任何参数,一些都由你自己决定。

但这里还有一个问题,首先 Cython 源文件一旦改变了,那么再导入的时候就会重新编译;但如果 Cython 源文件(.pyx)依赖的 C 文件改变了呢?这个时候导入的话还会自动重新编译吗?答案是会的(没想到吧),cython 编译器不仅会检测 Cython 文件的变化,还会检测它依赖的 C 文件的变化。

我们将 fib.c 中的函数 cfib 的返回值加上 1.1,然后其它条件不变,看看结果如何。

import pyximport
pyximport.install(language_level=3)

import fib
print(fib.fib_with_c(50))  # 12586269026.1

可以看到结果变了,之前的话还需要定义一个具有相同基名的 .pyxdeps 文件,来指定 .pyx 文件具有哪些依赖,但是目前不需要了,也会自动检测依赖文件的变化。

但是说实话,像这种依赖 C 文件的情况,建议还是事先编译好,这样才能百分百稳定运行。当然如果你部署服务的环境具备编译条件,那么也可以不用提前编译。

小结

目前我们介绍了如何将 pyx 文件编译成扩展模块,对于一个简单的 pyx 文件来说,方法如下:

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

# 推荐以后就使用这种方法
ext = Extension(
    name="wrapper_fib",  # 生成的扩展模块的名字
    sources=["fib.pyx"], # 源文件
)
setup(ext_modules=cythonize(ext, language_level=3))  # 指定Python3

如果还有其它需求,那么就通过其它参数指定,这些上面都说过了。

此外还可以通过 pyximport 自动编译,我们后面在学习 Cython 语法的时候,就采用这种自动编译的方式了。因为方便,不需要我们每次都来手动编译,但是要将服务放在生产环境中,建议还是提前编译好。

那么下一篇博客将来学习 Cython 的语法知识,看看如何才能写出健壮的 Cython 代码。

posted @ 2020-06-30 13:17  古明地盆  阅读(11657)  评论(0编辑  收藏  举报