- 内容提要
- 前言
- 作者简介
- 封面简介
- 第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.8 Cython 和 numpy
list对象(作为背景,请看第3章)的每一个解引用都有开销,因为它们所引用的对象可以存在于内存中任意处。而相反,array对象在RAM的连续块中存储原生类型,能够被快速寻址。
Python具有array模块,为基础原生类型(包括整数、浮点数、字符串和Unicode字串)提供了一维存储。Numpy的numpy.array模块允许多维存储和更多样的原生类型,包括复数。
当以可预测的方式去迭代访问一个array对象时,如果要移动到序列中的下一个原生项,编译器会被指导去直接访问该项的内存地址,而不是让Python来计算出一个合适的地址。既然数据布局到了连续块中,在C中用偏移量来计算下一项的地址就是轻而易举的,而不要让CPython去计算来得到相同的结果,因为这会涉及慢速的虚拟机回调。
你应该注意到了如果运行接下来的numpy版本而不用任何Cython注解(例如,只是作为一个普通Python脚本运行),大概要71秒跑完——远超出普通的Python list脚本,它大概花费11秒。拖慢运行速度的原因是在numpy lists中解引用每一个元素的开销——它从来不是为这种用法而设计的,即使对初学者来说,这种用法看上去直观。通过编译代码,我们移除了这个开销。
Cython为此有两种特殊的语法形式。更老的Cython版本有一种特殊的访问numpy array的类型,但最近更一般化的缓存接口协议通过memoryview引入了进来——允许对实现了缓存接口的任意对象进行相同的低级访问,包括numpy arrays和Python arrays。
缓存接口的一个附加优势是内存块能够很容易地在其他C库中共享,而不需要把它们从Python对象转换成其他形式。
例7-11中的代码块看上去有一点像原来的实现,除了我们增加的memoryview注解外。函数的第2个参数是double complex[:] zs,意味着我们有一个使用缓存协议(用[]声明)的双精度复数对象,包含了一个一维的数据块(由一个冒号:声明)。
例7-11 Julia计算函数的注解numpy版本
# cython_np.pyx import numpy as np cimport numpy as np def calculate_z(int maxiter, double complex[:] zs, double complex[:] cs): """Calculate output list using Julia update rule""" cdef unsigned int i, n cdef double complex z, c cdef int[:] output = np.empty(len(zs), dtype=np.int32) 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
除了使用缓存注解语法声明输入参数外,我们也注解了输出变量,由empty给它分配了一个一维的numpy array。调用empty会分配一块内存,但是不会用完整的数值初始化内存,所以它不包含任何东西。我们在内循环中会覆写这个array的内容,所以我们不需要重复给它赋默认值。这要比分配并给array的内容赋默认值要稍快一点。
我们也使用更快、更显式的数学版本来展开abs的调用。这个版本用了0.23秒跑完——结果比原来在例7-7中的纯Python的Julia例子的Cython化版本要稍微快一点。纯Python版本有每次解引用一个Python复数对象的开销,但是这些解引用发生于外循环中,所以不占用多少执行时间。在外循环之后,我们做了这些变量的本地版本,它们以“C的速度”运行。这个numpy的例子和之前的纯Python例子中的内循环都对相同的数据做了相同的处理,所以执行时间的差异归因于外循环中的解引用和输出队列的创建。
在一台机器上使用OpenMP来做并行解决方案
作为这版代码演进的最后步骤,让我们看一下使用OpenMP C++扩展来并行化处理让我们为难的并行问题。如果你的问题适合这个模式,那么你就能很快发挥你的计算机多核的优势。
OpenMP(Open Multi-Processing)是一个定义良好的跨平台API,支持并行执行,以及与C、C++和Fortran的内存共享。它被构建入了大多数的现代C编译器,如果C代码编写合适的话,并行化就会在编译器级别上发生,所以它就给开发者使用Cython带来了相对小的工作量。
与Cython一起,OpenMP能够通过使用prange(并行range)操作符和给setup.py增加-fopenmp编译指令的方式加入进来。在一个prange循环中的工作就能够做到并行运行,因为我们禁止了全局解释器锁(GIL)。
一个修改过的支持prange的代码版本显示在例7-12中。用nogil:来声明禁止GIL的代码块,在这个代码块内部,我们使用prange为循环开启OpenMP并行模式来独立计算每一个i。
警告
当禁止GIL时,我们一定不能在常规Python对象(例如,lists)上操作,必须要在原生对象和支持memoryview接口的对象上去操作。如果并行操作了常规的Python对象,我们不得不去解决随之而来的内存管理问题,而这是GIL意图避免的。Cython不阻止我们去操控Python对象,但是如果你这样做,只会招来痛苦和困扰。
例7-12 增加prange来启用OpenMP并行化
# cython_np.pyx from cython.parallel import prange import numpy as np cimport numpy as np def calculate_z(int maxiter, double complex[:] zs, double complex[:] cs): """Calculate output list using Julia update rule""" cdef unsigned int i, length cdef double complex z, c cdef int[:] output = np.empty(len(zs), dtype=np.int32) length = len(zs) with nogil: for i in prange(length, schedule="guided"): z = zs[i] c = cs[i] output[i] = 0 while output[i] < maxiter and (z.real * z.real + z.imag * z.imag) < 4: z = z * z + c output[i] += 1 return output
为了编译cython_np.pyx,我们不得不修改setup.py脚本,就如在例7-13中显示的那样。我们让它通知C编译器在编译期间使用-fopenmp作为参数来启用OpenMP以及和OpenMP库去链接。
例7-13 为Cython在setup.py中增加OpenMP编译器和和链接器标志
#setup.py 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", ["cython_np.pyx"], extra_compile_args=['-fopenmp'], extra_link_args=['-fopenmp'])] )
使用Cython的prange,我们能够选择不同的调度方式。使用static,工作负载可以均匀地在可用的CPU之间分布。我们的一些计算区域在时间上是开销很大的,另一些则开销很小。如果我们用static让Cython在CPU之间平等地调度工作块,那么一些区域要比另外一些完成得快,那些线程就会处于空闲状态。
D``ynamic和guided调度选项都企图缓解这个问题,可以通过在运行时动态地把工作分配给更小的块,这样当工作负载的运算时间可变时,CPU会更均匀地得到分布。对你的代码来说,正确的选择将会是根据你的工作负载的本质而做改变。
通过引入OpenMP和使用schedule=”guided’,我们把执行时间降低到了接近0.07秒——guided调度会动态地分配工作,所以更少的线程在等待新的工作。
我们也可能为这个例子使用#cython:boundscheck=False来禁止边界检查,但是这不会改进我们的运行时间。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论