Python性能优化杂记

依山傍水房数间,行也安然,住也安然;

一头耕牛半顷田,收也凭天,荒也凭天;

布衣得暖胜丝棉,长也可穿,短也可穿;

粗茶淡饭饱三餐,早也香甜,晚也香甜;

夜归儿女话灯前,今也谈谈,古也谈谈;

日上三竿犹在眠,人生苦短,我用Python。

Python近些年随着机器学习和数据挖掘大热,在金融量化策略开发上也已成主流。近期对早先采用纯Python开发的金融量化模拟引擎进行了一些性能优化方面的工作,同时参阅了一些书籍和文档,对Python性能相关的一些琐碎的点进行一个整理和记录。这里指的Python默认是指标准的CPython实现。

Python数值计算

由于Python内一切都是对象,包括各种类型的number在内(可以尝试执行一下dir(1)),所以Python里进行数值计算实际上是调用了对象相应的protocol method(也称为magic method),例如加法是__add__、乘法是__mul__、除法是__div__等等。Python通过这种方法实现了Duck Typing,使得语言动态且优雅。比如list通过实现__mul__函数能够与证书进行乘法运算,执行[]* 100即得到一个元素全为长度为100的列表。但这么做也带来一些性能上的问题,它使得即便是简单的运算也需要进行函数调用、参数类型判断等许多操作,从而导致即便最简单的1 + 1的运算都变得很慢。当然计算表达式数量不多的时候问题不大,但在金融量化这类涉及到大量数值计算的时候,用Python实现时在这上面的耗时就非常可观了。一般情况下,如果能够将数据表示成向量或是矩阵的形式进行计算,那么用numpy是不二选择。

使用numpy之所以快,有两个原因。一方面是因为numpy里面数据在内存中是连续存放的,能够充分利用CPU的cache。另一方面,由于很多CPU都支持SIMD(Single Instruction Multiple Data)指令,numpy(依赖的线性代数库)能够将例如向量和矩阵运算通过SIMD方式进行一定程度上的并行计算,从而提高计算速度。反之,假如我们使用list存储数据,然后通过循环进行处理,除去前面提到的CPU需要执行太多的额外指令以外,尽管list本身slot的存储在内存中是连续分布的,但它存储的只是对象引用,而list真正包含的元素对象往往是分散在内存中的,导致在对list进行循环计算的过程中,CPU出现大量的cache miss,速度进一步变慢(从内存读取比cache慢近2个数量级)。

既然list不抵,那么用array如何呢? python的array中,数据是存储在array的连续内存中的,看起来cache miss的问题应该解决了。可惜的是,array不支持运算,我们进行计算的时候依旧要把array中的数通过索引取出来,这时候又需要转化为python对象进行计算,这样结果是比list还多了一步转为对象的操作,性能比list还差。所以,除了有利于节省内存外,array帮不上什么忙。下图是利用linux perf对array(上)和list(下)分别调用sum()求和的统计结果,可以看到array的cache miss确实少了一个数量级,但是instructions也就是执行的指令要比list的多出好多,所以性能反而还不如list。

如果计算过程没有办法通过numpy进行,则可以考虑将数值计算部分代码采用C实现,例如cython是使用相对方便且使用得非常广泛的一种,有很多常用的python库是使用cython开发的。如下图所示,在原来python代码上加一些类型标记然后使用cython编译即可显著改善性能,cython会把能够转化为C代码的语句进行编译转化,无法转化的则通过调用python解释器解释执行,显然如果我们能够让越多的代码能够编译,则性能就能进一步提高。

除了广泛使用的cython外,还有一些别的项目可以尝试,例如pythran、Shed Skin、numba等,它们提供了各种丰富的特性,例如对openMP、GPU的支持等等;又或者干脆不适用CPython,使用pypy等支持JIT的替代方案。

variable resolving和attribute referencing

在python中,dict被广泛用于变量查找和属性管理。这意味着当我们在表达式中引用变量或者从module/对象中引用属性时,都涉及到对dict的查找。虽然dict是O1的时间复杂度,每次查询的时间可以忽略不计,但当查找的次数足够多之后,其累积的时间消耗就比较可观了,例如在一个深层的多次循环之中。例如:

三段代码功能一样,只有细微差异:第一段代码是通过module名引用sin函数;第二个段代码是将sin函数import为全局变量;第三段代码是在第二段的基础上将sin赋值给局部变量_sin。从timeit的结果来看是性能是越来越好的。我们通过反编译工具dis来查看这三者的差异:

可见,第一段代码首先需要通过LOAD_GLOBAL找到module,然后再执行LOAD_ATTR来找到sin,比直接从全局变量中获取sin要多了一个LOAD_ATTR的操作(也是dict查找),因此性能不及第二个的写法,而最后一个则是通过LOAD_FAST从局部变量中,速度稍快于LOAD_GLOBAL。

一般而言,从性能和可读性综合来讲,会推荐采用第二种写法,第三种写法繁冗且没有必要,仅对我们理解Python的工作方式有一定的帮助。

尽量使用generator

很多人曾都经历过Python列表解析之惑,掌握之后发现列表解析是个利器,不但代码更短更直观,性能也比循环append好。但大部分情况,我们还有更好的选择——使用generator。当我们只是为了构造一个集合用于之后遍历使用,完全可以用generator替代,当然如果确实是要生成一个list,那列表解析依旧是首选。同时由于大多数的python内置和第三方的lib在接受集合作为参数时都支持任何Iterable作为输入,因此generator在性能和节约内存上都是更优的选择。事实上,generator是基于yield,构造了一个lazy evaluation的routine,当每次对其调用next()的时候,再逐个产出序列元素。比如大部分时候,我们应该使用xrange代替range,因为xrange返回的是一个生成器,而range则会再内存中生成一个list,除了性能外还导致额外的内存消耗,尤其是当参数值非常大的时候,会导致很大的内存分配,在python3里面range已经替换成了xrange。除此之外,像遍历dict之类的也应该优先选择iterkeys(), itervalues(),iteritems()等返回generator的接口。

tuple VS list

对于可以用tuple代替list的场景,优先使用tuple。这是由于python解释器对tuple进行了特别的优化,例如对小于20个元素的tuple使用的内存进行cache,从而不必每次创建时进行内存分配,因此速度上会更快。

即便是对list进行修改,其性能也不及重新创建新的tuple:

多线程vs多进程

尽管python多线程虽然每个线程也是对应到操作系统的线程(linux上),但是由于GIL(全局解释器锁)的存在,实时上同一刻只能有一个线程在运行,程序在线程之间不断切换运行。因此对于CPU密集型的程序而言,多线程几乎起不到什么改善效果甚至线程切换的代价反而引起性能的下降。python若要想充分利用多核/多CPU改进性能的话,需要采用多进程而不是多线程方式,例如使用常见的multiprocessing库。

concurrency(并发):是一个时间单位内,有多个事件发生,python的多线程是并发执行的;

parallelism(并行):是有多个事件同时在发生,python的多进程相是并行执行的;

numpy和pandas相关

对于一个numpy.array而言,a += b 与 a = a + b不同,前者是直接在a上修改,后者会创建一个新的对象并赋值给a,前者性能要好于后者;

在pandas.DataFrame中进行循环遍历操作的性能非常差,可以批量操作的都使用批量操作,必须要通过循环遍历处理的,可以考虑对df.values返回的numpy.ndarray进行遍历处理;

Pandas.eval和DataFrame.eval,前者用于对DataFrame进行操作,后者是DataFrame对其内部进行操作;

之前看到多个进程避免复制共同操作同一个numpy数组的骚操作,做法是将数据load到进程共享的mmap中,每个进程中使用numpy.frombuffer创建数组,有点浪...;

Profiling Tools

要进行性能优化,首先需要定位到程序中的性能问题,指导我们挑大户下手,往往是一边对程序进行profile一边进行优化。这里罗列一下常见的Python profiler:

profiler/cProfiler:前者用纯Python实现;后者用C实现,性能更佳但兼容性上可能不如Python版本。二者功能一样,可以互相替代。它们能够统计每个函数的消耗时间和调用关系,用于指导我们找到合适目标优化点。采用一些额外的工具还能转化成下图这样的调用关系图,不同颜色表示不同的耗时占比,非常方便进行追溯和定位性能问题。

line_profiler:这个厉害了,可以对程序的每一行进行耗时统计,适合在定位到具体函数后,对其内部进行优化时使用。

memory_profiler:用于定位内存使用的情况,对于内存使用优化而言是非常好的工具。

linux perf:linux下的性能分析利器,能够得到程序运行时CPU执行指令情况等统计信息,对于运行程序性能调优是不可多得的利器。当然,它不限于Python程序使用。

  • 发表于:
  • 原文链接https://kuaibao.qq.com/s/20181008G1CW3C00?refer=cp_1026
  • 腾讯「云+社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 yunjia_community@tencent.com 删除。

扫码关注云+社区

领取腾讯云代金券