python性能优化全面指南

文章目录

    • python、c++与文言文、白话文
    • 鱼和熊掌兼而得之
      • 创建一门新的语言,这门语言能够写起来像python,跑起来像c++
        • Julia
        • Nim
      • 拼命提升高级语言Python的运行效率
        • 将python转化成c、c++代码进行优化
          • cython
          • nuitka
          • pythran
          • 11l
        • 使用JIT技术提高python效率
          • pypy
          • numba
      • 用c++编写python的扩展模块
      • pybind11
      • 其他常用的bind c++为python扩展模块的工具
    • 总结
      • pyfaster

python、c++与文言文、白话文

python语言简单易用,写起代码来就像用我们平常的话来描述流程图,平易近人的不得了,做个类比来说,python就像是现在的白话文,它很容易学会理解,而c++像是文言文。同样一件事情,用白话文洋洋洒洒一千字,还觉得意犹未尽,而用文言文,不到一百

字就已经完全表达了我们的意思。这让我想起来民国时候关于白话文和文言文的争论,有个小故事,那时候发电报按照字的数量来收费,一字一块大洋,一般人不遇到急事是不会破费的。有固守文言文的人以此为论据,说是用白话文,发电报,多么的浪费钱!推崇白话文反驳说,有的事情并不好用文言文来描述,比如,朋友要给你发一封急电,说“老婆不幸死了,请马上回来处理后事”,用文言文不见得能说得这么明白吧!另一人立即说,这简单,只需要发“妻死速归”。前后一对比,省了十几块大洋啊!当然,文言文的言简意赅,就像霍夫编码,熵很高,同样一段文本,可以蕴含更多的信息。用文言文和白话文的难易程度和信息量,可以类比python和c++的难易程度与执行效率。

语言 难易程度 效率
文言文
白话文
python
c++

鱼和熊掌兼而得之

正是因为如此,有些人就想着能不能各取所长,鱼和熊掌兼而得之呢!于是就有了以下几种思路。

  • 创建一门新的语言,结合python和c++各自的有点

  • 利用JIT技术对python进行性能优化

  • 用c、c++给Python编写第三饭模块

    我们将详细的对每一种技术做一个介绍。

创建一门新的语言,这门语言能够写起来像python,跑起来像c++

创建一门新的语言绝非易事,需要考虑到很多状况,世界上的编程语言不下百种,但是能够一般程序员有所耳闻的可能不到十种;但是已经有一些语言按照这种思路被开发了出来。

Julia

这是最值得推荐的一门新语言,被开发出来也就三四年吧,现在已经颇有名声。它的编程方式,结合了python、matlab的风格,比较简单易学;同时它可以声明变量的类型,拥有JIT技术,所谓的JIT就是just-in-time的缩写,是在运行时对程序进行编译,而c++等是AOT方式,即ahead-of-time,是在运行之前提前编译。这些特性使得Julia的执行效率堪比c/c++。这个语言被设计为主要用于科学计算领域,已经拥有很多像python pypi那样的第三方库。

# julia 代码片段
function dprod(l0, l1)
	return l0 * l1
end
l0 = transpose(Float64[i for i in 0:99999])
l1 = Float64[i for i in 0:99999]
@time dprod(l0, l1)

代码输出

0.000249 seconds
3.33283335e11

需要0.249 ms的时间。

上面这个代码片段,从语法上看,很像Python和Matlab的结合,需要用缩进,但是没有冒号,多了很多的end。如果是矩阵的加减乘除,也和MATLAB一样,多一个.是逐个元素的。Julia是可以调用Python的任何库的,也比较容易用c++写模块。

Nim

或许值得一试,它的语法也算比较自然,但是和python差距比Julia要大一些,具有十分强大的宏功能。

# Nim 代码片段
import strformat

type
  Person = object
    name: string
    age: Natural # Ensures the age is positive

let people = [
  Person(name: "John", age: 45),
  Person(name: "Kate", age: 30)
]

for person in people:
  # Type-safe string interpolation,
  # evaluated at compile time.
  echo(fmt"{person.name} is {person.age} years old")

拼命提升高级语言Python的运行效率

这种方式也已经有很多人去做,针对python而言,将python程序进行加速有很多方式。我们这里讨论的不是用c++给python写第三方模块,我将会在后续文章中详细介绍一下如何使用pybind11将c++程序转变为Python的第三方模块。

这里讨论的是,程序是用python写的,如何来加速这段程序的执行。总而言之,也有两种思路。

将python转化成c、c++代码进行优化

这就像是将“老婆死了,快回来处理后事”改为言简意赅的“妻死速归”,没有深厚的文言功底自然是做不了的。现在比较值得一试的类似软件有以下几个:

cython

cython已经非常成熟了,它允许你用python的语法来写c代码;同时,它也有自己多出来的一些语法,主要是用于变量的类型声明等。cython的原理是将你写的Python(或者说cython)代码,先翻译为c、c++语言,再编译为Python模块。对于任何的Python代码,你可以不经过任何改动使用cython生成模块,它的运行速度往往会超过原先的python代码,加速两倍左右;如果稍微改一下,注明变量类型等,并去除掉一些参数的合法检查,它的效率几乎逼近c代码。可以参考我之前写过的一篇介绍cython、pypy、numba的文章:python性能优化的比较:numba,pypy, cython。cython可以生成python模块,也可以嵌入python解释器,直接生成可执行程序。

pip install cython
## dprod.py

def dprod(l0, l1):
    n = len(l0)
    r = 0
    for i in range(n):
        r += l0[i] * l1[i]
    return r

运行如下命令生成模块

cython --cplus -p -3 -a dprod.py -o ./cython_ext/dprod.cpp

这个命令会生成两个文件

./cython_ext/dprod.cpp
./cython_ext/dprod.html

.cpp文件是翻译的c++文件,.html文件是一些提示,逐行告诉你python代码中每一句都被翻译成了什么c++代码,可以根据这个来进一步优化修改python代码。

生成python模块

g++ `python3-config --cflags --ldflags` -O3 --shared -fPIC -o ./cython_ext/dprod`python3-config --extension-suffix` ./cython_ext/dprod.cpp

这个将会生成可以导入的python模块

./cython_ext/dprod.cpython-37m-x86_64-linux-gnu.so

性能测试结果:

pure python version, average time: 6.188211954000053 ms
cython version, average time: 1.8879676910000853 ms

以上是对python程序未做任何的修改,编译出来的模块运行速度会有两三倍左右的提高,如何稍微用cython的语法来修改一下代码,速度会成倍的提高。

## dprod.pyx  cython程序的后缀是pyx

from cython cimport boundscheck, wraparound

@boundscheck(False)
@wraparound(False)
def dprod(double[:] l0, double[:] l1):
    cdef:
        long n, i
        double r
    n = l0.shape[0]
    r = 0
    for i in range(n):
        r += l0[i] * l1[i]
    return r

使用和上面相同的命令生成python模块,如果用timeit测试一下性能

from cython_ext.dprod import dprod as dprod_cython

x = list(range(100000))
y = list(range(100000))
xarr = np.asarray(x, dtype=int)
yarr = np.asarray(y, dtype=int)
num = 1000

duration = timeit.timeit("dprod_cython(xarr, yarr)",
                         globals=globals(), number=num)
print(f"cython version, average time: {duration/num * 1000} ms")

结果

cython version, average time: 0.13119223199964836 ms

第一个版本的大概需要1.8 ms,而第二个版本的只需要惊人的0.13ms,作为参考,numpy需要0.046 ms,纯python的版本需要6.2ms。也就是cython优化后的程序,效率可以是python程序的将近100倍,即使不做任何修改,性能也提高一倍。使用cython语法写的程序,性能几乎可以媲美numpy这种专业做矩阵运算的库。scipy大量的程序就使用了cython是不无道理的。

nuitka

这个也是将Python翻译为c、c++进行优化,生成python模块。

Nuitka is a Python compiler written in Python.

It’s fully compatible with Python2 (2.6, 2.7) and Python3 (3.3 … 3.8).

You feed it your Python app, it does a lot of clever things, and spits out an executable or extension module.

Free license (Apache).

Right now Nuitka is a good replacement for the Python interpreter and compiles every construct that all relevant CPython versions, and even irrelevant ones, like 2.6 and 3.3 offer. It translates the Python into a C program that then is linked against libpython to execute in the same way as CPython does, in a very compatible way.

It is somewhat faster than CPython already, but currently it doesn’t make all the optimizations possible, but a 312% factor on pystone is a good start (number is from version 0.6.0 and Python 2.7), and many times this is not generally achieved yet.

经过Nuitka优化过的程序可以加速312%倍,希望如此!nuitka和 cython一样,可以生成python模块,也可以直接生成可执行程序。使用nuitka的另一个功能就是将python程序打包成独立的二进制文件,方便在其他同类型的电脑系统上运行,这有点类似py2installer,不过比py2installer好的是,它对程序做出了一些优化,使程序运行得更快一些。

pip install nuitka
## dprod.py

def dprod(l0, l1):
    n = len(l0)
    r = 0
    for i in range(n):
        r += l0[i] * l1[i]
    return r

如果需要生成模块,则添加--module选项,如果要生成可执行文件,去掉该选项即可。

python3 -m nuitka --module --output-dir ./nuitka_ext dprod.py

这将生成以下python模块:

./nuitka_ext/dprod.so

测试结果

nuitka version, average time: 4.430712274000143 ms

nuitka 生成的模块进行性能测试,结果大概在4.43 ms左右,只比纯python的版本稍微好一点。

pythran

pythran 和上面的类似,将python翻译为c、c++然后编译成模块,供python调用。

Pythran is an ahead of time compiler for a subset of the Python language, with a focus on scientific computing. It takes a Python module annotated with a few interface description and turns it into a native Python module with the same interface, but (hopefully) faster.

It is meant to efficiently compile scientific programs, and takes advantage of multi-cores and SIMD instruction units.

pythran据称是比较好的处理了numpy的调用。pythran是只能生成python模块,而且需要对python的代码进行一定的注释,否则是不能直接生成模块的。

## dprod.py
## 注意函数上面的注释,没有它,pythran是不能工作的

# pythran export dprod(float64[:], float[:])
def dprod(l0, l1):
    n = len(l0)
    r = 0
    for i in range(n):
        r += l0[i] * l1[i]
    return r
pythran -w -o ./prthran_ext/dprod`python3-config --extension-suffix` dprod.py

这将会生成一个python模块:

./prthran_ext/dprod.cpython-37m-x86_64-linux-gnu.so

测试结果

pythran version, average time: 0.3394267869998657 ms

pythran生成模块的性能测试大概在0.34 ms左右,鉴于它对源码修改比cython要少,所以还是挺不错。

11l

这是一个有着奇怪名字的软件,它实际上是一个中间语言,它将python翻译为11l语言,然后再把11l语言翻译为c++。它最大的好处是翻译出来的c++程序,比其他几种都更适合人来阅读。cythan、pythran、nuitka等的翻译,是包含了大量的python wrapper代码的,读起来很困难。而11l没有,正因为如此,它只能将翻译后的结果编译为可执行文件,而不能是python的扩展模块。

我们对上面的dprod.py做一些typing,可以是翻译出来的程序更舒服一点,不然会有很多的auto关键字。

from typing import List

def dprod(l0: List[float], l1: List[float]) -> float:
    n: int = len(l0)
    r: float = 0
    for i in range(n):
        r += l0[i] * l1[i]
    return r

翻译后的c++文件:

#include "11l/_11l_to_cpp/11l.hpp"

double dprod(Array &l0, Array &l1)
{
    int n = l0.len();
    double r = 0;
    for (auto i : range_el(0, n))
        r += l0[i] * l1[i];
    return r;
}

int main()
{
}

头文件11l.hpp定义了一些常用的类型和函数,这里的Array继承了std::vector。这个翻译可以说很直观,如果我们需要用c++重写一遍已经用python实现的代码,那么借助这个工具,可以省事很多。

使用JIT技术提高python效率

除了上面讲的把python翻译为c++语言来提高效率,还有另外一种思路就是给python加上JIT技术。这个在julia里面已经提到了,使用JIT,即时的将反复执行的代码编译为机器码,来提高执行效率。对于python而言,有两个项目专注与这里,分别是pypy和numba。

pypy

pypy 是一个用python语言实现的python解释器,具有JIT的功能。它能够兼容绝大部分的python代码,但是对于numpy的支持,似乎总有点问题,而numpy是许多其他科学计算库的基础库。

  • Speed: thanks to its Just-in-Time compiler, Python programs often run faster on PyPy. (What is a JIT compiler?)
  • Memory usage: memory-hungry Python programs (several hundreds of MBs or more) might end up taking less space than they do in CPython.
  • Compatibility: PyPy is highly compatible with existing python code. It supports cffi, cppyy, and can run popular python libraries like twisted and django.
  • Stackless: PyPy comes by default with support for stackless mode, providing micro-threads for massive concurrency.

测试pypy我们不需要改变python代码。

## dprod.py

def dprod(l0, l1):
    n = len(l0)
    r = 0
    for i in range(n):
        r += l0[i] * l1[i]
    return r

if __name__ == "__main__":
    import timeit
    x = [float(i) for i in range(100000)]
    y = [float(i) for i in range(100000)]
    num = 1000

    duration = timeit.timeit("dprod(x, y)", globals=globals(), number=num)
    print(f"pypy version, average time: {duration/num * 1000} ms")
sudo apt-get install pypy3
pypy3 test/dprod.py

>>pypy version, average time: 0.24620251699980142 ms

大概需要 0.246 ms,这是一个很不错的结果,什么代码也没有改变,相比于python加速了30多倍。

numba

numba 可以对python程序中的一段代码进行JIT,往往是一个函数,只需要在函数上加一个装饰器就可以,它对numpy的支持十分友好。稍微修改一个我们的代码:

## dprod.py


def dprod(l0, l1):
    n = len(l0)
    r = 0
    for i in range(n):
        r += l0[i] * l1[i]
    return r

##############################################
# for numba test
from numba import njit

@njit
def dprod1(l0, l1):
    n = l0.shape[0]
    r = 0
    for i in range(n):
        r += l0[i] * l1[i]
    return r


if __name__ == "__main__":
    import timeit
    import numpy as np
    x = [float(i) for i in range(100000)]
    y = [float(i) for i in range(100000)]
    xarr = np.asarray(x)
    yarr = np.asarray(y)
    num = 1000
    
    dprod1(xarr, yarr)
    duration = timeit.timeit("dprod1(xarr, yarr)",
                             globals=globals(), number=num)
    print(f"numba version, average time: {duration/num * 1000} ms")

    duration = timeit.timeit("np.dot(xarr, yarr)",
                             globals=globals(), number=num)
    print(f"numpy version, average time: {duration/num * 1000} ms")
python3 test/dprod.py
numba version, average time: 0.1277403009999034 ms
numpy version, average time: 0.02811251999992237 ms

这段程序的测试结果达到了0.1277 ms,相比于python版本,速度提高了快百倍。而对代码的修改也并不多,十分值得推荐,但是主要用于数值计算,如果把上面的参数类型改为python的list,速度会严重下降。

用c++编写python的扩展模块

pybind11

优化python的效率,另外一个最常用的方法就是用c++来编写python的扩展模块。一般会针对代码中计算密集型、效率要求高的部分,用c++重写实现一遍,而python代码更多关注于逻辑。

将已有的c++代码编译为python模块,需要写一些wrap代码来实现两者之间的数据转换,现在有有一些工具可以十分方便的辅助我们实现这个目标。pybind11是众多工具中,相当简单、轻量、高效的一个。除了pybind11,上面提到的cython也可以,不过会稍微繁琐一点。此外针对c语言的代码,使用cffi来写扩展,也比较方便。

下面是一个pybind11的例子:

// test/dprod.cpp

#include 
#include 

namespace py = pybind11;
using namespace py::literals;

double dprod(const double *l0, const double *l1, unsigned n)
{
    double r = 0;
    for (unsigned i = 0; i < n; ++i)
    {
        r += l0[i] * l1[i];
    }
    return r;
}

PYBIND11_MODULE(dprod, m)
{
    m.def("dprod", [](py::array_t l0, py::array_t l1) {
        py::buffer_info l0buf = l0.request(), l1buf = l1.request();
        return dprod((double *)l0buf.ptr, (double *)l1buf.ptr, l0buf.shape[0]);
    });
}

将上面的代码编译为python模块:

g++ `python3-config --cflags --ldflags` -I`python3 -c "import pybind11;print(pybind11.get_include())"` -O3 --shared -fPIC -o test/pybind11_ext/dprod`python3-config --extension-suffix` test/dprod.cpp

这段代码的性能测试大概在0.12101731899997503 ms,有百倍的效率提高,而我们可以测试一下c++的原始执行速度:

#include 
#include 
#include 
#include 

double dprod(const double *l0, const double *l1, unsigned n)
{
    double r = 0;
    for (unsigned i = 0; i < n; ++i)
    {
        r += l0[i] * l1[i];
    }
    return r;
}

int main()
{
    std::vector l0(100000), l1(100000);
    std::iota(l0.begin(), l0.end(), 0);
    std::iota(l1.begin(), l1.end(), 0);
    auto t0 = std::chrono::high_resolution_clock::now();
    for (unsigned i = 0; i < 1000; ++i)
    {
        double r = dprod(l0.data(), l1.data(), l0.size());
    }
    auto t1 = std::chrono::high_resolution_clock::now();
    std::chrono::duration duration = t1 - t0;
    std::cout << "average time: " << duration.count()/ 1000 << " ms\n";
    return 0;
}

编译开启-O3选项,运行时间是 2.907e-06 ms,速度太快了,而将它wrap成python模块,运行时间是0.12 ms,这中间相差的时间,都被数据转换给浪费了。

其他常用的bind c++为python扩展模块的工具

除了pybind11,还有几个比较常用,这里仅仅列出:

  • swig,不仅仅python和c++

  • cffi,主要面向c语言写的代码

  • cppyy,可以在python里指定c++模板参数

  • boost.python,pybind11 脱胎于此

  • cppimport ,实际上是让pybind11更加方便一点

总结

我们不妨来看一下各种方法的结果:

numpy version, average time: 0.02811251999992237 ms
pybind11 version, average time: 0.12712017100056983 ms
numba version, average time: 0.1277403009999034 ms
cython version, average time: 0.12953058299990516 ms
pypy version, average time: 0.24620251699980142 ms
pythran version, average time: 0.33915015000002313 ms
nuitka version, average time: 4.488496339999983 ms
pure python version, average time: 6.111591079999926 ms

优化后的python在数值计算方面效率可以提高百倍。

pyfaster

以上的测试例子代码,可以在pyfaster找到,同时它可以很方便的让你生成python扩展模块,而不必手动去设置编译参数等头疼的事情。

你可能感兴趣的:(python,linux,python,c++,编程语言)