读python高性能编程

写在前面

最近看了本书,“python高性能编程”。其实买书的时候还是对这个书抱有很大的希望的,但是读了一遍之后,感觉,翻译,对,翻译,实在是太烂了。好多中式英语不说,甚至有些地方不是很通顺。不过对于我这样英文一般的人来讲肯定还是比英文书看的效率高些。书中其次讲述了优化python效率,增强计算性能和节省空间的方法,部分内容还是很有启发性的,这里简单的聊一聊。(下面的内容和书中的介绍顺序不一定一致,但是大多数内容都是可以在书中找到对应的)

python高性能的局限

我之前在学习大气模式的时候也是解除了一些编程语言,C、c++、fortran。但是这次是第一次使用动态语言来实现高性能。各种原因主要是python是一个高级语言,python解释器为了抽离底层用到的计算元素做了很多工作,我们实际上面对的就是一个python黑箱,只是知道我们的操作和计算是可以做到的但是执行的具体过程对开发者是不透明的;另一个原因就是开发python解释器的时候为了线程安全所引入的全局解释器锁,这就是“臭名昭著的GIL”。
对于前一个问题,所造成的影响比较直观,比如由于python虚拟机的影响使得矢量操作不能直接可用了,但是其实我们可以用例如numpy这样的外部库实现,这个并不是一个根本性的问题;另外就是数据局部性的问题,python抽象影响了任何需要为下一次计算所准备的缓存安排,这是由于python是一个垃圾收集语言,虽然对象在无引用的时候会进入垃圾收集过程,但是内存是自动分配并在需要的时候释放的,这会导致内存碎片并且会影响向CPU缓存的传输;最后一个就是由于python是高级语言,所谓高处不胜寒。高级语言在让开发者方便的实现程序原型的时候也带来了一个问题,就是没有来自编译器的恰当优化。当我们编译静态代码时,编译器可以做很多的操作来改变对象的内存布局以及优化CPU的指令来优化。此外由于python支持动态类型,张三可能一开始还是个草履虫,转眼间就是人了,这让优化过程更是难上加难。
对于后一个问题,首先谈一谈GIL本身。前面已经说过了,GIL中文名称是全局解释器锁。但是需要明确一点,就是虽然这里提到了这个是影响python高性能的缺点,但是这并不是python语言的特性,这只是在实现python解释器(CPython)的时候所引入的特性。一些其他的python解释器是没有这样的问题的,如JPython。但是谁让现在是CPython的天下呢?
回到GIL,官方的解释是这样的:

In CPython, the global interpreter lock, or GIL, is a mutex that prevents multiple native threads from executing Python bytecodes at once. This lock is necessary mainly because CPython’s memory management is not thread-safe. (However, since the GIL exists, other features have grown to depend on the guarantees that it enforces.)

可以看到,为了解决多线程之间的数据完整性和状态同步,前人们用了最自然的解决方法,就是加锁。但是由于这么干了之后很多库的开始接受这样的设定,就导致这个并不是python特性非常像“特性”。但是随着python3开始的优化设计,至少在高密度I/O问题时,多线程仍然是一个可行的加速方案。

一些性能检测工具

在书的第二章中,作者介绍了几种比较好用的性能检测工具(for python2,but some of them are available in python3)。

  • import time 使用python time模块,这个是最为直观且方便的方式,但是这样的函数插在代码段中不甚优雅
  • 定义一个修饰器,实际上也是调用了上面的time module,但是只需要在需要做性能分析的函数上@修饰器就可以完成,相对比较优雅;且由于上一种方法调用函数带来了额外的开销,所以一般情况下,这种方法测得的时间要小于上一种方法
  • 使用timeit模块,如 python -m timeit -n 5 -r 5 "import 测试代码" "代码中具体函数,需要填写具体形参",若使用的是ipython解释器可以直接使用%timeit来减少工作量
  • 使用系统/usr/bin/time --verbose(注意这里不同于shell的内建time),返回三个时间,real ,user,和sys,对于一个单核机器来讲real~=user+sys,但是对于多核机器,由于任务会分配到多个核上完成,所以这个一般不成立
  • 使用标准库内建的cProfile工具,python -m cProfile -s cumulative ex.py
  • 对于cpu密集型程序可以使用line_profiler
  • heapy 调查堆上的对象
  • dowser画出变量实例
  • dis 检查字节码

上面介绍的基本上涵盖了不同粒度的性能分析工具,且上面的工具都是我查证python3可用的,毕竟几个月之后就是彻底和python2说再见的时候了。
前四个工具只能提供一个大概的运行时间情况,只能让我们知道哪个部分的代码运行的时间比较长,但是不能让我们真正的了解为什么。所以需要后面的cProfile了解代码调用的情况,精确的定位位置,然后使用line_profiler逐行工具进行分析,必要的时候再加以dis字节码检查,基本上就是一个从粗到细的优化过程了。但是中间值得注意的是,我们使用的工具很多都是需要修饰器的,但是修饰器在进行代码测试的时候会影响到代码的正确性,实际上是一个很烦人的存在,所以我们需要使用No-op的修饰器来防止测试的时候出现问题。
最后,如果有条件的话,最好还是在做性能分析的时候保持系统和运行进程相对“干净”,尽量不要影响到分析的结果。

计算使用的基本数据结构

列表和元组

首先用一句话说明列表和元组之前的联系,他们都是可以存放数组的容器,列表是动态的数组,元组是静态的数组。列表是可以重设长度的,但是素组不可以,其内部元素创建之后就不能可以改变了。且元组缓存与python运行时环境,也就是说我们在每次使用元组的时候无需访问访问内核来分配内存,无疑效率是比较高的。
详细谈谈列表的内存分配方式,由于列表是可以改变大小的,在列表长度增加的时候,实际上python会在每一次创建新的数据的时候创建一个新的列表,但是这个新的列表的大小大于之前列表的长度+1,这是由于我们每一次新的添加可能是后面很多次类似添加的开始,逻辑上可以理解为是一种操作局部性。值得注意的是这个和过程中由于涉及到了内存复制,所以操作的代价是很大的。
那么元组又是怎样的呢,不同于列表,元组是没有“超售现象的”,所以元组每增加一个元素都会有分配和复制工作,而不是像列表一样只在当前列表可使用的长度不够的时候才做。另外,由于元组具有静态特性,所以python在处理元组的时候是资源缓存的,也就是说即使是元组已经不再使用,它们的空间也不会立即返还给系统,而是留待未来使用。也就是未来要是需要同样大小的元组的时候,不需要向操作系统申请内存,而是直接使用这样的预留空间。
对于列表中数据的搜索问题,建议使用python内建的排序(Tim算法)+二分搜索。

字典和集合

其实这里讲的字典和集合可以认为是上面列表的一种广义表现。你看,我们在使用列表的时候,实际上index,或者说内存位置的offser就是字典的键值啊!而实际上,字典的实现就是借鉴了这个想法。总的来讲,如果我们有一些无序数据,但是可以被唯一的索引对象来引用(任何可以被散列的类型都可以成为索引对象),那么我们就可以利用字典和集合了。集合其实还是更为特殊一些,我们可以认为集合只是由不包含value的keys所组成的。
字典的查询作用是比较简单的,但是在插入数据时则会需要散列函数的帮助。本质上,新插入数据的位置取决于数据的两个属性,键的散列值以及该值如何跟其他对象比较。这是由于当我们插入数据时,首先需要计算键的散列值并掩码来得到一个有效的数组索引。掩码是为了保证一个可能是任意数字的散列值最终可以转化到索引区间中。插入过程中,如果找到了对应的索引位置,但是索引位置对应的值是空的,我们可以将值附上,但是若是索引位置已经被使用,则分成两种情况,若索引位置中的值与我们希望插入的值相等,则直接返回,或不是相等关系,我们需要嗅探一个新的索引位置,而为了执行嗅探来找到新的位置我们需要使用一个函数计算出新的位置。这个函数实际上我们希望他具有两个性质,一是对于一定的键值其输出是确定的,不然在指定查找操作的时候我们会有大麻烦,另外就是对于不同的键值输入函数的结果分散,也就是函数的熵应该足够大。而在对字典性能的影响上,主要需要考虑的是当字典的规模逐渐变大的时候,当越来越多的内容插入散列表的时候,表本身必须改变大小来适应。我们有一个较为普遍的规律,就是一个不超过三分之二满的表在具有最佳空间节约的同时依然具有不错的散列碰撞避免率。但是当一个散列表满的时候,就需要分配一个更大的表,并将掩码调整为适合新的表,旧表中的所有元素再被重新插入新表。而这中间就需要重新计算索引,所以在对性能进行优化的时候需要对此保持警惕。尽量减少由于散列表长度不够导致的重复分配。

字典和命名空间

python的命名空间和字典也有很大的关系,事实上python可以说是过度的使用了字典来进行查询。当python访问一个变量、函数、模块的时候,都有一个机制决定如何对这些对象进行查找。顺序上,python首先查找locals()数组,其内保存了所有本地变量的条目。实际上python在这里进行了比较大的优化工作,这里也是上面所提到的搜索链中唯一不需要字典查询的部分。而如果python在locals()中没有查到,则会搜索global()字典,最后则是会搜索__buildin__对象。所以在尝试优化本地代码的时候一个可选的方案是去使用本地变量保存外部函数。当然,最好显示的给出以增加代码的可读性。

迭代器和生成器

一开始,我们先泼一盆冷水,事实上生成器并不能为计算效率做啥贡献,不过确实可以减小内存消耗。另外书中介绍生成器的使用的时候说明了生成器可以与普通函数搭配起来,生成器用于创建数据,而普通函数则负责操作生成的数据。这种功能和逻辑上的划分增加了代码的清晰度和功能,并且是解耦的体现。除此之外,书中也介绍了生成器带来的问题,也就是“单通”问题,我们只能访问当前的值,但是无法访问数列中的其他元素。但是没啥可抱怨的,毕竟生成器可以节省内存也就是节省在了这个上面,但是这并不是无法避免的trade-off。可以调用python标准库中的itertools库,其中有部分函数可以帮助我们解决这样的问题(如islice等)。

矩阵和矢量计算

这章节的内容主要是介绍python矢量计算可能存在的瓶颈,并介绍了原因和解决方法。过程是用一个扩散方程作为实例展开讲的,其中值得注意的点有几个。首先是在高密度的CPU计算环境下,内存分配确实不便宜,每次当我们需要内存用于存储一个变量或列表,Python都必须花时间向操作系统申请更多的内存空间,然后还要遍历新分配的空间来将他初始化为某个值,所以在变量分配足够的情况下,应当尽量利用已(或者说复用)已分配的内存空间,这样会给我们带来一定的速度提升。另一个点是内存碎片,在前面也提到过,Python对矢量计算的核心问题,Python并不支持矢量操作。这主要是由两个原因导致的,Python列表存储的是指向实际数据的指针,而实际的数据则不是在内存中顺序存储的;且Python字节码本身也并没有对矢量操作进行优化。上面所讲的原因在真是的使用中对矢量操作的影响是相当大的,首先一个简单的读取元素的工作就会被分解为先在列表中按照索引找到对应位置,但由于对应位置所存的是值的地址,所以还需要解引用来获得对应地址的值,另外在更大的粒度上,当我们试图讲数据分为块,我们只能对单独的小片分别传输,而不能一次性的传输整个块,而我们也就不能预计缓存中会出现怎样的情况了,糟糕!一个“冯诺依曼瓶颈”出现了。在试图找到解决方法之前,可以使用linux的perf工具了解CPU是怎样处理运行中的程序的,亲测是个好工具,但是安装的时候最好确认linux kernel版本一致。而书中由之分析得到的结果也不难理解,矢量计算在我们将相关数据都填入到CPU缓存的时候才会实现。但是由于总线只能移动连续的内存数据,所以只有数据在RAM中是连续存储时才有可能。Python中的array对象可以在内存中连续存储数据,但是Python的字节码问题仍然得不到解决,更何况实际上array类型创建列表实际上比list还要慢,所以我们需要一个好的工具,介绍numpy的时间到了。
numpy能将数据连续的存储在内存中,并支持数据的矢量操作。而在实际的代码中尽量使用numpy对应的函数(尽量是特化的函数,毕竟一般专用的代码要比通用的代码性能更好),可以带来性能上的提升。并且实际的代码中,我们也可以通过使用就地操作来避免内存分配带来的影响,毕竟内存分配是比缓存失效代价更高的。因为其不仅仅在缓存中找不到数据而需要到RAM中去寻找,并且内存分配还必须像操作系统请求一块可用的数据保留它。向操作系统进行请求所需的开销比简单的填充缓存大很多。毕竟填充一次缓存失效是一个在主板上优化过的硬件行为(前面提到的元组也是类似的机制使得再填充比较快),但是内存分配则需要跟另一个进程、内核打交道。不过比较难受的是,虽然我们可以通过使用numexpr工具来使得就地操作(尤其是连续的就地操作)更直观更方便,但是就地操作的代码可读性仍然比较差。
最后值得一提的是,在进行矢量操作的研究中,最好提前了解下硬件缓存相关的知识,会对理解数据局部性、研究perf结果和程序优化有很大的帮助。

解决动态语言的毛病! 编译成C

一开始就介绍过,由于动态语言没有编译器优化,所以在实际执行代码的过程中很难提升效率。书中给出了几种方法来讲我们的部分代码编译为机器码。

  • Cython 编译成C的最通用工具。 支持numpy和标准python 默认gcc
  • shed skin 用于非numpy代码的,自动将Python转换成C的转化器 使用了g++
  • Numba 专用于numpy代码的新编译器
  • Pythran 用于numpy和非numpy代码的新编译器
  • PyPy 可以取代常规Python(主要是指Cpython)的即时编译器,但是需要注意的是JIT相对与AOT有冷启动的问题,不要用JIT来处理短小且频繁运行的脚本

经过我的考察,在python3中可以获得比较好使用体验的应该就是Cython和PyPy,但是PyPy对numpy的支持比较差,之前做了一个numpypy的项目,用于转化基于Cpython的numpy库,做了80%的工作之后numpy项目discontinued,取而代之的是使用转阶层的numpy。但是效率嘛,你还要啥自行车啊!不过可惜的是,但从效率方面上看,PyPy对于Cython还是有优势的,作为JIT普遍效率是Cython的3倍,另外PyPy是永久支持python2的,所以对于不怎么使用numpy这样的外部库,或者需要stay in python2的朋友来说可以考虑下PyPy,毕竟纯Python环境下,它是最赞得了。不过暗自还是希望以后可以在pypy中安然的使用numpy,毕竟还是对JIT有信心的,万一JIT带来的速度提升真的可以抵消掉cpyext的存在呢?
其实到了这章开始私以为才是真的有科技含量的内容,不过也别高兴的太好,首先让我们心里有点b数,我们用过编译器最多可以带来怎样的提升呢?
首先调用外部库的代码,哈哈,编译之后是不会有速度提升的,其次我们我不能寄希望于I/O密集型的代码可以获得速度上的提升。也就是说,清醒一点编译后的代码不可能比正常编写的C代码更快(毕竟我们选择了python啊)。所以我们在优化代码的时候势必会得到一条工作曲线,首先我们剖析代码来理解程序的行为,随后开始有依据的修改算法以及使用编译器获得性能提升,最后我们终究会意识到工作量的增大指挥带来很小的回报,是时候收手啦!

Cython

其实其工作过程非常好理解,就是通过运行一个制导的setup.py将.pyc文件转化为由Cython编译的C代码,我们会得到c中间代码和静态库。
使用的时候可以通过Cython自带的注解选项在浏览器通过GUI查看可注解的代码块,然后通过增加注解(势必会失去一些通用性)以及对于计算列表移除boundscheck来优化程序。当然openmp ready也是很好的一点,指定with nogil,然后就可以发车了。

Shed Skin

感觉是个不温不火的项目,而且对python3支持有限,所以这里没有做深入的了解,私以为对于一般的python编译是非常傻瓜式的解决方案,只需要给出一个种子做示例,shed skin就可以自动实现编译,适合开发者容许用户修改代码,然后“舒服”的利用编译进行加速而不至于要软件使用者掌握这方面的知识。不过值得注意的是Shed Skin是在一个独立的内存空间中执行,所以会有额外的内存拷贝的开销。

PyPy

pypy是我最喜欢的获得编译加速的方式,但是由于其对于numpy并不是原生支持,而是通过cpyext做连接(胶水?),所以对于numpy这样底层使用c的库不是很友好,但是PyPy是个很活跃的项目,随着PyPy6.0的到来,实际上可以看到在numpy的性能上已经有了很大的提高,更何况JIT实在强力,我很看好PyPy的发展。

并发

主要是为了解决I/O对程序执行流的影响,并发可以让我们在等待一个I/O操作完成的时候进行其他操作,可以让我们将这个时间利用起来。这次我们的目标是解决I/O等待。
深入来看,当一个程序进入I/O等待之后,会暂停执行,这样内核就能执行I/O请求相关的低级操作(也就是完成了一次上下文切换),直到I/O操作完成时才会继续。但是上下文切换是重量级的操作,需要消耗很大资源。具体来说,上下文切换需要我们先保存程序的状态(换言之,也会使得我们丢失了CPU层面上的任何类型的缓存),并退出CPU的使用。这样,我们之后被再次允许运行的时候,就必须花时间重新初始化程序并准备好继续运行。
那我们一般使用并发的思路是怎样的呢?典型情况,我们需要一个“事件循环”来管理程序中应该运行什么,什么时候运行。实质上,一个事件循环只是需要运行的一个函数列表(或者说队列?)。在列表顶端的函数得到执行,接着轮到下一个,and so on。但是这样的操作实际上也是有代价的,毕竟函数之间的切换也是有开销的,内核必须花费时间来设置在内存中被调用的函数,而且缓存的状态也无法预测。但是在程序有很多的I/O等待时,函数切换可以大大的将I/O等待的时间利用起来,相比于其开销,总体的性能会有较大的提升。
而前面所提到的时间循环编程主要也是有两种方式:回调或future。
主要可以使用到的异步库有下面这些:

  • gevent 主要逻辑遵照了让异步函数返回future的模式,代码中大部分逻辑会保持一致
  • tornado 使用回调方式实现异步行为
  • AsynclO

多进程

之前已经介绍过GIL和它给我们优化代码带来的危害。但是一个GIL的作用空间就是一个对应的python进程,当我们同时运行好几个python进程的时候并不会受到GIL的影响(当然我们就需要转而考虑消息传递的问题了)。
python的multiprocessing module可以喔让我们使用基于进程和基于线程的并行处理,在队列上共享任务,以及在进程上共享数据。其主要针对的是单机多核的问题,比较广泛的应用空间解决CPU密集型问题。其实前面也介绍过使用cython编译C代码来使用openmp框架,这里介绍的multiprocessing是工作在更高的层次上,在我们需要广泛的使用如numpy之类的python库做并行计算时的选择。

multiproceing module可以做一些典型的工作:

  • 用进程和pool对象来并行化一个CPU密集型任务
  • 用dummy module在线程池中并行化一个I/O密集型任务
  • 由队列共享捎带的工作
  • 在并行worker之间共享状态,可共享的数据类型由字节,原生数据类型,字典和列表

multiproceing module的主要组件有:

  • 进程,一个当前进程的派生拷贝,创建了一个新的进程标识符,并且任务在操作系统中以一个独立的子进程运行。开发者可以启动并查询进程的状态并给它提供一个运行的方法。但是也由于以上的特点,我们在使用的时候应当小心如生成随机数这样的操作(由于派生拷贝的问题,各个进程生成的随机数可能是完全一样的:-)
  • 池,pool,包装了进程和线程。在一个worker线程池中共享了一块工作并返回聚合的结果。
  • 队列
  • 管理者,一个单向或双向的在两个进程间的通信渠道
  • ctypes,允许在进程forked之后,在父子进程间共享原生数据类型
  • 同步原语

书中分别介绍了两个较有代表性的并行化例子。蒙特卡洛方法求pi和寻找素数。前者任务负载是均衡的,后者则在不同的计算区间是不平均的。第一个例子中,在实行分析时可以发现,超线程资源是一个很小的附加值;当然最大的问题是使用超线程的CPython使用了很多RAM,毕竟超线程不是缓存友好的,所以对每个芯片上剩余资源的利用率很低,所以一般情况下,我们可以将超线程视作一个附加值,而不是一个优化的目标,更多的情况下,考虑适当的通信压力,增加CPU才是王道。
在第二个寻找素数的例子中,作者总结了一些解决棘手的并行问题的策略:

  • 将工作分成独立的工作单元
  • 对于寻找素数这样的worker问题空间不平均的问题,可以采用随机化工作序列的方法
  • 对工作队列进行排列,尽量使平均时间最少,甚至是先使用串行做预检来避免并行部分的开销
  • 要是没有好的理由,最好老老实实使用默认的chunksize

集群和工作队列

实际上对于一般问题的处理,一台计算机上需要花的心思是远远少于一个集群的,所以首先要确定这个时间和精力花的值得。而python有比较成熟的三个集群化的解决方法,分别是parallel python、IPython parallel和NSQ。当然这三者入门的难易程度基本上也是这个顺序。

  • parallel python,接口易于上手,将multiprocessing的接口稍作修改就可以,但是功能不是很强劲
  • Ipython集群则是由于支持MPI,并且调试比较方便(毕竟是以交互的方式运行的)
  • NSQ 一个高性能的分布式消息平台 鲁棒性比较强 但是相应的实现难度也更高

结尾

其实这本书的内容总的来说还是十分丰富的,基本上我们想用python将程序运行的更快的方法都介绍了(也许少了点硬件配置,超频?linux优化?)。并且给读者一个很大的提醒,就是在解决高性能计算问题的时候,实战经验是非常重要的,书中的内容光看是吃不透的。另外,自以为为了达成程序调优的目的操作系统和计算机组成还是绕不开的两个必须次重要知识体系,所以有时间还是需要看看相关的材料。
最后,感谢nathanmarz的blog:You should blog even if you have no readers让我重新有了写blog的动力。

posted @ 2020-01-01 10:13  gabriel_sun  阅读(971)  评论(0编辑  收藏  举报