为了账号安全,请及时绑定邮箱和手机立即绑定

Python性能分析——为什么代码慢以及在哪里慢

用这些超棒的工具找出你的瓶颈

“Python 瓶颈问题。”图片来源 — Viktoria Drake @ viktoria.dev

比如说,你写了个脚本处理笔记本上的数据,去喝咖啡休息一下,15分钟后回来一看,才跑了10%。

怎么这么慢?是哪个环节慢?是读取、处理还是保存数据慢?我怎么才能让它快点?它是不是真的慢?

一个名为 profiler 的工具可以帮你回答这些问题。

什么是Profiler呢?

剖析器(Profiler)是一个工具,它会分析你的代码,运行它,并收集每个函数调用的耗时信息、执行次数以及调用栈。

通过分析其输出,你可以找出代码中最耗时的部分(称为瓶颈),甚至可能找到改进的方法。你想识别并解决这些瓶颈,这会带来整体速度的最大提升。

本例问题

比如说,我们有一个很大的文本文件,我们想在其中找到几次特定模式。首先,让我们生成一个包含随机字母和数字的大型文件。

    import random  
    import string  

    def generate_random_string(length):  
        """生成由小写字母和数字组成的指定长度的随机字符串。"""  
        letters_and_digits = string.ascii_letters + string.digits  
        return ''.join(random.choice(letters_and_digits) for _ in range(length))  

    def generate_random_file(filename, num_lines, line_length):  
        """生成一个指定行数和行长度的随机行文件。"""  
        with open(filename, 'w') as f:  
            for i in range(num_lines):  
                random_line = generate_random_string(line_length) + '\n'  
                f.write(random_line)  

    if __name__ == '__main__':  
        # 生成一个文件,包含1,000,000行,每行具有1000个字符  
        generate_random_file('random.txt', 1_000_000, 1000)  

接下来,让我们定义我们的基线函数——它逐行读取文件,并统计其中前面带有数字的 bobrob 的出现次数。例如,统计前面带有数字的 bobrob 的出现次数。

    import re  

    def baseline():  
        num_total_matches = 0  
        pattern1 = r"[0-9]{1}bob"  
        pattern2 = r"[0-9]{1}rob"  

        with open('random.txt', 'rt') as f:  
            # 读取文件并逐行处理  
            for line in f:  
                line = line.lower()  # 将文本转换为小写  
                for pattern in [pattern1, pattern2]:  
                    # 查找字符串中所有模式的匹配项  
                    matches = re.findall(pattern, line)  

                    # 计算匹配的数量  
                    num_matches = len(matches)  
                    num_total_matches += num_matches  

        return num_total_matches  # 返回匹配的总计数量

比如,这个字符串abc1robdef02bob中有两个“出现”。

我们运行了 baseline 函数,计算出现次数(在我的例子中是 10861 次),并测量运行耗时,我的机器上是 32 秒。

我们怎么才能让它更快?

潜在改进空间

以下是让代码运行变慢的四个部分:

  1. 使用原始字符串(而不是编译的正则表达式对象)
  2. 使用两个单独的搜索操作而不是单个联合的正则表达式
  3. 将每一行都转换为小写而不是使用忽略大小写的标志(例如 re.I)
  4. 逐行读取文件而不是一次性读取大块内容

我们代码中潜在的瓶颈点

我们怎么知道哪个是瓶颈?我们用一个工具叫 profiler。

Python 性能分析器

好消息是您不需要自己实现任何东西。Python 自带了两个内置的性能分析模块——cProfileprofile。它们的作用相同,但 cProfile 是用 C 语言编写的,而 profile 则是纯 Python 编写的,不过这对我们的需求来说无关紧要。可以直接用它们。这样可以简化我们的工作。我们将会用到一些外部工具。

我们需要一种快速简便的方法来对代码的一部分(例如,一个函数)进行代码剖析并将结果保存到文件中。一个叫做 profilehooks 的模块提供了一个简单的装饰器,我们可以像下面这样将它应用到感兴趣的函数上:

from profilehooks import profile  

# stdout=False -> 不在终端输出任何内容  
# filename -> 包含 profiling 结果的文件路径  
@profile(stdout=False, filename='baseline.prof')  
def 基础实现():  
    ...

只需一条简单的命令 pip install profilehooks 即可安装它。

这里我们需要把这个文件可视化一下,让人能看得懂。我用下面两种工具来做这个。

SnakeViz — 超快速简单

Snakeviz(https://github.com/jiffyclub/snakeviz)将Python性能分析结果可视化,可以在浏览器里运行。安装极其简单(`pip install snakeviz),使用起来也十分方便(snakeviz <性能分析输出文件路径>)。我们来看看baseline`函数的性能分析结果吧。

蛇可视化结果(交互式)如下所示 — icicle(左)和 sunburst(右,可能不太容易阅读)图。你可以悬停或点击每个函数调用来查看其详细信息。从上到下代表你调用的嵌套层次结构,线条的长度表示代码执行该调用所花费的相对时间的多少。

Snakeviz 还展示了一个关于你函数执行时间的交互式统计表格。

我们注意到,大部分的执行时间都花在了findall这个函数上,也就是说,在进行正则表达式匹配。也就是说,如果我们想提速代码,就需要重点加快这个函数的速度,因为它才是瓶颈,不是其他部分。

我们再用另一个工具来核对一下结果。

gprof2dot —易于阅读且灵活

Gprof2dot 提供更易读的流程图形式的可视化,保存为图片文件,易于分享,如需还可自动化。不过它不是交互式的,并且需要在系统中安装 Graphviz

要安装 gprof2dot,只需运行命令 pip install gprof2dot

要生成包含性能分析结果的输出图像,请使用以下命令:

python -m gprof2dot -f pstats <文件名> | dot -Tpng -o output.png

我们首先将函数调用的层级表示为一个 dot 格式的图,然后生成该图的图像——即图的可视化。dot 命令支持多种输出格式,如 .jpg.svg,并且 gprof2dot 的输出也非常灵活。

我们来看看它是什么样的。

我们的代码中函数执行情况的图展示。百分比显示了函数内部执行时间所占的总比例,以及 (%) ,但这些仅包括该函数自身的代码。最后一个数字是该函数在代码中调用的次数。

现在这张图确实更易读了,它显示正则表达式的搜索占了总执行时间的88%之多,所以我们需要想办法让它跑得更快。

如果你没有安装 Graphviz(dot 命令),你可以使用一个对应的 Python 库(Graphviz)(通过 pip install graphviz 安装),并编写一个简单的 Python 脚本来生成结果。

  1. 我们将把 gprof2dot 的输出保存为一个 .dot 文件:
    python -m gprof2dot -f pstats file.prof > file.dot

    这条命令会将分析结果输出到 file.dot 文件中。

  2. 接下来,我们将使用以下代码从该 .dot 文件生成一个图像:
    import graphviz  

    # 生成指定格式的图片文件
    def make_png(input_file_name, output_file_name):  
        dot = graphviz.Source.from_file(input_file_name)  
        dot.render(outfile=output_file_name)  

    if __name__ == '__main__':  
        make_png('file.dot', 'file.svg')  # 也支持如 .png,.jpg 等格式
代码优化

让我们通过将两个正则表达式合并成一个来加快代码。

    def single_pattern():  # 定义一个只进行一次模式匹配的函数  
        num_total_matches = 0  
        pattern = r"[0-9][rb]ob"  # 现在我们只需要一次搜索  

        with open('random.txt', 'rt') as f:  
            for line in tqdm(f, total=1_000_000):  
                line = line.lower()  
                # 查找字符串中所有匹配模式的部分  
                matches = re.findall(pattern, line)  

                # 统计匹配数  
                num_matches = len(matches)  
                num_total_matches += num_matches  

        return num_total_matches

跑起来,现在我们有15秒了,快了一倍!

新代码的性能分析结果如下。注意,findall 花费的时间百分比从 88% 下降到 81.5%。

分析新代码后,我们确认这是一个正确的选择——主要的findall函数所用的时间减少了,而其他函数的执行时间则增加了大约两倍。

如果我们实现其他三个想法而不是专注于瓶颈,运行时间会是32秒,和原来的完全一样!我们白费了这么多功夫却毫无进展!这就是为什么专注于瓶颈很重要。

     def 无效的努力():
        总匹配数 = 0  
        # 我们使用了正则表达式的编译版本  
        模式1 = re.compile(r"[0-9]{1}rob", flags=re.IGNORECASE)  
        # 我们也使用了大小写不敏感性  
        模式2 = re.compile(r"[0-9]{1}bob", flags=re.IGNORECASE)  

        with open('random.txt', 'rt') as f:  
            # 我们将行加载到一个块中,然后一次性处理  
            块 = []  
            for 行 in tqdm(f, total=1_000_000):  
                块.append(行)  

                if len(块) == 1000:  
                    块字符串 = ''.join(块)  
                    for 模式 in [模式1, 模式2]:  
                        # 在字符串中查找所有匹配的模式  
                        匹配 = re.findall(模式, 块字符串)  

                        # 计算匹配的数量  
                        匹配数 = len(匹配)  
                        总匹配数 += 匹配数  

                    块 = []  

        # 尽管我们做了努力,这段代码仍然像原文一样慢  
        return 总匹配数
多处理(同时运行多个任务)

findall函数依然是瓶颈。然而,在这个简单的例子中,我们只能做一件事来进一步提升它:将代码并行化。

不同字符串中的匹配互不相关,因此我们可以同时搜索这些匹配。那我们该怎么操作呢,大家有什么好主意吗?

最简单的方法是创建一个进程池——一个操作多个可并行运行的 Python 进程的对象,这些进程可以同时运行。如果我们有一个值列表和一个应用于每个值的函数,我们只需调用进程池的 map 方法即可,它就会为我们并行运行该函数,处理所有这些值。

from multiprocessing import Pool  

def 计算匹配数量(string):  
    # 这是一个为Pool对象准备的函数  
    # 它将在文件的每一行上并行执行  
    pattern = r"[0-9]{1}[rb]ob"  
    return len(re.findall(pattern, string))  

def 分块单个池():  
    总匹配数 = 0  

    pool = Pool(8)  # 进程数量,应不超过您的CPU核心数  

    with open('random.txt', 'rt') as f:  
        分块 = []  
        # 我们将行加载到列表里   
        for line in tqdm(f, total=1_000_000):  
            line = line.lower()  
            分块.append(line)  

            if len(分块) == 1000:  
                # 然后我们并行地将`计算匹配数量`函数应用于分块中的每一行  
                匹配数 = pool.map(计算匹配数量, 分块)  
                # 将所有独立匹配的数量汇总起来  
                总匹配数 += sum(匹配数)  

                # 清空分块  
                分块 = []  

    返回 总匹配数

哇,6.1秒!这是一个新的记录。它比我们优化后的方案快2.2倍,比原来的方案快约5倍!我们来看一下性能分析的结果:

多进程代码的分析结果。可以看到,正则表达式搜索的比例在不断减少(绿色块)。

Snakeviz 会为多进程的代码显示相同的图表。

他们确认现在当前的正则表达式搜索大约占据总时间的2/3,远低于原来的版本。

一个注意点
多处理进程

一个重要的注意事项是,剖析器不再知道子程序中进行正则表达式搜索时的具体情况。它只是显示了等待所有这些进程完成共花了4秒,但它无法访问子程序中执行的代码,因为这些子程序是与我们的主Python进程松散连接的独立程序。

如果你想分析程序中子进程的运行状况,你应该在将会在子进程中执行的函数或代码中添加 @profile 装饰器。

多线程处理

一般情况下,由于[全局解释器锁(GIL)]的限制,单个Python进程中同一时间只能有一个线程执行Python代码,因此你通常不需要直接编写多线程的Python代码。

不过,如果你在使用很多非Python的库(例如NumPy、PyTorch、scipy)或者处理大量输入输出(例如网络通信),那么你的程序运行时间大多花费在Python解释器之外运行用C、C++、Fortran等语言编写的代码。

在这样的两个场景中,使用多个线程可能是实用的,因此你需要明白,Python 内置的两个性能分析工具模块——profile 和 cProfile——仅对应用程序的主线程进行性能分析。如果你想分析其他线程执行的代码,你可以在线程执行的函数中运行性能分析器,或者使用一些第三方性能分析工具,例如 YappiVizTracer

GPU(图形处理器)计算:

如果你使用 GPU 进行计算,请注意它在你的系统中是一个独立的硬件,并且与你的 CPU 异步工作。当你在 GPU 上运行代码时,测量执行时间时要特别小心,因为你的 Python 代码无法知道 GPU 上的具体情况。它只能让 GPU 去执行任务,并等待 GPU 完成。

下面来看这段代码:

导入 torch  
from profilehooks import profile  

@profile(filename='gpu_test.prof', stdout=False)  
def compute_big_sum(tensor: torch.Tensor):  
    # 执行一些耗时的计算,最终输出一个结果  
    a = tensor.sum()  
    b = tensor.pow(2).sum()  
    c = tensor.sqrt().sum()  

    sum_all = a + b + c  
    sum_all_cpu = sum_all.cpu()  # 将值移动到CPU内存  

    # 返回所有操作结果的总和(标量)  
    return sum_all_cpu  

if __name__ == '__main__':  
    # 创建一个位于GPU上的大随机数矩阵  
    X = torch.rand((10000, 10000), dtype=torch.float64, device='cuda:0')  
    res = compute_big_sum(X)  

    print(float(res)) 

我们在 PyTorch 中(在 GPU 上)创建一个 10000x10000 的矩阵,然后对计算如下操作的函数进行性能分析:

  1. 所有元素的总和
  2. 所有元素平方的总和
  3. 所有元素平方根的总和
  4. 前三项的总和

这个函数会输出一个数字。

你觉得哪一行代码最耗时?

性能分析结果:_compute_bigsum函数的

性能分析结果:计算大和的

更令人惊讶的是,是 .cpu() 方法将数据从 GPU 内存移动到了 CPU 内存!但这似乎没有道理,我们只是在移动 8 字节大小的数据!内存传输真的这么慢吗?数据传输速度真的这么低吗?

不行

由于在 PyTorch 的底层,计算是由 CUDA 在 GPU 上完成的,而 GPU 是一个独立于 CPU 的设备,因此当你使用 .sum()torch.pow() 或其他类似函数时,Python 只是告诉 GPU 开始算,并不会等计算结果出来!这意味着 Python 会立即跳到下一行代码。

等待发生在 .cpu() 方法里,该方法会把数据从 GPU 内存移到 CPU 内存。因此,它必须等待,直到所有之前的计算结果都在 GPU 内存中准备好——也就是说,我们对大矩阵做的所有操作都已完成。

但我们如何建立这个档案呢?

  1. 使用内置的PyTorch profiler,它支持跨多个设备的操作性能剖析;
  2. 如果你对每个单独的GPU操作所花费的时间不感兴趣(或者你只有一个GPU),你可以在启动所有GPU计算后立即在代码中添加torch.cuda.synchronize(),这将迫使PyTorch等待所有GPU上的操作完成。

让我们看看这条线上的特征分析结果怎么样。

使用 torch.cuda.synchronize() 时的分析结果。现在来看,. cpu() 只占总时间的大约 1%,而大部分运行时间都花在等待 GPU 计算结束上,这符合预期。

我们修改后的 compute_big_sum 函数的代码如下:

    def compute_big_sum(tensor: torch.Tensor):  
        a = tensor.sum()  
        b = tensor.pow(2).sum()  
        c = tensor.sqrt().sum()  

        sum_all = a + b + c  
        torch.cuda.synchronize()  # 在所有GPU操作之后添加这行代码  
                                  # 这将确保时间测量的准确性  
        sum_all_cpu = sum_all.cpu()  

        return sum_all_cpu
参考资料
  1. Python 正则表达式参考
  2. Python 内置性能分析器文档
  3. profilehooks — 方便性能分析代码部分的装饰器工具
  4. Snakeviz — 简单即插即用的互动式性能分析可视化工具
  5. gprof2dot — 高可读性和灵活性强的性能分析可视化工具
  6. GraphViz — 用于生成执行图的工具
  7. graphviz — GraphViz 的可选 Python 包
  8. Python 多进程库文档
  9. Python 全局解释器锁 (GIL)
  10. Yappi: 第三方 Python 性能分析器,支持多线程和异步操作
  11. VizTracer: 第三方 Python 性能分析器,支持多线程和异步操作
  12. CUDA 概述
点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消