性能分析¶
注意
此页面使用两种不同的语法变体
Cython 特定的
cdef
语法,旨在使类型声明简洁,并易于从 C/C++ 的角度阅读。纯 Python 语法,允许在 纯 Python 代码 中进行静态 Cython 类型声明,遵循 PEP-484 类型提示和 PEP 526 变量注释。
要在 Python 语法中使用 C 数据类型,您需要在要编译的 Python 模块中导入特殊的
cython
模块,例如:import cython
如果您使用纯 Python 语法,我们强烈建议您使用最新的 Cython 3 版本,因为与 0.29.x 版本相比,这里已经进行了重大改进。
本部分介绍 Cython 的性能分析功能。如果您熟悉纯 Python 代码的性能分析,您只需阅读第一部分(Cython 性能分析基础)。如果您不熟悉 Python 性能分析,您还应该阅读教程(性能分析教程),它将逐步带您完成一个完整的示例。
请注意,性能分析和跟踪在 CPython 3.12 中不可用。对 PEP-669 的更改已在 CPython 中实现,因此以前对注入跟踪事件的支持不再有效,但没有提供替代方案。
想要(Python-)分析其 Cython 代码或分析其测试覆盖率的 Python 3.12 用户必须使用 Python 3.11。
Cython 性能分析基础¶
Cython 中的性能分析由编译器指令控制。它可以为整个文件设置,也可以通过 Cython 装饰器在每个函数的基础上设置。
为整个源文件启用性能分析¶
通过在文件顶部向 Cython 编译器添加全局指令,可以为整个源文件启用性能分析
# cython: profile=True
请注意,性能分析会给每个函数调用带来轻微的开销,因此会使您的程序稍微变慢(或者如果经常调用一些小函数,则会变慢很多)。
启用后,您的 Cython 代码在从 cProfile 模块调用时将表现得与 Python 代码一样。这意味着您可以使用与单独使用 Python 代码相同的工具,将 Cython 代码与 Python 代码一起进行性能分析。
按函数禁用性能分析¶
如果您的性能分析由于对某些小函数的调用开销而变得混乱,而您不想在性能分析中看到这些函数 - 无论是因为您计划将它们内联,还是因为您确信无法使它们更快 - 您可以使用特殊的装饰器来仅为一个函数禁用性能分析(无论它是否全局启用)。
import cython
@cython.profile(False)
def my_often_called_function():
pass
cimport cython
@cython.profile(False)
def my_often_called_function():
pass
启用行跟踪¶
要获取更详细的跟踪信息(对于可以使用它的工具),您可以启用行跟踪
# cython: linetrace=True
这也会启用性能分析支持,因此上面的 profile=True
选项不再需要。例如,覆盖率分析需要行跟踪。
请注意,即使通过编译器指令启用了行跟踪,它默认情况下也不会使用。由于运行时速度变慢可能很大,因此必须通过设置 C 宏定义 CYTHON_TRACE=1
在 C 编译器中额外编译它。要将 nogil 函数包含在跟踪中,请设置 CYTHON_TRACE_NOGIL=1
(这意味着 CYTHON_TRACE=1
)。C 宏可以在 setup.py
脚本的扩展定义中定义,也可以通过在源文件中使用以下文件头注释设置相应的 distutils 选项(如果使用 cythonize()
进行编译)。
# distutils: define_macros=CYTHON_TRACE_NOGIL=1
启用覆盖率分析¶
从 Cython 0.23 版本开始,行跟踪(见上文)也支持使用 coverage.py 工具进行覆盖率报告。为了让覆盖率分析理解 Cython 模块,您还需要在您的 .coveragerc
文件中启用 Cython 的覆盖率插件,如下所示
[run]
plugins = Cython.Coverage
使用此插件,您的 Cython 源文件应该在覆盖率报告中正常显示。
要将覆盖率报告包含在 Cython 注释的 HTML 文件中,您需要先运行 coverage.py 工具以生成 XML 结果文件。将此文件传递到 cython
命令中,如下所示
$ cython --annotate-coverage coverage.xml package/mymodule.pyx
这将重新编译 Cython 模块,并在它处理的每个 Cython 源文件旁边生成一个 HTML 输出文件,其中包含覆盖率报告中包含行的颜色标记。
性能分析教程¶
这将是一个完整的教程,从头到尾,涵盖了对 Python 代码进行性能分析,将其转换为 Cython 代码,并持续进行性能分析,直到其速度足够快。
作为一个玩具示例,我们希望评估到某个整数 的平方倒数的总和,用于评估 。我们想要使用的关系由欧拉在 1735 年证明,被称为 巴塞尔问题。
评估截断和的简单 Python 代码如下所示
def recip_square(i):
return 1. / i ** 2
def approx_pi(n=10000000):
val = 0.
for k in range(1, n + 1):
val += recip_square(k)
return (6 * val) ** .5
在我的机器上,这需要大约 4 秒才能使用默认的 n 运行该函数。我们选择的 n 越大,对 的近似值就越好。经验丰富的 Python 程序员已经看到了很多优化此代码的地方。但请记住优化的黄金法则:在进行性能分析之前,永远不要优化。让我再说一遍:**永远**不要在对代码进行性能分析之前进行优化。你对代码中哪些部分耗时过长的想法是错误的。至少,我的想法总是错误的。所以让我们编写一个简短的脚本来对我们的代码进行性能分析
import pstats, cProfile
import calc_pi
cProfile.runctx("calc_pi.approx_pi()", globals(), locals(), "Profile.prof")
s = pstats.Stats("Profile.prof")
s.strip_dirs().sort_stats("time").print_stats()
在我的机器上运行它会产生以下输出
Sat Nov 7 17:40:54 2009 Profile.prof
10000004 function calls in 6.211 CPU seconds
Ordered by: internal time
ncalls tottime percall cumtime percall filename:lineno(function)
1 3.243 3.243 6.211 6.211 calc_pi.py:4(approx_pi)
10000000 2.526 0.000 2.526 0.000 calc_pi.py:1(recip_square)
1 0.442 0.442 0.442 0.442 {range}
1 0.000 0.000 6.211 6.211 <string>:1(<module>)
1 0.000 0.000 0.000 0.000 {method 'disable' of '_lsprof.Profiler' objects}
这包含了代码在 6.2 个 CPU 秒内运行的信息。请注意,代码变慢了 2 秒,因为它是在 cProfile 模块中运行的。该表包含了真正有价值的信息。您可能想查看 Python 的 性能分析文档,了解更详细的信息。这里最重要的列是 totime(在此函数中花费的总时间,**不包括**由该函数调用的函数)和 cumtime(在此函数中花费的总时间,**包括**由该函数调用的函数)。查看 tottime 列,我们看到大约一半的时间花在了 approx_pi()
上,另一半花在了 recip_square()
上。还有半秒花在了 range 上……当然,对于如此大的迭代,我们应该使用 xrange。事实上,仅仅将 range 更改为 xrange 就可以使代码在 5.8 秒内运行。
我们可以在纯 Python 版本中进行很多优化,但由于我们对 Cython 感兴趣,让我们继续前进,将此模块移植到 Cython。我们无论如何都会在某个时候这样做,以使循环运行得更快。这是我们的第一个 Cython 版本
# cython: profile=True
import cython
def recip_square(i: cython.longlong):
return 1. / i ** 2
def approx_pi(n: cython.int = 10000000):
val: cython.double = 0.
k: cython.int
for k in range(1, n + 1):
val += recip_square(k)
return (6 * val) ** .5
# cython: profile=True
def recip_square(int i):
return 1. / i ** 2
def approx_pi(int n=10000000):
cdef double val = 0.
cdef int k
for k in range(1, n + 1):
val += recip_square(k)
return (6 * val) ** .5
注意第一行:我们必须告诉 Cython 启用性能分析。这会使 Cython 代码稍微慢一些,但如果没有它,我们就无法从 cProfile 模块获得有意义的输出。代码的其余部分基本保持不变,我只键入了一些变量,这可能会稍微提高速度。
我们还需要修改性能分析脚本以直接导入 Cython 模块。以下是添加了 Pyximport 模块导入的完整版本
import pstats, cProfile
import pyximport
pyximport.install()
import calc_pi
cProfile.runctx("calc_pi.approx_pi()", globals(), locals(), "Profile.prof")
s = pstats.Stats("Profile.prof")
s.strip_dirs().sort_stats("time").print_stats()
我们只添加了两行,其余部分完全保持不变。或者,我们也可以手动将代码编译成扩展;这样我们就不需要更改性能分析脚本了。脚本现在输出以下内容
Sat Nov 7 18:02:33 2009 Profile.prof
10000004 function calls in 4.406 CPU seconds
Ordered by: internal time
ncalls tottime percall cumtime percall filename:lineno(function)
1 3.305 3.305 4.406 4.406 calc_pi.py:6(approx_pi)
10000000 1.101 0.000 1.101 0.000 calc_pi.py:3(recip_square)
1 0.000 0.000 4.406 4.406 {calc_pi.approx_pi}
1 0.000 0.000 4.406 4.406 <string>:1(<module>)
1 0.000 0.000 0.000 0.000 {method 'disable' of '_lsprof.Profiler' objects}
Sat Nov 7 18:02:33 2009 Profile.prof
10000004 function calls in 4.406 CPU seconds
Ordered by: internal time
ncalls tottime percall cumtime percall filename:lineno(function)
1 3.305 3.305 4.406 4.406 calc_pi.pyx:6(approx_pi)
10000000 1.101 0.000 1.101 0.000 calc_pi.pyx:3(recip_square)
1 0.000 0.000 4.406 4.406 {calc_pi.approx_pi}
1 0.000 0.000 4.406 4.406 <string>:1(<module>)
1 0.000 0.000 0.000 0.000 {method 'disable' of '_lsprof.Profiler' objects}
我们获得了 1.8 秒的提升。还不错。将输出与之前的结果进行比较,我们发现 recip_square()
函数变快了,而 approx_pi()
函数变化不大。让我们更关注 recip_square()
函数。首先,请注意,此函数不能从模块外部的代码中调用;因此,明智的做法是将其转换为 cdef 以减少调用开销。我们还应该去掉幂运算符:它被 Cython 转换为 pow(i, 2)
函数调用,但我们可以改为编写 i * i
,这可能会更快。整个函数也是内联的良好候选者。让我们看看这些想法所需的更改
# cython: profile=True
import cython
@cython.cfunc
@cython.inline
@cython.exceptval(-1.0)
def recip_square(i: cython.longlong) -> cython.double:
return 1. / (i * i)
def approx_pi(n: cython.int = 10000000):
val: cython.double = 0.
k: cython.int
for k in range(1, n + 1):
val += recip_square(k)
return (6 * val) ** .5
# cython: profile=True
cdef inline double recip_square(long long i) except -1.0:
return 1. / (i * i)
def approx_pi(int n=10000000):
cdef double val = 0.
cdef int k
for k in range(1, n + 1):
val += recip_square(k)
return (6 * val) ** .5
请注意,为了传播除以零错误,需要在 recip_square()
的签名中声明 except
/@exceptval
。
现在运行性能分析脚本会产生以下结果
Sat Nov 7 18:10:11 2009 Profile.prof
10000004 function calls in 2.622 CPU seconds
Ordered by: internal time
ncalls tottime percall cumtime percall filename:lineno(function)
1 1.782 1.782 2.622 2.622 calc_pi.py:9(approx_pi)
10000000 0.840 0.000 0.840 0.000 calc_pi.py:6(recip_square)
1 0.000 0.000 2.622 2.622 {calc_pi.approx_pi}
1 0.000 0.000 2.622 2.622 <string>:1(<module>)
1 0.000 0.000 0.000 0.000 {method 'disable' of '_lsprof.Profiler' objects}
Sat Nov 7 18:10:11 2009 Profile.prof
10000004 function calls in 2.622 CPU seconds
Ordered by: internal time
ncalls tottime percall cumtime percall filename:lineno(function)
1 1.782 1.782 2.622 2.622 calc_pi.pyx:9(approx_pi)
10000000 0.840 0.000 0.840 0.000 calc_pi.pyx:6(recip_square)
1 0.000 0.000 2.622 2.622 {calc_pi.approx_pi}
1 0.000 0.000 2.622 2.622 <string>:1(<module>)
1 0.000 0.000 0.000 0.000 {method 'disable' of '_lsprof.Profiler' objects}
这又给我们带来了 1.8 秒的提升。这并不是我们预期的戏剧性变化。为什么 recip_square()
仍然出现在此表中;它应该被内联,不是吗?原因是即使函数调用被消除,Cython 仍然会生成性能分析代码。让我们告诉它不要再分析 recip_square()
;无论如何我们都无法让该函数变得更快
# cython: profile=True
import cython
@cython.profile(False)
@cython.cfunc
@cython.inline
@cython.exceptval(-1.0)
def recip_square(i: cython.longlong) -> float:
return 1. / (i * i)
def approx_pi(n: cython.int = 10000000):
val: cython.double = 0.
k: cython.int
for k in range(1, n + 1):
val += recip_square(k)
return (6 * val) ** .5
# cython: profile=True
cimport cython
@cython.profile(False)
cdef inline double recip_square(long long i) except -1.0:
return 1. / (i * i)
def approx_pi(int n=10000000):
cdef double val = 0.
cdef int k
for k in range(1, n + 1):
val += recip_square(k)
return (6 * val) ** .5
运行此代码显示了一个有趣的结果
Sat Nov 7 18:15:02 2009 Profile.prof
4 function calls in 0.089 CPU seconds
Ordered by: internal time
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.089 0.089 0.089 0.089 calc_pi.py:12(approx_pi)
1 0.000 0.000 0.089 0.089 {calc_pi.approx_pi}
1 0.000 0.000 0.089 0.089 <string>:1(<module>)
1 0.000 0.000 0.000 0.000 {method 'disable' of '_lsprof.Profiler' objects}
Sat Nov 7 18:15:02 2009 Profile.prof
4 function calls in 0.089 CPU seconds
Ordered by: internal time
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.089 0.089 0.089 0.089 calc_pi.pyx:12(approx_pi)
1 0.000 0.000 0.089 0.089 {calc_pi.approx_pi}
1 0.000 0.000 0.089 0.089 <string>:1(<module>)
1 0.000 0.000 0.000 0.000 {method 'disable' of '_lsprof.Profiler' objects}
首先要注意巨大的速度提升:此版本仅花费我们第一个 Cython 版本时间的 1/50。还要注意 recip_square()
已从表格中消失,正如我们所期望的那样。但最奇怪和最重要的变化是 approx_pi()
也变得快得多。这是所有性能分析中的一个问题:在性能分析运行中调用函数会给函数调用增加一定的开销。此开销**不会**添加到调用函数中花费的时间,而是添加到**调用**函数中花费的时间。在本例中,approx_pi()
在上次运行中不需要 2.622 秒;但它调用了 recip_square()
10000000 次,每次都花费一些时间来为其设置性能分析。这加起来大约 2.6 秒的巨大时间损失。现在为经常调用的函数禁用性能分析,可以揭示 approx_pi()
的真实计时;如果需要,我们可以继续优化它。
本性能分析教程到此结束。此代码仍然有一些改进的空间。我们可以尝试用 C 标准库中的 sqrt 调用来替换 approx_pi()
中的幂运算符;但这不一定比调用 pow(x, 0.5)
快。
即便如此,我们在这里取得的结果还是相当令人满意的:我们找到了一个比我们最初的 Python 版本快得多的解决方案,同时保留了功能和可读性。