- 内容提要
- 前言
- 作者简介
- 封面简介
- 第1章 理解高性能 Python
- 第2章 通过性能分析找到瓶颈
- 2.1 高效地分析性能
- 2.2 Julia 集合的介绍
- 2.3 计算完整的 Julia 集合
- 2.4 计时的简单方法——打印和修饰
- 2.5 用 UNIX 的 time 命令进行简单的计时
- 2.6 使用 cProfile 模块
- 2.7 用 runsnakerun 对 cProfile 的输出进行可视化
- 2.8 用 line_profiler 进行逐行分析
- 2.9 用 memory_profiler 诊断内存的用量
- 2.10 用 heapy 调查堆上的对象
- 2.11 用 dowser 实时画出变量的实例
- 2.12 用 dis 模块检查 CPython 字节码
- 2.13 在优化期间进行单元测试保持代码的正确性
- 2.14 确保性能分析成功的策略
- 2.15 小结
- 第3章 列表和元组
- 第4章 字典和集合
- 第5章 迭代器和生成器
- 第6章 矩阵和矢量计算
- 第7章 编译成 C
- 第8章 并发
- 第9章 multiprocessing 模块
- 第10章 集群和工作队列
- 第11章 使用更少的 RAM
- 第12章 现场教训
7.6 Cython
Cython是一个能把类型注解的Python转换为一个扩展编译模块的编译器。类型注解如同C一样。能够像一个常规Python模块那样使用import来导入扩展模块。一开始简单,但是它有一个学习曲线,必须去攀登每一级更高的复杂度和优化度。对Ian来说,Cython是一个可以选择的工具,用来把计算密集型的函数转为更快的代码,这是由于它的广泛使用性、成熟性和对OpenMP的支持。
随着OpenMP标准,它可能把并行问题转换为在一台多核CPU机器上运行的多处理器感知模块。线程从你的Python代码隐藏起来了,它们通过生成的C代码来运算。
Cython(2007年发布)是Pyrex(2002年发布)的一个分支,在原始Pyrex基础上扩展了能力。使用Cython的库包括scipy、scikit-learn、lxml和zmp。
Cython能够被用来通过一个setup.py脚本编译一个模块。它也能够在IPython中通过一个“magic”命令来交互使用。通常情况下类型由开发者注解,尽管一些自动注解也是有可能的。
7.6.1 使用Cython编译纯Python版本
开始写一个扩展编译模块的简单方法涉及3个文件。使用我们的Julia作为例子,它们是:
调用它的Python代码(来自前面的Julia代码块)。
在一个新.pyx文件中要被编译的函数。
一个setup.py,包含了调用Cython来制作扩展模块的指令。
使用这个方法,setup.py脚本调用Cython把.pyx文件编译成一个编译模块。在类UNIX系统上,编译模块可能会是一个.so文件;在Windows上应该是一个.pyd(类DLL的Python库)。
对于Julia的例子,我们会用:
julia1.py,构建输入列表并调用计算函数。
cythonfn.pyx,包含了我们能注解的CPU密集型函数。
setup.py,包含了构建指令。
运行setup.py的结果就是获得一个能够被导入的模块。在例7-2的julia1.py脚本中,我们只需要做一些很小的改动来导入新的模块并调用我们的函数。
例7-2 导入新编译模块到我们的主代码中
... import calculate # as defined in setup.py ... def calc_pure_python(desired_width, max_iterations): # ... start_time = time.time() output = calculate.calculate_z(max_iterations, zs, cs) end_time = time.time() secs = end_time - start_time print "Took", secs, "seconds" ...
在例7-3中,我们将从一个没有类型注解的纯Python脚本开始。
例7-3 在cythonfn.pyx(从.py重命名)中为Cython的setup.py准备的未改动过的纯Python代码
# cythonfn.pyx def calculate_z(maxiter, zs, cs): """Calculate output list using Julia update rule""" output = [0] * len(zs) for i in range(len(zs)): n = 0 z = zs[i] c = cs[i] while n < maxiter and abs(z) < 2: z = z * z + c n += 1 output[i] = n return output
在例7-4中显示的setup.py脚本是简短的,它定义了把cythonfn.pyx转换为calculate.so的方法。
例7-4 setup.py,把cythonfn.pyx转换为由Cython去编译的C代码
from distutils.core import setup from distutils.extension import Extension from Cython.Distutils import build_ext setup( cmdclass = {'build_ext': build_ext}, ext_modules = [Extension("calculate", ["cythonfn.pyx"])] )
当我们在例7-5中用参数build_ext运行setup.py脚本时,Cython会查找cythonfn.pyx并构建calculate.so。
备忘
记住这是一个手动步骤——如果你更新了你的.pyx或者setup.py,但是忘记重新去运行构建命令,你不会获得一个要导入的更新的.so模块。如果你不确定是否编译了代码,检查.so文件的时间戳。如果怀疑的话,删除生成的C文件和.so文件,再重新构建。
例7-5 运行setup.py构建一个新的编译模块
$ python setup.py build_ext --inplace running build_ext cythoning cythonfn.pyx to cythonfn.c building 'calculate' extension gcc -pthread -fno-strict-aliasing -DNDEBUG -g -fwrapv -O2 -Wall -Wstrict-prototypes -fPIC -I/usr/include/python2.7 -c cythonfn.c -o build/temp.linux-x86_64-2.7/cythonfn.o gcc -pthread -shared -Wl,-O1 -Wl,-Bsymbolic-functions -Wl, -Bsymbolic-functions -Wl,-z, relro build/temp.linux-x86_64-2.7/cythonfn.o -o calculate.so
--inplace参数让Cython在当前目录中构建编译模块,而不是在一个独立的构建目录中。在构建完成后,我们会有两个难以卒读的中间文件cythonfn.c和calculate.so。
现在当运行julia1.py时,会导入编译模块,在Ian的笔记本电脑上计算Julia集在8.9秒内做完,而不是通常的多于11秒。这是一个花费很少工作量的小小改进。
7.6.2 Cython注解来分析代码块
前面的示例给我们演示了我们能够快速构建一个编译模块。对紧凑的循环和数学运算来说,这往往能带来速度提升。很明显,我们不应该盲目优化——我们需要知道哪里慢了,这样就能够决定集中于哪个方向去努力。
Cython有一个注解选项能输出一个可以让我们在浏览器上查看的HTML文件。要产生注解,我们要使用命令cython –a cythonfh.pyx来产生输出文件cythonfn.html。在浏览器中它看起来就像图7-2中的那样。一张类似的图片在Cython文档中有提供。
图7-2 彩色版Cython的非注解函数输出
每一行能够被双击扩展来显示生成的C代码。更多的黄色意味着“更多的Python虚拟机调用”,而更多的白色意味着“更多的非Python的C代码”。目标就是移除尽可能多的黄色并以更尽可能多的白色来结束。
尽管“更多的黄色线条”意味着更多的虚拟机调用,但这并不一定让你的代码跑得更慢。每一个虚拟机调用都有一个开销,但是这些调用的开销仅当发生于大循环的内部时才会变得显著。在大循环外部的调用(例如,在函数开始处创建输出的一行)相对于内部的计算循环的开销来说并不高。不用把你的时间浪费在不会拖慢运行速度的代码行间。
在我们的例子中,那些回调Python虚拟机最多次的代码行(“最深的黄色”)是第4行和第8行。从我们之前的剖析工作中,我们知道第8行可能被调用了超过3千万次,所以这是一个需要集中很多精力的候选对象。
第9、10和11行几乎是一样深度的黄色,我们也知道它们是在紧凑的内循环内。总体上,它们占了这个函数执行时间的大部分,所以我们需要首先集中于它们上面。如果你需要回忆起多少时间花费在这部分上了,请向前参考2.8节。
第6和第7行的黄色深度减弱了,既然它们只是被调用了一百万次,它们对最终的速度影响就小得多了,所以我们可以在稍后再集中于它们身上。事实上,因为它们是列表对象,我们几乎没有办法来提升他们的存取速度,除非如我们将会在7.8节中读到的那样,用numpy数组来取代list对象,这会带来小小的速度优势。
为了更好地理解黄色区域,我们可以双击展开每一行。在图7-3中,我们可以看到为了创建输出列表,我们遍历了zs的长度,创建了新的由Python虚拟机来引用计数的Python对象。尽管这些调用是耗时的,它们不会真正影响这个函数的执行时间。
为了改进函数的执行时间,我们需要开始声明对象的类型,这些对象被包含在了耗时的内循环中。这样这些循环才能够减少相对耗时的回调Python虚拟机的次数,从而节省时间。
一般情况下,可能最消耗CPU时间的代码行是下面这些:
在紧凑的内循环内。
解引用list、array或者np.array这些项。
执行数学运算。
图7-3 在一行Python代码后面的C代码
备忘
如果你并不知道哪些代码行执行得最频繁,使用一个剖析工具是最合适的——line_profile,在2.8节中讨论过。你将会得到哪些行在Python虚拟机中消耗最多,这样你就会有一个清晰的依据再次集中于那些代码行以获得最佳的速度。
7.6.3 增加一些类型注解
图7-2展示了函数几乎每一行都回调了Python虚拟机。我们所有的数值运算也都回调了Python虚拟机,因为我们使用的是高层的Python对象。我们需要把这些都转换为本地C对象,接着在进行数值方面的编码时,我们需要把结果再转换回Python对象。
在例7-6中,我们会看到怎样使用cdef语法来增加一些原始类型。
备忘
值得重视的是那些类型只能被Cython理解,而不是Python。Cython使用这些类型把Python代码转换为C对象,而不需要回调Python栈,这意味着运算能执行得更快,但是损失了灵活性和开发速度。
我们增加的类型是:
有符号整数int。
只能为正的无符号整数unsigned int。
双精度复数double complex。
Cdef关键字让我们在函数体内声明变量。作为C语言规范的需求,这些声明必须出现在函数顶部。
例7-6 增加原始C类型开始让我们的编译函数运行更快,通过用C做更多工作从而减少Python虚拟机的工作
def calculate_z(int maxiter, zs, cs): """Calculate output list using Julia update rule""" cdef unsigned int i, n cdef double complex z, c output = [0] * len(zs) for i in range(len(zs)): n = 0 z = zs[i] c = cs[i] while n < maxiter and abs(z) < 2: z = z * z + c n += 1 output[i] = n return output
备忘
当增加Cython注解后,你正在给.pyx增加非Python的代码。这意味着你丧失了在解释器中开发Python的天然的交互性。对熟悉C语言编程的人来说,我们又回到了编码—编译—运行—调试的循环中去了。
你可能会奇怪我们是否可以给作为参数传入的list增加类型注解。我们能够使用list关键字,但是实践中对于这个例子无效。list对象还是不得不在Python层面去查询来找出其中的内容,速度是很慢的。
给其中一些原始对象增加类型的做法在图7-4中的注解输出中反映了出来。第11和12行是很关键的地方——这2行我们调用最频繁的代码——现在已由黄色转为了白色,显示出它们不再去回调Python虚拟机了。我们可以期待与前面的版本比较之下的一个很大的速度提升。第10行被调用了超过3 000万次,所以我们还是要集中在它上面。
图7-4 我们的第一个类型注解
在编译后,这个版本花了4.3秒来完成任务。只对函数做了很少的改动,我们却跑出了是原来Python版本两倍的速度。
值得重视的是我们获得提速的原因是更多的频繁调用的运算被放到了C的层面——在这个案例中,更新了z和n。这意味着C编译器能够去优化更底层的函数来对代表这些变量的字节做运算,而不是去调用相对慢速的Python虚拟机。
我们在图7-4中可以看到while循环还是相当耗时的(黄色部分)。耗时的调用在于Python对复数z的abs函数中。Cython没有对复数提供原生的abs函数。作为替代,我们可以提供自己的本地扩展。
就如本章前面提醒的那样,对一个复数做abs涉及对实部和虚部的平方和开平方根。在我们的测试中,我们想要看看结果的平方根是否小于2。与其开平方根,我们不如对比较表达式的另一端求平方,因此我们把<2转换为<4。这就避免了必须要计算平方根来作为abs函数的最终结果。
实质上,我们从下式开始:
我们还有简化版的运算:
如果我们在下列代码中保留了sqrt运算,我们还是能看到执行速度的提升。优化代码的秘诀之一就是让它尽可能的少干活。通过考虑一个函数的最终目标来移除相对耗时的运算意味着C编译器能集中于它所擅长的方面,而不是尝试去感知程序员的最终需求。
编写等价的但是更特殊的代码来解决相同的问题就是所谓的强度减弱。你用更糟糕的灵活性(和可能更糟糕的可读性)去换来更快的执行速度。
数学分解在下一个例子中,见例7-7,其中我们已经用一行简化的扩展数学表达式来代替相对耗时的abs函数。
例7-7 用Cython来扩展abs函数
def calculate_z(int maxiter, zs, cs): """Calculate output list using Julia update rule""" cdef unsigned int i, n cdef double complex z, c output = [0] * len(zs) for i in range(len(zs)): n = 0 z = zs[i] c = cs[i] while n < maxiter and (z.real * z.real + z.imag * z.imag) < 4: z = z * z + c n += 1 output[i] = n return output
通过代码注解,我们看到在第10行(图7-5)以while语句为代价换来的小小改进。现在它包含了更少的Python虚拟机调用。尽管对于能够获得多少速度提升来说并不是立即显而易见的,但是我们知道这行被调用了超过3000万次,因此我们期待一个优越的性能提升。
图7-5 展开数学表达式取得了最终的胜利
这个改变有巨大的效果——通过减少在最内循环中Python调用的次数,我们大大降低了函数的运算时间。这个新版本只用0.25秒就执行完毕了,具有超过原版本40倍的速度提升,令人惊叹。
备忘
Cython支持几种编译成C的方式,有一些比这里描述的全类型注解方式要来得简单。如果你想要一个更简单的起点来使用Cython,并且查看pyximport来方便向同事介绍Cython,你应该熟悉纯Python模式。
为了对代码片段做最终可能的改进,我们会禁止list中的每个解引用的边界检查。边界检查的目的是确保程序不会去访问超出分配数组的空间——在C中,很容易无意中访问了超出数组边界的内存,产生不可预料的结果(可能是一个段错误!)。
Cython默认会保护程序员免于意外地去寻址超出list边界的空间。这种保护消耗了一点CPU时间,但是它发生于我们函数的外循环处,所以总体上不会占用很多时间。通常禁止边界检查是安全的,除非你要执行自己的计算来做数组寻址,这种情况下你会不得不小心翼翼地呆在list的边界内。
Cython有一系列标志开关可以用各种各样的方式去表述。最简单的就是在.pyx文件的起始处把它们作为单行注释加入。也可以利用装饰器或编译时标志来改变这些设定。为了禁止边界检查,我们在.pyx文件开头的注释内给Cython增加了一行指令(directive)。
#cython: boundscheck=False def calculate_z(int maxiter, zs, cs):
要注意的是,禁止边界检查只会节省一点时间,因为它发生于外循环中,而不是在更耗时的内循环中。对于这个例子来说,它不会节省更多的时间。
备忘
如果你的CPU密集型代码在频繁解引用的循环中,尝试禁止边界检查和外围检查。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论