Skip to content

Latest commit

 

History

History
1319 lines (959 loc) · 56.3 KB

File metadata and controls

1319 lines (959 loc) · 56.3 KB

十二、性能——跟踪并减少内存和 CPU 使用

在我们谈论表演之前,有一个引用 Donald Knuth 的话,你首先要考虑:

“真正的问题是程序员花了太多时间在错误的地方和错误的时间担心效率;过早优化是编程中所有邪恶(或至少大部分)的根源。”

Donald Knuth 常被称为算法分析之父。他的丛书计算机编程艺术可以被认为是所有基本算法的圣经。

只要使用正确的算法选择正确的数据结构,就不必担心性能。这并不意味着您应该完全忽略性能,而只是确保您选择了正确的策略,并仅在实际需要时进行优化。微优化/过早优化肯定很有趣,但很少有用。

我们已经在第 2 章python 语法、常见陷阱和样式指南中看到了许多数据结构的性能特征,因此我们不再讨论这些,但我们将向您展示如何测量性能以及如何检测问题。在某些情况下,微优化会起到作用,但在衡量性能之前,您不会知道这一点。

在本章中,我们将介绍:

  • 分析 CPU 使用情况
  • 分析内存使用情况
  • 学习如何正确比较性能指标
  • 优化性能
  • 查找和修复内存泄漏

什么是性能?

绩效是一个非常宽泛的术语。它有许多不同的含义,在许多情况下定义不正确。您可能听过类似于“语言 X 比 Python 快”的说法。然而,这种说法本质上是错误的。Python 既不快也不慢;Python 是一种编程语言,它没有任何性能指标。如果说对于语言 X,CPython 解释器比解释器 Y 快或慢,那是可能的。代码的性能特征在不同的解释器之间可能会有很大的差异。看看这个小测试:

# python3 -m timeit '"".join(str(i) for i in range(10000))'
100 loops, best of 3: 2.91 msec per loop
# python2 -m timeit '"".join(str(i) for i in range(10000))'
100 loops, best of 3: 2.13 msec per loop
# pypy -m timeit '"".join(str(i) for i in range(10000))'
1000 loops, best of 3: 677 usec per loop

三个不同的口译员,他们的表现完全不同!所有这些都是 Python,但解释器显然有所不同。看看这个基准测试,您可能会完全放弃 CPython 解释器,而只使用 Pypy。像这样的基准的危险在于它们很少提供任何有意义的结果。对于这个有限的示例,Pypy 解释器的速度大约是 CPython3 解释器的四倍,但这与一般情况没有任何关系。这里可以安全得出的唯一结论是,对于这个精确的测试,这个特定版本的 Pypy 解释器比这个特定版本的 CPython3 快四倍多。对于任何其他测试和解释器版本,结果可能会大不相同。

Timeit–比较代码段性能

在我们开始改善性能之前,我们需要一种可靠的方法来测量它。Python 有一个非常好的模块(timeit),其具体目的是测量代码位的执行时间。它多次执行一段代码,以确保变化尽可能小,并使测量相当干净。如果您想比较一些代码片段,那么它非常有用。以下是执行示例:

# python3 -m timeit 'x=[]; [x.insert(0, i) for i in range(10000)]'
10 loops, best of 3: 30.2 msec per loop
# python3 -m timeit 'x=[]; [x.append(i) for i in range(10000)]'
1000 loops, best of 3: 1.01 msec per loop
# python3 -m timeit 'x=[i for i in range(10000)]'
1000 loops, best of 3: 381 usec per loop
# python3 -m timeit 'x=list(range(10000))'
10000 loops, best of 3: 212 usec per loop

这几个示例演示了list.insertlist.append、列表理解和list函数之间的性能差异。但更重要的是,它演示了如何使用timeit命令。当然,该命令也可以与常规脚本一起使用,timeit模块只接受作为字符串执行的语句,这有点麻烦。幸运的是,通过将代码包装到函数中并对该函数计时,您可以轻松解决此问题:

import timeit

def test_list():
    return list(range(10000))

def test_list_comprehension():
    return [i for i in range(10000)]

def test_append():
    x = []
    for i in range(10000):
        x.append(i)

    return x

def test_insert():
    x = []
    for i in range(10000):
        x.insert(0, i)

    return x

def benchmark(function, number=100, repeat=10):
    # Measure the execution times
    times = timeit.repeat(function, number=number, globals=globals())
    # The repeat function gives `repeat` results so we take the min()
    # and divide it by the number of runs
    time = min(times) / number
    print('%d loops, best of %d: %9.6fs :: %s' % (
        number, repeat, time, function))

if __name__ == '__main__':
    benchmark('test_list()')
    benchmark('test_list_comprehension()')
    benchmark('test_append()')
    benchmark('test_insert()')

当执行此操作时,您将获得以下内容:

# python3 test_timeit.py
100 loops, best of 10:  0.000238s :: test_list()
100 loops, best of 10:  0.000407s :: test_list_comprehension()
100 loops, best of 10:  0.000838s :: test_append()
100 loops, best of 10:  0.031795s :: test_insert()

正如您可能已经注意到的,这个脚本仍然有点基本。虽然常规版本会一直尝试,直到达到 0.2 秒或更长时间,但此脚本的执行次数是固定的。不幸的是,timeit模块的编写并不完全考虑重用,因此除了从脚本中调用timeit.main()之外,您也没有多少方法可以重用该逻辑。

就个人而言,我建议改用 IPython,因为它使测量更容易:

# ipython3
In [1]: import test_timeit
In [2]: %timeit test_timeit.test_list()
1000 loops, best of 3: 255 µs per loop
In [3]: %timeit test_timeit.test_list_comprehension()
1000 loops, best of 3: 430 µs per loop
In [4]: %timeit test_timeit.test_append()
1000 loops, best of 3: 934 µs per loop
In [5]: %timeit test_timeit.test_insert()
10 loops, best of 3: 31.6 ms per loop

在这种情况下,IPython 会自动处理globals()的字符串包装和传递。尽管如此,这些都是非常有限的,并且只在比较做同一件事的多种方法时有用。对于完整的 Python 应用,有更多的方法可用。

提示

要查看 IPython 函数和常规模块的源代码,在 IPython shell 中输入object??将返回源代码。在这种情况下,只需输入timeit??即可查看timeitIPython 函数定义。

您可以自己实现%timeit函数的最简单方法是调用timeit.main

import timeit

timeit.main(args=['[x for x in range(1000000)]'])

timeit模块的内部没有什么特别之处。基本版本只需一个evaltime.perf_counter(Python 中可用的最高分辨率计时器)组合即可实现:

import time
import functools

TIMEIT_TEMPLATE = '''
import time

def run(number):
    %(setup)s
    start = time.perf_counter()
    for i in range(number):
        %(statement)s
    return time.perf_counter() - start
'''

def timeit(statement='pass', setup='pass', repeat=1, number=1000000,
           globals_=None):
    # Get or create globals
    globals_ = globals() if globals_ is None else globals_

    # Create the test code so we can separate the namespace
    src = TIMEIT_TEMPLATE % dict(
        statement=statement,
        setup=setup,
        number=number,
    )
    # Compile the source
    code = compile(src, '<source>', 'exec')

    # Define locals for the benchmarked code
    locals_ = {}

    # Execute the code so we can get the benchmark fuction
    exec(code, globals_, locals_)

    # Get the run function
    run = functools.partial(locals_['run'], number=number)
    for i in range(repeat):
        yield run()

在检查输入方面,实际的timeit代码稍微高级一些,但是这个例子粗略地展示了timeit.repeat函数是如何实现的。

要在 IPython 中注册自己的函数,需要使用一些 IPython 魔术。请注意,魔术不是双关语。负责此类命令的 IPython 模块实际上被称为magic。证明:

from IPython.core import magic

@magic.register_line_magic(line):
    import timeit
    timeit.main(args[line])

要了解更多关于 IPython 中的自定义魔法的信息,请查看中的 IPython 文档 https://ipython.org/ipython-doc/3/config/custommagics.html

cProfile–查找最慢的组件

profile模块可以轻松分析脚本/应用中使用的相对 CPU 周期。注意不要将这些结果与timeit模块的结果进行比较。当timeit模块尽可能给出执行代码片段所需绝对时间的准确基准时,profile模块只对相对结果有用。原因是分析代码本身会导致速度减慢,结果无法与未分析的代码进行比较。然而,有一种方法可以使它更精确一些,但稍后会有更多的内容。

在本节中,我们将讨论profile模块,但在示例中,我们将实际使用cProfile模块。cProfile模块是纯 Pythonprofile模块的高性能仿真。

第一次分析运行

让我们从第 5 章中分析我们的斐波那契函数,修饰符–通过装饰实现代码重用,无论是否使用缓存函数。首先,守则:

import sys
import functools

@functools.lru_cache()
def fibonacci_cached(n):
    if n < 2:
        return n
    else:
        return fibonacci_cached(n - 1) + fibonacci_cached(n - 2)

def fibonacci(n):
    if n < 2:
        return n
    else:
        return fibonacci(n - 1) + fibonacci(n - 2)

if __name__ == '__main__':
    n = 30
    if sys.argv[-1] == 'cache':
        fibonacci_cached(n)
    else:
        fibonacci(n)

为了可读性,所有cProfile统计数据将从所有cProfile输出中的percallcumtime列中删除。这些列与这些示例的目的无关。

首先,我们将执行不带缓存的函数:

# python3 -m cProfile -s calls test_fibonacci.py no_cache
 2692557 function calls (21 primitive calls) in 0.815
 seconds

 Ordered by: call count

 ncalls tottime percall filename:lineno(function)
2692537/1   0.815   0.815 test_fibonacci.py:13(fibonacci)
 7   0.000   0.000 {built-in method builtins.getattr}
 5   0.000   0.000 {built-in method builtins.setattr}
 1   0.000   0.000 {method 'update' of 'dict' objects}
 1   0.000   0.000 {built-in method builtins.isinstance}
 1   0.000   0.000 functools.py:422(decorating_function)
 1   0.000   0.815 test_fibonacci.py:1(<module>)
 1   0.000   0.000 {method 'disable' of '_lsprof.Profiler'}
 1   0.000   0.815 {built-in method builtins.exec}
 1   0.000   0.000 functools.py:43(update_wrapper)
        1   0.000   0.000 functools.py:391(lru_cache)

那是相当多的电话,不是吗?显然,我们调用了test_fibonacci函数近 300 万次。这就是分析模块提供了很多洞察的地方。让我们进一步分析这些指标:

  • Ncalls:对该函数进行的调用次数

  • Tottime: The total time spent in seconds within this function with all sub-functions excluded

    珀索尔,tottime / ncalls

  • Cumtime: The total time spent within this function, including sub-functions

    珀索尔,cumtime / ncalls

哪个最有用取决于您的用例。在默认输出中使用-s参数更改排序顺序非常简单。但是现在让我们看看缓存版本的结果是什么。同样,对于剥离输出:

# python3 -m cProfile -s calls test_fibonacci.py cache
 51 function calls (21 primitive calls) in 0.000 seconds

 Ordered by: call count

 ncalls tottime percall filename:lineno(function)
 31/1   0.000   0.000 test_fibonacci.py:5(fibonacci_cached)
 7   0.000   0.000 {built-in method builtins.getattr}
 5   0.000   0.000 {built-in method builtins.setattr}
 1   0.000   0.000 test_fibonacci.py:1(<module>)
 1   0.000   0.000 {built-in method builtins.isinstance}
 1   0.000   0.000 {built-in method builtins.exec}
 1   0.000   0.000 functools.py:422(decorating_function)
 1   0.000   0.000 {method 'disable' of '_lsprof.Profiler'}
 1   0.000   0.000 {method 'update' of 'dict' objects}
 1   0.000   0.000 functools.py:391(lru_cache)
 1   0.000   0.000 functools.py:43(update_wrapper)

这一次我们看到了0.000tottime,因为它太快了,无法测量。而且,尽管fibonacci_cached函数仍然是执行次数最多的函数,但它只执行了 31 次,而不是 300 万次。

校准您的轮廓仪

为了说明在profilecProfile,之间的区别,让我们用profile模块再次尝试非缓存运行。只是提醒一下,这要慢得多,所以如果它稍微停顿一下,不要感到惊讶:

# python3 -m profile -s calls test_fibonacci.py no_cache
         2692558 function calls (22 primitive calls) in 7.696 seconds

   Ordered by: call count

   ncalls tottime percall filename:lineno(function)
2692537/1   7.695   7.695 test_fibonacci.py:13(fibonacci)
        7   0.000   0.000 :0(getattr)
        5   0.000   0.000 :0(setattr)
        1   0.000   0.000 :0(isinstance)
        1   0.001   0.001 :0(setprofile)
        1   0.000   0.000 :0(update)
        1   0.000   0.000 functools.py:43(update_wrapper)
        1   0.000   7.696 profile:0(<code object <module> ...>)
        1   0.000   7.695 test_fibonacci.py:1(<module>)
        1   0.000   0.000 functools.py:391(lru_cache)
        1   0.000   7.695 :0(exec)
        1   0.000   0.000 functools.py:422(decorating_function)
        0   0.000         profile:0(profiler)

差别很大,不是吗?现在代码的速度慢了近 10 倍,唯一的区别是使用纯 Pythonprofile模块而不是cProfile模块。这确实表明profile模块存在一个大问题。模块本身的开销大到足以扭曲结果,这意味着我们应该考虑这种偏移。这就是Profile.calibrate()函数所要处理的,因为它会计算由模块产生的偏差。要计算偏差,我们可以使用以下脚本:

import profile

if __name__ == '__main__':
    profiler = profile.Profile()
    for i in range(10):
        print(profiler.calibrate(100000))

数字将略有不同,但您应该能够使用此代码获得对偏差的公平估计。如果数字仍然变化很大,您可以将试验从100000增加到更大的数值。这种类型的校准仅适用于 profile 模块,但如果您正在寻找更准确的结果,而cProfile模块由于继承或平台不支持而不适用于您,则可以使用此代码全局设置偏差并获得更准确的结果:

import profile

# The number here is bias calculated earlier
profile.Profile.bias = 2.0939406059394783e-06

对于特定的Profile实例:

import profile

profiler = profile.Profile(bias=2.0939406059394783e-06)

请注意,通常使用较小的偏差比使用较大的偏差更好,因为较大的偏差可能会导致非常奇怪的结果。在某些情况下,你甚至会得到消极的时间安排。让我们尝试一下我们的斐波那契码:

import sys
import pstats
import profile
import functools

@functools.lru_cache()
def fibonacci_cached(n):
    if n < 2:
        return n
    else:
        return fibonacci_cached(n - 1) + fibonacci_cached(n - 2)

def fibonacci(n):
    if n < 2:
        return n
    else:
        return fibonacci(n - 1) + fibonacci(n - 2)

if __name__ == '__main__':
    profiler = profile.Profile(bias=2.0939406059394783e-06)
    n = 30

    if sys.argv[-1] == 'cache':
        profiler.runcall(fibonacci_cached, n)
    else:
        profiler.runcall(fibonacci, n)

    stats = pstats.Stats(profiler).sort_stats('calls')
    stats.print_stats()

当运行它时,我确实使用了一个太大的偏差:

# python3 test_fibonacci.py no_cache
 2692539 function calls (3 primitive calls) in -0.778
 seconds

 Ordered by: call count

 ncalls tottime percall filename:lineno(function)
2692537/1  -0.778  -0.778 test_fibonacci.py:15(fibonacci)
 1   0.000   0.000 :0(setprofile)
 1   0.000  -0.778 profile:0(<function fibonacci at 0x...>)
 0   0.000         profile:0(profiler)

不过,它展示了如何正确使用代码。您甚至可以使用以下代码段将偏差计算合并到脚本中:

import profile

if __name__ == '__main__':
    profiler = profile.Profile()
    profiler.bias = profiler.calibrate(100000)

使用装饰器进行选择性剖析

使用 decorator 计算简单的计时非常简单,但分析也很重要。两个都很有用,但服务于不同的目标。让我们看看这两个选项:

import cProfile
import datetime
import functools

def timer(function):
    @functools.wraps(function)
    def _timer(*args, **kwargs):
        start = datetime.datetime.now()
        try:
            return function(*args, **kwargs)
        finally:
            end = datetime.datetime.now()
            print('%s: %s' % (function.__name__, end - start))
    return _timer

def profiler(function):
    @functools.wraps(function)
    def _profiler(*args, **kwargs):
        profiler = cProfile.Profile()
        try:
            profiler.enable()
            return function(*args, **kwargs)
        finally:
            profiler.disable()
            profiler.print_stats()
    return _profiler

@profiler
def profiled_fibonacci(n):
    return fibonacci(n)

@timer
def timed_fibonacci(n):
    return fibonacci(n)

def fibonacci(n):
    if n < 2:
        return n
    else:
        return fibonacci(n - 1) + fibonacci(n - 2)

if __name__ == '__main__':
    timed_fibonacci(32)
    profiled_fibonacci(32)

代码非常简单,只是一个基本的计时器和分析器打印一些默认的统计数据。当然,哪些函数最适合您取决于您的用例,但它们肯定都有各自的用途。这种选择性分析的另一个优点是输出更加有限,这有助于提高可读性:

# python3 test_fibonacci.py
 timed_fibonacci: 0:00:01.050200
 7049157 function calls (3 primitive calls) in 2.024
 seconds

 Ordered by: standard name

 ncalls tottime percall filename:lineno(function)
 1   0.000   2.024 test_fibonacci.py:31(profiled_fibonacci)
7049155/1   2.024   2.024 test_fibonacci.py:41(fibonacci)
 1   0.000   0.000 {method 'disable' of '_lsprof.Profiler'}

正如您所看到的,分析器仍然使代码的速度提高了一倍左右,但它确实是可用的。

使用档案统计

为了得到更复杂的分析结果,我们将分析脚本。pystone脚本是一个内部 Python 性能测试,它对 Python 解释器进行了相当彻底的基准测试。首先,让我们使用以下脚本创建统计信息:

from test import pystone
import cProfile

if __name__ == '__main__':
    profiler = cProfile.Profile()
    profiler.runcall(pystone.main)
    profiler.dump_stats('pystone.profile')

执行脚本时,您应该得到如下结果:

# python3 test_pystone.py
Pystone(1.2) time for 50000 passes = 0.725432
This machine benchmarks at 68924.4 pystones/second

在运行脚本之后,您应该有一个包含评测结果的pystone.profile文件。这些结果可以是和包含所有分析统计信息的pystone.profile文件。这些统计数据可以通过与 Python 捆绑的pstats模块查看:

import pstats

stats = pstats.Stats('pystone.profile')
stats.strip_dirs()
stats.sort_stats('calls', 'cumtime')
stats.print_stats(10)

在某些情况下,将多个测量结果结合起来可能会很有趣。这可以通过指定多个文件或使用stats.add(*filenames)实现。但首先,让我们看看常规输出:

# python3 parse_statistics.py

 1050012 function calls in 0.776 seconds

 Ordered by: call count, cumulative time
 List reduced from 21 to 10 due to restriction <10>

 ncalls  tottime  percall  cumtime  percall filename:lineno(function)
 150000    0.032    0.000    0.032    0.000 pystone.py:214(Proc7)
 150000    0.027    0.000    0.027    0.000 pystone.py:232(Func1)
 100000    0.016    0.000    0.016    0.000 {built-in method builtins.chr}
 100000    0.010    0.000    0.010    0.000 {built-in method builtins.ord}
 50002    0.029    0.000    0.029    0.000 pystone.py:52(__init__)
 50000    0.127    0.000    0.294    0.000 pystone.py:144(Proc1)
 50000    0.094    0.000    0.094    0.000 pystone.py:219(Proc8)
 50000    0.048    0.000    0.077    0.000 pystone.py:60(copy)
 50000    0.051    0.000    0.061    0.000 pystone.py:240(Func2)
 50000    0.031    0.000    0.043    0.000 pystone.py:171(Proc3)

显然,可以很容易地修改参数以更改排序顺序和输出行数。但这并不是统计数据的唯一可能性。有很多包可以解析这些结果并将其可视化。一个选项是 RunSnakeRun,它虽然有用,但目前并不在 Python3 上运行。此外,我们还有 QCacheGrind,这是一个非常好的概要统计可视化工具,但需要一些手动编译才能运行,当然也需要一些搜索二进制文件。

让我们看看 QCacheGrind 的输出。在 Windows 的情况下,QCacheGrindWin 软件包提供了一个二进制文件,而在 Linux 中,它很可能通过您的软件包管理器提供,使用 OS X 您可以尝试brew install qcachegrind --with-graphviz。但您还需要一个软件包:pyprof2calltree软件包。它将profile输出转换为 QCacheGrind 能够理解的格式。因此,在简单的pip install pyprof2calltree,之后,我们现在可以将profile文件转换为callgrind文件:

# pyprof2calltree -i pystone.profile -o pystone.callgrind
writing converted data to: pystone.callgrind
# qcachegrind pystone.callgrind

这将导致QCacheGrind应用的运行。切换到相应选项卡后,您应该会看到如下图所示的内容:

Using profile statistics

对于这样一个简单的脚本,几乎所有的输出都可以工作。然而,对于完整的应用,像 QCacheGrind 这样的工具是非常宝贵的。查看 QCacheGrind 生成的输出,很明显哪个过程花费的时间最多。右上角的结构显示了更大的矩形,如果所花费的时间更大,这是一个非常有用的 CPU 时间块的可视化。左边的列表与cProfile非常相似,因此没有什么新内容。右下角的树可能非常有价值,也可能非常无用,就像在本例中一样。它显示一个函数占用 CPU 时间的百分比,更重要的是,该函数与其他函数的关系。

由于这些工具根据输入进行缩放,因此结果对于几乎任何应用都很有用。一个函数需要 100 毫秒还是 100 分钟都没有区别,输出将清晰地显示慢部分的概览,这就是我们将尝试解决的问题。

测线仪

line_profiler实际上不是一个与 Python 捆绑在一起的包,但是它太有用了,不能忽略。当常规的profile模块对某个块中的所有(子)函数进行分析时,line_profiler允许对函数中的每一行进行分析。斐波那契函数在这里不是最合适的,但是我们可以使用质数生成器来代替。但首先,安装line_profiler

 pip install line_profiler

现在我们已经安装了line_profiler模块(以及kernprof命令),让我们测试line_profiler

import itertools

@profile
def primes():
    n = 2
    primes = set()
    while True:
        for p in primes:
            if n % p == 0:
                break
        else:
            primes.add(n)
            yield n
        n += 1

if __name__ == '__main__':
    total = 0
    n = 2000
    for prime in itertools.islice(primes(), n):
        total += prime

    print('The sum of the first %d primes is %d' % (n, total))

你可能想知道装饰师是从哪里来的。它源于line_profiler模块,这就是为什么我们必须使用kernprof命令运行脚本的原因:

# kernprof -l test_primes.py
The sum of the first 2000 primes is 16274627
Wrote profile results to test_primes.py.lprof

正如命令所说,结果已写入test_primes.py.lprof文件。因此,让我们看一下为可读性而跳过Time列的输出:

# python3 -m line_profiler test_primes.py.lprof
Timer unit: 1e-06 s

Total time: 2.33179 s
File: test_primes.py
Function: primes at line 4

Line #      Hits   Per Hit   % Time  Line Contents
==================================================
 4                               @profile
 5                               def primes():
 6         1       3.0      0.0      n = 2
 7         1       1.0      0.0      primes = set()
 8         1       0.0      0.0      while True:
 9   2058163       0.5     43.1          for p in primes:
 10   2056163       0.6     56.0              if n % p == 0:
 11     15388       0.5      0.3                  break
 12                                       else:
 13      2000       1.2      0.1              primes.add(n)
 14      2000       0.5      0.0              yield n
 15     17387       0.6      0.4          n += 1

很好的输出,不是吗?这使得在一段代码中找到慢的部分变得微不足道。在这段代码中,慢度显然来自循环,但在其他代码中可能没有那么清楚。

该模块也可以添加为 IPython 扩展,它在 IPython 中启用%lprun命令。要加载扩展,可以从 IPython shell%load_ext line_profiler使用load_ext命令。

提高绩效

关于性能优化,我们可以说很多,但说实话,如果您已经阅读了到目前为止的整本书,您就知道编写快速代码的大多数 Python 特定技术。应用性能中最重要的因素始终是算法的选择,进而是数据结构的选择。在list中搜索项目几乎总是比在dictset中搜索项目更糟糕。

使用正确的算法

在任何应用中,算法的正确选择是迄今为止最重要的性能特征,这就是为什么我重复它来说明错误选择的结果:

In [1]: a = list(range(1000000))

In [2]: b = dict.fromkeys(range(1000000))

In [3]: %timeit 'x' in a
10 loops, best of 3: 20.5 ms per loop

In [4]: %timeit 'x' in b
10000000 loops, best of 3: 41.6 ns per loop

检查项目是否在list内为O(n)操作,检查项目是否在dict内为O(1)操作。当n=1000000出现巨大差异时,显然,在这个简单的测试中,我们可以看到,对于 100 万个项目,它的速度要快 500 倍。

所有其他性能提示结合在一起可能会使代码的速度提高一倍,但使用正确的算法可以带来更大的改进。使用花费O(n)时间而不是O(n^2)时间的算法将使您的代码n=1000,1000倍,而n越大,差异只会越大。

全局解释器锁

CPython 解释器的最模糊的组件之一是全局解释器锁GIL),一个互斥锁互斥锁),用于防止内存损坏。Python 内存管理器不是线程安全的,这就是为什么需要 GIL。如果没有 GIL,多个线程可能会同时改变内存,导致各种意外和潜在的危险结果。

那么 GIL 在实际应用中的影响是什么呢?在单线程应用中,它没有任何区别,实际上是一种非常快速的内存一致性方法。但是,在多线程应用中,它会使应用的速度减慢一点,因为一次只有一个线程可以访问 GIL。因此,如果您的代码必须大量访问 GIL,那么它可能会从一些重构中受益。

幸运的是,Python 为并行处理提供了一些其他选项:我们前面看到的asyncio模块和我们将在第 13 章多进程中看到的multiprocessing库—当单个 CPU 核心不够时

尝试与如果

在许多语言中,try/except类型的块会导致相当大的性能损失,但在 Python 中,情况并非如此。并不是说if语句很重,但是如果你希望你的try/except大部分时间都能成功,并且只有在少数情况下失败,那么这绝对是一个有效的选择。一如既往,关注代码的可读性和传达代码的目的。如果使用if语句代码的意图更清晰,请使用if语句。如果try/except以更好的方式传达意图,请使用该方式。

列表与生成器

使用生成器惰性地评估代码几乎总是一个比计算整个数据集更好的主意。性能优化最重要的规则可能是,你不应该计算任何你不打算使用的东西。如果你不确定你是否需要它,不要计算它。

不要忘记,您可以轻松地链接多个生成器,因此只有在实际需要时才计算所有内容。但一定要小心,这不会导致重新计算;itertools.tee通常比完全重新计算结果更好。

字符串串联

您可能已经看到基准测试表明使用+=比连接字符串慢得多。在某一点上,这确实产生了很大的不同。然而,在 Python3 中,大多数差异已经消失。

In [1]: %%timeit
 ...: s = ''
 ...: for i in range(1000000):
 ...:     s += str(i)
 ...:
1 loops, best of 3: 362 ms per loop

In [2]: %%timeit
 ...: ss = []
 ...: for i in range(1000000):
 ...:     ss.append(str(i))
 ...: s = ''.join(ss)
 ...:
1 loops, best of 3: 332 ms per loop

In [3]: %timeit ''.join(str(i) for i in range(1000000))
1 loops, best of 3: 324 ms per loop

In [4]: %timeit ''.join([str(i) for i in range(1000000)])
1 loops, best of 3: 294 ms per loop

当然还有一些差异,但它们太小了,我建议忽略它们,选择可读性最好的选项。

添加与生成器

与字符串串联的情况一样,曾经的显著差异现在太小,无法提及。

In [1]: %%timeit
   ...: x = 0
   ...: for i in range(1000000):
   ...:     x += i
   ...:
10 loops, best of 3: 73.2 ms per loop

In [2]: %timeit x = sum(i for i in range(1000000))
10 loops, best of 3: 75.3 ms per loop

In [3]: %timeit x = sum([i for i in range(1000000)])
10 loops, best of 3: 71.2 ms per loop

In [4]: %timeit x = sum(range(1000000))
10 loops, best of 3: 25.6 ms per loop

不过,让 Python 使用本机函数在内部处理所有事情是有帮助的,如上一个示例所示。

地图与生成器和列表理解

再一次可读性比性能更重要。少数情况下,map比列表理解和生成器更快,但前提是map函数可以使用预定义函数。只要你需要快速lambda,它实际上就慢了。这并不重要,因为可读性无论如何都应该是关键,使用生成器或列表理解而不是map

In [1]: %timeit list(map(lambda x: x/2, range(1000000)))
10 loops, best of 3: 182 ms per loop

In [2]: %timeit list(x/2 for x in range(1000000))
10 loops, best of 3: 122 ms per loop

In [3]: %timeit [x/2 for x in range(1000000)]
10 loops, best of 3: 84.7 ms per loop

正如您所看到的,列表理解显然比生成器快很多。在许多情况下,我仍然建议使用生成器而不是列表理解,即使只是因为内存使用和潜在的惰性。如果出于某种原因,您只打算使用前 10 项,那么计算完整的项列表仍然会浪费大量资源。

缓存

我们已经在第 5 章中介绍了functools.lru_cache修饰符修饰符–通过装饰实现代码重用,但其重要性不容低估。不管你的代码有多快、多聪明,不必计算结果总是更好的,缓存就是这样做的。根据您的用例,有许多选项可用。在一个简单的脚本中,functools.lru_cache是一个很好的竞争者,但是在一个应用的多次执行之间,cPickle模块也可以起到救生的作用。

如果涉及多台服务器,我建议您看看Redis。Redis 服务器是一个单线程内存服务器,速度非常快,有许多有用的数据结构可用。如果您看到有关使用 Memcached 提高性能的文章或教程,只需将 Memcached 替换为 Redis everywhere 即可。Redis 在各个方面都优于 Memcached,并且在其最基本的形式上 API 是兼容的。

懒惰的进口

应用加载时间中的一个常见问题是在程序开始时立即加载所有内容,而对于许多应用,这实际上是不需要的,并且应用的某些部分仅在实际使用时才需要加载。为了实现这一点,可以偶尔在函数内部移动导入,以便根据需要加载它们。

虽然在某些情况下这是一种有效的策略,但我一般不推荐它,原因有二:

  1. 它使你的代码不那么清晰;在文件顶部以相同样式进行所有导入可以提高可读性。
  2. 它不会使代码更快,因为它只是将加载时间移动到不同的部分。

使用优化库

这实际上是一个非常广泛的技巧,但还是有用的。如果有一个高度优化的库适合您的目的,那么如果不付出大量的努力,您很可能无法超越它的性能。像numpypandasscipysklearn这样的库在性能方面进行了高度优化,它们的本机操作速度可以达到令人难以置信的速度。如果它们适合你的目的,一定要试一试。为了说明numpy与普通 Python 相比有多快,请参考以下内容:

In [1]: import numpy

In [2]: a = list(range(1000000))

In [3]: b = numpy.arange(1000000)

In [4]: %timeit c = [x for x in a if x > 500000]
10 loops, best of 3: 44 ms per loop

In [5]: %timeit d = b[b > 500000]
1000 loops, best of 3: 1.61 ms per loop

numpy代码与 Python 代码完全相同,只是它使用numpy数组而不是 Python 列表。这一微小的差异使代码的速度提高了 25 倍以上。

及时编译

即时JIT编译是一种在运行时动态编译(部分)应用的方法。因为在运行时有更多的可用信息,这会产生巨大的影响,并使您的应用更快。

numba包为您提供选择性 JIT 编译,允许您标记与 JIT 编译器兼容的函数。本质上,如果函数遵循只基于输入的函数编程范式,那么它很可能与 JIT 编译器一起工作。

numbaJIT 编译器如何使用的基本示例:

import numba

@numba.jit
def sum(array):
    total = 0.0
    for value in array:
        total += value
    return value

这些的案例的使用是有限的,但是如果您使用numpy或熊猫,您很可能会从numba中受益。

另一个值得注意的有趣事实是,numba不仅支持 CPU 优化执行,还支持 GPU。这意味着对于某些操作,您可以使用视频卡中的快速处理器来处理结果。

将部分代码转换为 C

我们将在第 14 章C/C++中的扩展、系统调用和 C/C++库中看到更多关于这一点的内容,但如果确实需要高性能,那么本机 C 函数可以帮上大忙。这甚至不必那么困难。Cython 模块使得编写性能非常接近本机 C 代码的部分代码变得非常简单。

以下是 Cython 手册中近似 pi 值的示例:

cdef inline double recip_square(int i):
    return 1./(i*i)

def approx_pi(int n=10000000):
    cdef double val = 0.
    cdef int k
    for k in xrange(1,n+1):
        val += recip_square(k)
    return (6 * val)**.5

虽然存在一些小的差异,例如值和参数的类型定义是cdef而不是def,代码大体上与常规 Python 相同,但肯定是快得多。

内存使用

到目前为止,我们只是简单地查看了执行时间,而忽略了脚本的内存使用情况。在许多情况下,执行时间是最重要的,但不应忽略内存使用。在几乎所有的情况下,CPU 和内存都是交换的;一段代码要么使用大量 CPU,要么使用大量内存,这意味着两者都非常重要。

Tracemalloc

监控内存使用过去是的一种功能,只有通过外部 Python 模块才能实现,例如DowserHeapy。虽然这些模块仍然可以工作,但由于tracemalloc模块的存在,它们现在基本上已经过时了。让我们尝试一下tracemalloc模块,看看现在的内存使用监控有多容易:

import tracemalloc

if __name__ == '__main__':
    tracemalloc.start()

    # Reserve some memory
    x = list(range(1000000))

    # Import some modules
    import os
    import sys
    import asyncio

    # Take a snapshot to calculate the memory usage
    snapshot = tracemalloc.take_snapshot()
    for statistic in snapshot.statistics('lineno')[:10]:
        print(statistic)

这导致:

# python3 test_tracemalloc.py
test_tracemalloc.py:8: size=35.3 MiB, count=999745, average=37 B
<frozen importlib._bootstrap_external>:473: size=1909 KiB, count=20212, average=97 B
<frozen importlib._bootstrap>:222: size=895 KiB, count=3798, average=241 B
collections/__init__.py:412: size=103 KiB, count=1451, average=72 B
<string>:5: size=36.6 KiB, count=133, average=282 B
collections/__init__.py:406: size=29.9 KiB, count=15, average=2039 B
abc.py:133: size=26.1 KiB, count=102, average=262 B
ipaddress.py:608: size=21.3 KiB, count=182, average=120 B
<frozen importlib._bootstrap_external>:53: size=21.2 KiB, count=140, average=155 B
types.py:234: size=15.3 KiB, count=124, average=127 B

您可以轻松地看到代码的每个部分是如何分配内存的,以及内存可能被浪费在哪里。虽然可能还不清楚是哪个部分导致了内存使用,但也有一些选项,我们将在下面的部分中看到。

内存档案器

memory_profiler模块与前面讨论的line_profiler非常相似,但用于内存使用。安装它与pip install memory_profiler一样简单,但也强烈建议使用可选的pip install psutil(在 Windows 的情况下也是必需的),因为它可以大大提高您的性能。为了测试line_profiler,,我们将使用以下脚本:

import memory_profiler

@memory_profiler.profile
def main():
    n = 100000
    a = [i for i in range(n)]
    b = [i for i in range(n)]
    c = list(range(n))
    d = list(range(n))
    e = dict.fromkeys(a, b)
    f = dict.fromkeys(c, d)

if __name__ == '__main__':
    main()

注意,我们实际上在这里导入了memory_profiler,尽管这不是严格要求的。也可以通过python3 -m memory_profiler your_scripts.py执行:

# python3 test_memory_profiler.py
Filename: test_memory_profiler.py

Line #    Mem usage    Increment   Line Contents
================================================
 4     11.0 MiB      0.0 MiB   @memory_profiler.profile
 5                             def main():
 6     11.0 MiB      0.0 MiB       n = 100000
 7     14.6 MiB      3.5 MiB       a = [i for i in range(n)]
 8     17.8 MiB      3.2 MiB       b = [i for i in range(n)]
 9     21.7 MiB      3.9 MiB       c = list(range(n))
 10     25.5 MiB      3.9 MiB       d = list(range(n))
 11     38.0 MiB     12.5 MiB       e = dict.fromkeys(a, b)
 12     44.1 MiB      6.1 MiB       f = dict.fromkeys(c, d)

即使尽管一切都按预期运行,但您可能会对此处代码行使用的内存量的变化感到好奇。为什么a只取3.5 MiBb``3.2 MiB?这是由 Python 内存分配代码引起的;它将内存保留在较大的块中,这些块在内部被细分和重用。另一个问题是memory_profiler在内部拍摄快照,这导致内存在某些情况下被归因于错误的变量。变化应该足够小,最终不会造成很大的差异,但有些变化是可以预料的。

该模块也可以添加为 IPython 扩展,它在 IPython 中启用%mprun命令。要加载扩展,可以从 IPython shell%load_ext memory_profiler使用load_ext命令。另一个非常有用的命令是%memit,它是与%timeit命令等效的内存。

内存泄漏

这些模块的使用通常仅限于搜索内存泄漏。特别是tracemalloc模块有一些功能,使之相当容易。Python 内存管理系统相当简单;它只是有一个简单的引用计数器来查看是否使用了对象。虽然这在大多数情况下效果很好,但当涉及循环引用时,它很容易导致内存泄漏。带有泄漏检测代码的内存泄漏的基本前提如下所示:

 1 import tracemalloc
 2
 3
 4 class Spam(object):
 5     index = 0
 6     cache = {}
 7
 8     def __init__(self):
 9         Spam.index += 1
10         self.cache[Spam.index] = self
11
12
13 class Eggs(object):
14     eggs = []
15
16     def __init__(self):
17         self.eggs.append(self)
18
19
20 if __name__ == '__main__':
21     # Initialize some variables to ignore them from the leak
22     # detection
23     n = 200000
24     spam = Spam()
25
26     tracemalloc.start()
27     # Your application should initialize here
28
29     snapshot_a = tracemalloc.take_snapshot()
30     # This code should be the memory leaking part
31     for i in range(n):
32         Spam()
33
34     Spam.cache = {}
35     snapshot_b = tracemalloc.take_snapshot()
36     # And optionally more leaking code here
37     for i in range(n):
38         a = Eggs()
39         b = Eggs()
40         a.b = b
41         b.a = a
42
43     Eggs.eggs = []
44     snapshot_c = tracemalloc.take_snapshot()
45
46     print('The first leak:')
47     statistics = snapshot_b.compare_to(snapshot_a, 'lineno')
48     for statistic in statistics[:10]:
49         print(statistic)
50
51     print('\nThe second leak:')
52     statistics = snapshot_c.compare_to(snapshot_b, 'lineno')
53     for statistic in statistics[:10]:
54         print(statistic)

让我们看看这段代码实际上泄漏得有多严重:

# python3 test_leak.py
The first leak:
tracemalloc.py:349: size=528 B (+528 B), count=3 (+3), average=176 B
test_leak.py:34: size=288 B (+288 B), count=2 (+2), average=144 B
test_leak.py:32: size=120 B (+120 B), count=2 (+2), average=60 B
tracemalloc.py:485: size=64 B (+64 B), count=1 (+1), average=64 B
tracemalloc.py:487: size=56 B (+56 B), count=1 (+1), average=56 B
tracemalloc.py:277: size=32 B (+32 B), count=1 (+1), average=32 B
test_leak.py:31: size=28 B (+28 B), count=1 (+1), average=28 B
test_leak.py:9: size=28 B (+28 B), count=1 (+1), average=28 B

The second leak:
test_leak.py:41: size=18.3 MiB (+18.3 MiB), count=400000 (+400000), average=48 B
test_leak.py:40: size=18.3 MiB (+18.3 MiB), count=400000 (+400000), average=48 B
test_leak.py:38: size=10.7 MiB (+10.7 MiB), count=200001 (+200001), average=56 B
test_leak.py:39: size=10.7 MiB (+10.7 MiB), count=200002 (+200002), average=56 B
tracemalloc.py:349: size=680 B (+152 B), count=6 (+3), average=113 B
test_leak.py:17: size=72 B (+72 B), count=1 (+1), average=72 B
test_leak.py:43: size=64 B (+64 B), count=1 (+1), average=64 B
test_leak.py:32: size=56 B (-64 B), count=1 (-1), average=56 B
tracemalloc.py:487: size=112 B (+56 B), count=2 (+1), average=56 B
tracemalloc.py:277: size=64 B (+32 B), count=2 (+1), average=32 B

在绝对内存使用率方面,增长甚至没有那么大,但肯定有一点泄漏。第一次泄漏可以忽略不计;在上一次迭代中,我们看到增加了 28 个字节,这几乎是零。然而,第二次泄漏量很大,峰值为 18.3 兆字节。这些都是内存泄漏,Python 垃圾收集器(gc)足够聪明,可以最终清理循环引用,但在达到某个限制之前,它不会清理循环引用。很快就会有更多的消息。

无论何时您想要有一个不会导致内存泄漏的循环引用,weakref模块都可用。它创建的引用不计入对象引用计数。在查看 AUTT1 模块之前,让我们通过 Python 垃圾收集器的眼睛来查看对象引用本身(Po.T2A.):

import gc

class Eggs(object):

    def __init__(self, name):
        self.name = name

    def __repr__(self):
        return '<%s: %s>' % (self.__class__.__name__, self.name)

# Create the objects
a = Eggs('a')
b = Eggs('b')

# Add some circular references
a.b = a
b.a = b

# Remove the objects
del a
del b

# See if the objects are still there
print('Before manual collection:')
for object_ in gc.get_objects():
    if isinstance(object_, Eggs):
        print('\t', object_, gc.get_referents(object_))

print('After manual collection:')
gc.collect()
for object_ in gc.get_objects():
    if isinstance(object_, Eggs):
        print('\t', object_, gc.get_referents(object_))

print('Thresholds:', gc.get_threshold())

让我们看看输出:

# python3 test_refcount.py
Before manual collection:
 <Eggs: a> [{'b': <Eggs: a>, 'name': 'a'}, <class '__main__.Eggs'>]
 <Eggs: b> [{'name': 'b', 'a': <Eggs: b>}, <class '__main__.Eggs'>]
After manual collection:
Thresholds: (700, 10, 10)

正如我们在这里看到的,在我们手动调用垃圾收集器之前,Eggs对象将保留在内存中。即使在显式删除对象之后。那么这是否意味着您总是需要手动调用gc.collect()来删除这些引用?幸运的是,这并不需要,因为一旦达到阈值,Python 垃圾收集器将自动收集。默认情况下,对于三代收集的对象,Python 垃圾收集器的阈值设置为700, 10, 10。收集器跟踪 Python 中的所有内存分配和解除分配,一旦分配数减去解除分配数达到 700,如果不再引用该对象,则将其删除;如果仍有引用,则将其移动到下一代。第 2 代和第 3 代也是如此,尽管阈值较低,为 10。

这就引出了一个问题:何时何地手动调用垃圾收集器有用?由于 Python 内存分配器重用内存块,并且很少释放内存块,因此对于长时间运行的脚本,垃圾收集器非常有用。这正是我建议使用它的地方:在内存不足的环境中长时间运行脚本,特别是在分配大量内存之前。

然而,更重要的是,gc模块在查找内存泄漏时也能帮上大忙。tracemalloc模块可以显示占用字节内存最多的部分,但gc模块可以帮助您找到定义最多的对象。只需小心设置垃圾收集器调试设置,如gc.set_debug(gc.DEBUG_LEAK);即使您自己没有保留任何内存,它也会返回大量输出。回顾前面的SpamEggs脚本,让我们看看在何处以及如何使用垃圾收集模块使用内存:

import gc
import collections

class Spam(object):
    index = 0
    cache = {}

    def __init__(self):
        Spam.index += 1
        self.cache[Spam.index] = self

class Eggs(object):
    eggs = []

    def __init__(self):
        self.eggs.append(self)

if __name__ == '__main__':
    n = 200000
    for i in range(n):
        Spam()

    for i in range(n):
        a = Eggs()
        b = Eggs()
        a.b = b
        b.a = a

    Spam.cache = {}
    Eggs.eggs = []
    objects = collections.Counter()
    for object_ in gc.get_objects():
        objects[type(object_)] += 1

    for object_, count in objects.most_common(5):
        print('%d: %s' % (count, object_))

输出可能与您预期的接近:

# python3 test_leak.py
400617: <class 'dict'>
400000: <class '__main__.Eggs'>
962: <class 'wrapper_descriptor'>
920: <class 'function'>
625: <class 'method_descriptor'>

大量的dict对象是因为类的内部状态,但除此之外,我们只看到Eggs对象,正如我们所期望的那样。垃圾收集器正确地删除了Spam对象,因为它们和所有引用都被删除了。由于循环引用,无法删除Eggs对象。现在我们将使用weakref模块重复相同的示例,看看它是否有区别:

import gc
import weakref
import collections

class Eggs(object):
    eggs = []

    def __init__(self):
        self.eggs.append(self)

if __name__ == '__main__':
    n = 200000
    for i in range(n):
        a = Eggs()
        b = Eggs()
        a.b = weakref.ref(b)
        b.a = weakref.ref(a)

    Eggs.eggs = []
    objects = collections.Counter()
    for object_ in gc.get_objects():
        objects[type(object_)] += 1

    for object_, count in objects.most_common(5):
        print('%d: %s' % (count, object_))

现在让我们看看这次剩下什么:

# python3 test_leak.py
962: <class 'wrapper_descriptor'>
919: <class 'function'>
625: <class 'method_descriptor'>
618: <class 'dict'>
535: <class 'builtin_function_or_method'>

除了一些标准的内置 Python 对象之外,什么都没有,这正是我们所希望的。但要小心弱引用,因为如果引用对象消失,它们很容易在您的脸上爆炸:

import weakref

class Eggs(object):
    pass

if __name__ == '__main__':
    a = Eggs()
    b = Eggs()
    a.b = weakref.ref(b)

    print(a.b())
    del b
    print(a.b())

此导致一个工作参考和一个死参考:

# python3 test_weakref.py
<__main__.Eggs object at 0x104891a20>
None

减少内存使用

一般来说,在 Python 中,内存的使用可能不会是最大的问题,但了解如何减少内存使用仍然很有用。当试图减少内存使用时,理解 Python 如何分配内存是很重要的。

在 Python 内存管理器中,您需要了解四个概念:

  • 首先我们有堆。堆是所有 Python 托管内存的集合。请注意,这与常规堆是分开的,两者混合可能导致内存损坏和崩溃。
  • 其次是竞技场。这些是 Python 从系统请求的块。这些块的固定大小为 256kib,它们是构成堆的对象。
  • 第三,我们有游泳池。这些是组成竞技场的记忆块。这些块每个 4 千磅。由于池和竞技场的大小是固定的,所以它们是简单的数组。
  • 第四个也是最后一个,我们有积木。Python 对象存储在这些块中,每个块都有特定的格式,具体格式取决于数据类型。由于整数比字符占用更多的空间,为了提高效率,使用了不同的块大小。

现在我们知道了内存是如何分配的,我们也可以理解它是如何返回到操作系统的。当竞技场完全空置时,它可以也将被释放。为了增加这种情况发生的可能性,使用了一些启发式方法来最大限度地利用更充分的竞技场。

需要注意的是,常规堆和 Python 堆是分开维护的,因为混合使用它们可能导致应用损坏和/或崩溃。除非您自己编写扩展,否则您可能永远不必担心手动内存分配。

生成器与列表

最重要的提示是尽可能使用生成器。Python3 已经用生成器取代了列表,但记住这一点确实是值得的,因为它不仅节省了内存,而且还节省了 CPU,而不是所有的内存都需要同时保留。

为了说明区别:

Line #    Mem usage    Increment   Line Contents
================================================
 4     11.0 MiB      0.0 MiB   @memory_profiler.profile
 5                             def main():
 6     11.0 MiB      0.0 MiB    a = range(1000000)
 7     49.7 MiB     38.6 MiB    b = list(range(1000000))

range()生成器占用的内存非常少,甚至无法注册,而数字列表占用38.6 MiB

重新创建集合与删除项目

Python 中集合的一个非常重要的细节是,其中许多集合只能增长;他们不会自己退缩。举例说明:

Line #    Mem usage    Increment   Line Contents
================================================
 4     11.5 MiB      0.0 MiB   @memory_profiler.profile
 5                             def main():
 6                             # Generate a huge dict
 7     26.3 MiB     14.8 MiB   a = dict.fromkeys(range(100000))
 8
 9                             # Remove all items
 10     26.3 MiB      0.0 MiB   for k in list(a.keys()):
 11     26.3 MiB      0.0 MiB   del a[k]
 12
 13                             # Recreate the dict
 14     23.6 MiB     -2.8 MiB   a = dict((k, v) for k, v in a.items())

这是列表和字典中最常见的记忆使用错误之一。当然,除了重新创建对象之外,还可以选择使用生成器,这样就永远不会分配内存。

使用插槽

如果您长期使用 Python,您可能已经看到了类的__slots__特性。它允许您指定要在类中存储哪些字段,并通过不实现instance.__dict__跳过所有其他字段。虽然此方法确实在类定义中节省了一点内存,但我建议不要使用它,因为使用它有几个缺点。最重要的一点是,它们使继承变得不明显(将__slots__添加到没有__slots__的子类中没有任何效果)。它还使得不可能动态修改类属性,默认情况下会中断weakref。最后,如果不定义一个__getstate__函数,就不能对带有插槽的类进行 pickle。

然而,为了完整起见,这里演示了插槽功能和内存使用的差异:

import memory_profiler

class Slots(object):
    __slots__ = 'index', 'name', 'description'

    def __init__(self, index):
        self.index = index
        self.name = 'slot %d' % index
        self.description = 'some slot with index %d' % index

class NoSlots(object):

    def __init__(self, index):
        self.index = index
        self.name = 'slot %d' % index
        self.description = 'some slot with index %d' % index

@memory_profiler.profile
def main():
    slots = [Slots(i) for i in range(25000)]
    no_slots = [NoSlots(i) for i in range(25000)]
    return slots, no_slots

if __name__ == '__main__':
    main()

以及内存使用情况:

# python3 test_slots.py
Filename: test_slots.py

Line #    Mem usage    Increment   Line Contents
================================================
 21     11.1 MiB      0.0 MiB   @memory_profiler.profile
 22                             def main():
 23     17.0 MiB      5.9 MiB   slots = [Slots(i) for i in range(25000)]
 24     25.0 MiB      8.0 MiB   no_slots = [NoSlots(i) for i in range(25000)]
 25     25.0 MiB      0.0 MiB   return slots, no_slots

你可能会争辩说,这不是一个公平的比较,因为它们都存储了大量数据,从而扭曲了结果。你确实是对的,因为“裸”比较只存储index,没有其他东西给出2 MiB4.5 MiB。但老实说,如果您不打算存储数据,那么创建类实例有什么意义呢?这就是为什么我不建议使用__slots__,而是建议使用元组或collections.namedtuple,如果内存那么重要的话。还有一种结构的内存效率更高,array模块。它几乎将数据存储在一个裸内存阵列中。请注意,这通常比列表慢,使用起来也不方便。

性能监控

到目前为止,我们已经看到了如何测量和改进 CPU 和内存性能,但有一部分我们已经完全跳过了。由于数据量不断增长等外部因素导致的性能变化很难预测。在实际应用中,瓶颈并不是恒定不变的。它们一直在变化,一旦应用了更多的负载,曾经非常快的代码可能会停滞不前。

因此,我建议实施一个监控解决方案,随着时间的推移跟踪任何事物的性能。性能监控的一个大问题是,您不知道将来什么会减速,原因是什么。我甚至有网站因为 Memcached 和 Redis 的调用而变慢。这些仅内存的缓存服务器在毫秒内响应良好,这使得减速非常不可能,直到您执行 100 次以上的缓存调用,并且缓存服务器的延迟从 0.1 毫秒增加到 2 毫秒,突然之间,这 100 次调用需要 200 毫秒而不是 10 毫秒。尽管 200 毫秒听起来仍然很小,但如果您的总页面加载时间通常低于 100 毫秒,那么这将是一个突然的巨大增长,而且肯定是显而易见的。

为了监控性能并能够跟踪随时间的变化并找到负责的组件,我个人非常喜欢 Statsd 统计收集服务器和 Graphite 接口。尽管可用性有点不足,但结果是一个图形界面,您可以动态查询该界面,以分析性能何时、何地以及如何变化。为了能够使用这些,您必须将度量从应用发送到 Statsd 服务器。为此,我编写了 Python Statsd(https://pypi.python.org/pypi/python-statsd 和 Django Statsd(https://pypi.python.org/pypi/django-statsd 包装。这些包允许您从头到尾监控应用,对于 Django,您将能够监控每个应用或视图的性能,并在这些应用或视图中查看所有组件,例如数据库、模板和缓存层。这样,您就可以确切地知道是什么原因导致您的网站(或应用)速度减慢。

总结

在性能方面,没有圣杯,也没有一件事可以确保所有情况下的最高性能。但是,这不应该让您担心,因为在大多数情况下,您永远不需要调整性能,如果您这样做,一个简单的调整可能会解决您的问题。您现在应该能够在代码中发现性能问题和内存泄漏,这才是最重要的,所以请尽量控制自己,只在实际需要时进行调整。

本章最重要的内容包括:

  • 在你投入任何努力之前先进行测试。使某些功能更快似乎是一项伟大的成就,但却很少需要。
  • 选择正确的数据结构/算法比任何其他性能优化都要有效得多。
  • 循环引用将耗尽内存,直到垃圾收集器开始清理。
  • 槽是不值得的努力。

下一章将讨论多进程器,这是一个使脚本使用多个处理器变得简单的库。如果您无法从脚本中挤出更多的性能,多进程可能是您的答案,因为每个(远程?)CPU 核心都可以使脚本更快。