Python性能优化-Cython-2

  |  

摘要: 《Python性能分析与优化》笔记,Cython 层面如何做 Python 的性能优化。第二部分

【对算法,数学,计算机感兴趣的同学,欢迎关注我哈,阅读更多原创文章】
我的网站:潮汐朝夕的生活实验室
我的公众号:算法题刷刷
我的知乎:潮汐朝夕
我的github:FennelDumplings
我的leetcode:FennelDumplings


写在前面

这是 Python 性能分析与优化系列的第 11 篇文章,主要参考 Python性能分析与优化 这本书。

往期回顾

第一期: Python性能分析基础
第二期: Python性能分析器 — cProfile
第三期: 使用cProfile进行性能分析与优化实践
第四期: Python性能分析器 — line_profiler
第五期: line_profiler性能分析实践 — 优化倒排索引
第六期: Python性能分析-可视化
第七期: Python性能优化-优化每一个细节
第八期: Python性能优化-多进程与多线程
第九期: Python性能优化-PyPy
第十期: Python性能优化-Cython-1

在前面若干期中,我们都是在优化 Python 众多实现中的一种(CPython)。Python 还有其他实现方式,在这一期我们将学习其中的两种。

PyPy: 它是Python解释器的另一个版本。

Cython: 它是一种优化过的静态编译器,可以让我们写静态代码,并轻松借助C/C++的力量。

本文主要是围绕 Cython 的第二部分(第一部分为上面的第十期)。Cython 的内容是很庞大的,详细内容可以参考 Cython User Guide。这里只关注一些皮毛,看看是怎么用的。


4. 定义类型

Cython 可以自定义变量类型或返回值类型。均用 cdef 即可(在需要的地方定义静态类型有助于优化代码)。

例子: 在 Cython 中增加变量类型定义

对比以下三种方式的执行效率

  • 纯Python
  • 没有类型的编译过的Cython
  • 有类型且编译过的Cython

Python 代码

1
2
3
4
5
def is_prime(num):
for j in range(2, num):
if (num % j) == 0:
return False
return True

没有类型的 Cython

1
2
3
4
5
6
7
# cython: language_level=3

def is_prime(num):
for j in range(2, num):
if (num % j) == 0:
return False
return True

加了类型的 Cython

1
2
3
4
5
6
7
8
# cython: language_level=3

def is_prime(int num):
cdef int j;
for j in range(2, num):
if (num % j) == 0:
return False
return True

测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import time

from my_py import is_prime as is_prime_py
from my_c import is_prime as is_prime_c
from my_c_with_type import is_prime as is_prime_c_with_type

N = int(5e4)

def check(method):
output = []
for x in range(2, N):
if method(x):
output.append(x)
return output

def main():
start = time.process_time()
output_py = check(is_prime_py)
print("py: {:.6f}".format(time.process_time() - start))
start = time.process_time()
output_c = check(is_prime_c)
print("c: {:.6f}".format(time.process_time() - start))
start = time.process_time()
output_c = check(is_prime_c_with_type)
print("c_with_type: {:.6f}".format(time.process_time() - start))

if __name__ == "__main__":
main()

结果如下:

1
2
3
py: 8.816629
c: 5.818364
c_with_type: 0.570429

尽管非优化版的Cython代码也比纯Python要快,但当我们开始声明类型之后才会看到Cython的真正威力。


5. 定义函数类型

Cython中有两种不同类型的函数可以定义。

  1. 标准Python函数:这种普通函数与纯Python代码中声明的函数完全一样。要定义这种函数,你只需要用标准的def关键字就行。这种函数接受Python对象作为参数,也返回Python对象。
  2. C函数:这种函数是是标准函数的优化版。它们可以用Python对象和C语言类型作为参数,返回值也可以是两种类型。要定义这种函数,你需要用特殊关键字cdef。

这两种函数都可以在 Cython 模块中调用。但若想在 Python 中调用有两种方式

  • 函数是标准 Python 函数。
  • cpdef 关键字,该关键字会创建一个函数的封装对象。当用 Cython 调用时,它用 C 语言对象,当从 Python 调用时,它用 Python 函数。

为函数参数定义C语言类型时,一个自动的转换会将Python对象转换成C语言类型。
目前可以使用的类型只有数值类型、字符串string和结构体struct类型

如果返回值的类型或参数类型未定义,将它被看成Python对象。

不返回Python对象的C语言函数,在调用时不能抛出Python异常。可以在函数定义中使用except关键字。这个关键字的含义是,任何时候当函数出现异常时,都会返回一个特定的值。

1
cdef int text(double param) except -1:

6. Cython 例子: 反复调用同一个函数

下面看一下在 Python性能优化-PyPy 中优化过的一个例子。

做同样的计算 500 万次,每次计算都要用数学库里的 PI, acos, cos, sin

原始 Python 代码

  • great_circle.py
1
2
3
4
5
6
7
8
9
10
11
12
import math

def great_circle(lon1, lat1, lon2, lat2):
radius = 3956 # miles
x = math.pi / 180.0

a = (90.0 - lat1) * x
b = (90.0 - lat2) * x
theta = (lon2 - lon1) * x
c = math.acos((math.cos(a) * math.cos(b)) +
(math.sin(a) * math.sin(b) * math.cos(theta)))
return radius * c

优化1

  • great_circle_cy_v1.pyx

只是把代码中的变量和参数都改为 C 语言类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# cython: language_level=3

import math

def great_circle(double lon1, double lat1, double lon2, double lat2):
cdef double a, b, theta, c, x, radius

radius = 3956 # miles
x = math.pi / 180.0

a = (90.0 - lat1) * x
b = (90.0 - lat2) * x
theta = (lon2 - lon1) * x
c = math.acos((math.cos(a) * math.cos(b)) +
(math.sin(a) * math.sin(b) * math.cos(theta)))
return radius * c

优化2

  • great_circle_cy_v2.pyx

把Python的数学库改成C语言的math.h文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# cython: language_level=3

cdef extern from "math.h":
float cosf(float theta)
float sinf(float theta)
float acosf(float theta)

def great_circle(double lon1,double lat1,double lon2,double lat2):
cdef double a, b, theta, c, x, radius
cdef double pi = 3.141592653589793

radius = 3956 # miles
x = pi / 180.0

a = (90.0 - lat1) * x
b = (90.0 - lat2) * x
theta = (lon2 - lon1) * x
c = acosf((cosf(a) * cosf(b)) +
(sinf(a) * sinf(b) * cosf(theta)))
return radius * c

测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import time

from great_circle_py import great_circle as great_circle_v0
from great_circle_cy_v1 import great_circle as great_circle_v1
from great_circle_cy_v2 import great_circle as great_circle_v2
from great_circle_cy_v3 import great_circle as great_circle_v3

lon1, lat1, lon2, lat2 = -72.345, 34.323, -61.823, 54.826
num = int(5e6)

start = time.process_time()
for i in range(num):
great_circle_v0(lon1, lat1, lon2, lat2)
print("py: {:.6f}".format(time.process_time() - start))
start = time.process_time()
for i in range(num):
great_circle_v1(lon1, lat1, lon2, lat2)
print("optim_v1: {:.6f}".format(time.process_time() - start))
start = time.process_time()
for i in range(num):
great_circle_v2(lon1, lat1, lon2, lat2)
print("optim_v2: {:.6f}".format(time.process_time() - start))
start = time.process_time()
for i in range(num):
great_circle_v3(lon1, lat1, lon2, lat2)
print("optim_v3: {:.6f}".format(time.process_time() - start))

运行结果

1
2
3
py: 9.558854
optim_v1: 6.450516
optim_v2: 1.660615

注: PyPy 运行时间为 0.3 秒。


7. 定义类型的时机选择

对于一些较大的Python项目,把每个变量都转变成C语言类型,把每个Python库都替换成C语言库文件,并非最佳方案。

这么做可能会影响代码的可维护性和可读性,还可能会损害Python代码的灵活性。甚至可能由于增加了大量不必要的静态类型检查和转换而损害了性能。

Cython具有注释源代码的能力,可以图形化地显示出每行代码是如何转变成C代码的。

cython -a属性生成一个HTML文件,里面会将代码高亮显示。黄色代码行越多,表示需要换成C代码的C-API接口越多。白色代码行(没有颜色的代码行)表示已经被直接转换为C代码。

回到上面的 great_circle 的例子,cython -a great_circle_py.py 产生的 HTML 文件如下

通过上图你会清晰地看到,为了转换成C语言代码,大部分代码都需要与一些C-API接口进行交互(只有第4行是全白的)。

我们的目标是要让代码行都尽可能变白。

下面对黄色部分进行优化

  1. 把变量都改成C语言类型,去掉原来的Python对象
  2. 去掉 math 库,改成 C 语言的 math.h 库

以上两步分别为 great_circle_cy_v1.pyx 和 great_circle_cy_v2.pyx 所做的优化。

结果如下

将 def 改为 cpdef double 可以将最后一行的黄色去掉。修改后我们不再返回一个Python对象,而是返回一个double对象。


Cython 与 PyPy: 如何选择正确的工具

优劣又以下需求决定

  1. 需要优化的实际情况
  2. 对 Python 和 C 的熟悉程度
  3. 被优化代码可读性的重要程度
  4. 完成优化需要花费的时间

什么时候用 Cython

  1. 熟悉 C 语言,理解 C 语言的常用原则,比如静态类型和 C 语言库
  2. 失去代码可读性不成问题
  3. 需要完全支持 Python 的特性,Cython 更多地作为 Python 的扩展而不是 Python 的子集。

什么时候用 PyPy

  1. 需要经常运行,或者长期运行,PyPy 循环运行可以不断优化代码。而如果只运行一次就不再用,则 PyPy 比 CPython 还慢。
  2. 需要代码与 Cpython 完全兼容。如果要求代码在两种环境下运行(Cpython 和 PyPy),则 Cython 无法满足要求,PyPy 是唯一选择。

Share