性能分析

注意

此页面使用两种不同的语法变体

  • 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

启用行跟踪

要获取更详细的跟踪信息(对于可以使用它的工具),您可以启用行跟踪

# 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 代码,并持续进行性能分析,直到其速度足够快。

作为一个玩具示例,我们希望评估到某个整数 n 的平方倒数的总和,用于评估 \pi。我们想要使用的关系由欧拉在 1735 年证明,被称为 巴塞尔问题

\pi^2 = 6 \sum_{k=1}^{\infty} \frac{1}{k^2} =
6 \lim_{k \to \infty} \big( \frac{1}{1^2} +
      \frac{1}{2^2} + \dots + \frac{1}{k^2}  \big) \approx
6 \big( \frac{1}{1^2} + \frac{1}{2^2} + \dots + \frac{1}{n^2}  \big)

评估截断和的简单 Python 代码如下所示

calc_pi.py
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 越大,对 \pi 的近似值就越好。经验丰富的 Python 程序员已经看到了很多优化此代码的地方。但请记住优化的黄金法则:在进行性能分析之前,永远不要优化。让我再说一遍:**永远**不要在对代码进行性能分析之前进行优化。你对代码中哪些部分耗时过长的想法是错误的。至少,我的想法总是错误的。所以让我们编写一个简短的脚本来对我们的代码进行性能分析

profile.py
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 版本

calc_pi.py
# 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 启用性能分析。这会使 Cython 代码稍微慢一些,但如果没有它,我们就无法从 cProfile 模块获得有意义的输出。代码的其余部分基本保持不变,我只键入了一些变量,这可能会稍微提高速度。

我们还需要修改性能分析脚本以直接导入 Cython 模块。以下是添加了 Pyximport 模块导入的完整版本

profile.py
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}

我们获得了 1.8 秒的提升。还不错。将输出与之前的结果进行比较,我们发现 recip_square() 函数变快了,而 approx_pi() 函数变化不大。让我们更关注 recip_square() 函数。首先,请注意,此函数不能从模块外部的代码中调用;因此,明智的做法是将其转换为 cdef 以减少调用开销。我们还应该去掉幂运算符:它被 Cython 转换为 pow(i, 2) 函数调用,但我们可以改为编写 i * i,这可能会更快。整个函数也是内联的良好候选者。让我们看看这些想法所需的更改

calc_pi.py
# 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

请注意,为了传播除以零错误,需要在 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}

这又给我们带来了 1.8 秒的提升。这并不是我们预期的戏剧性变化。为什么 recip_square() 仍然出现在此表中;它应该被内联,不是吗?原因是即使函数调用被消除,Cython 仍然会生成性能分析代码。让我们告诉它不要再分析 recip_square();无论如何我们都无法让该函数变得更快

calc_pi.py
# 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

运行此代码显示了一个有趣的结果

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}

首先要注意巨大的速度提升:此版本仅花费我们第一个 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 版本快得多的解决方案,同时保留了功能和可读性。