我们的 AI 引擎 Neutron 在加州大学伯克利分校的 CyberGym 基准测试中取得了 96.75% 的成绩。 了解更多

工程

工程

编写超快 Python 代码的策略

本文介绍了提升 Python 这门解释型语言性能的多种方法。

Python 是一门广受欢迎的编程语言,凭借易用、简洁和可读性强的特点获得了极高的人气。然而,它的动态特性使其与其他语言相比运行速度相对较慢。

在处理大型数据集、计算密集型任务或需要快速响应的实时应用时,Python 代码的性能可能会成为问题。在按使用量计费计算资源的云环境中,缓慢的代码还会增加应用的运行成本。

因此,开发人员一直在寻找让 Python 代码运行得更快的方法。在本文中,我们将探讨编写超快 Python 代码的策略。

本文将介绍开发人员让 Python 代码变得超快的各种方法,从利用内置的 API/模块到使用第三方库/工具。

为了便于比较,我们将使用一个基准脚本,并将其性能与文中讨论的各种工具或技术进行对比。该脚本只是根据一组图片 URL 从 Unsplash 下载图片。

# base.py

import requests

IMG_URLS = [
    'https://images.unsplash.com/photo-1681139504760-4c17f2c8b380',
    'https://images.unsplash.com/photo-1661956601031-4cf09efadfce',
    'https://images.unsplash.com/photo-1681138279775-2b407d5d962a',
    'https://images.unsplash.com/photo-1530224264768-7ff8c1789d79',
    'https://images.unsplash.com/photo-1564135624576-c5c88640f235',
    'https://images.unsplash.com/photo-1541698444083-023c97d3f4b6',
    'https://images.unsplash.com/photo-1522364723953-452d3431c267',
    'https://images.unsplash.com/photo-1513938709626-033611b8cc03',
    'https://images.unsplash.com/photo-1507143550189-fed454f93097',
    'https://images.unsplash.com/photo-1504198453319-5ce911bafcde',
    'https://images.unsplash.com/photo-1661956602116-aa6865609028',
    'https://images.unsplash.com/photo-1516972810927-80185027ca84',
    'https://images.unsplash.com/photo-1681105225329-2372f21a59a8',
    'https://images.unsplash.com/photo-1661956601349-f61c959a8fd4'
]


def download_images() -> None:
    for img_url in IMG_URLS:
        img_bytes = requests.get(img_url).content
        img_name = img_url.split('/')[3]
        img_name = f'{img_name}.jpg'
        with open(img_name, 'wb') as img_file:
            img_file.write(img_bytes)
            print(f'{img_name} was downloaded...')

该脚本是未经优化的普通 Python 代码。

基准测试脚本:

# benchmark.py

from timeit import timeit

t1 = timeit(
    "download_images()",
    setup="from base import download_images",
    number=1
)
print(f'Finished running base script in {t1} seconds')

t2 = timeit(
    "run_download_images()",
    setup="from threads import run_download_images",
    number=1
)
print(f'Finished running threads script in {t2} seconds')

print(f"Base: {t1:.3f} seconds")
print(f"Threading: {t2:.3f} seconds")
print(f"Threading is  {t1 / t2:.3f}x faster!")

benchmark.py 脚本将用于对我们的各个脚本进行性能基准测试。setup(‘from threads import run_download_images’ 和 ‘from base import download_images’)以及 stmt(‘run_download_images()’ 和 ‘download_images()’)会根据我们测试的内容而变化。

1. 更新版本的 Python

运行更新版本的 Python,尤其是 Python 3.11(截至 2023 年),相比之前的 Python 版本带来了多项性能提升。根据 Python 文档,Python 3.11 比 Python 3.10 快 10-60%,平均约为 Python 3.10 的 1.25x。

2. 线程

Python 的 Thread API 提供了一种简单高效的方式来创建和管理线程。借助线程,开发人员可以同时运行多个任务,从而加快代码执行速度。线程非常适合 I/O 密集型任务,即涉及大量输入和输出操作的任务,例如读写文件系统、发起 API 请求和下载数据。这是因为线程仍然使用一个进程来执行代码,因此虽然它们适合同时处理更多任务,但并不适合追求速度(例如在执行 CPU 密集型任务,即需要大量计算的任务时)。

Thread 模块

Python 中的 thread 模块为创建和管理线程提供了一个简单易用的接口。在 Python 中,线程是使用 threading 模块创建的,该模块提供了用于创建和管理线程的类。

利用线程发挥多核优势

使用线程的主要优势之一是,它们使程序能够利用 CPU 的多个核心。通过同时运行多个线程,开发人员可以利用多个核心的处理能力,获得更快的执行速度。要通过线程利用多核,开发人员需要确保代码被设计为可并发运行。这意味着各个线程应执行相互独立的任务,彼此互不干扰。如果线程需要共享变量或数据结构等资源,开发人员需要确保对这些资源的访问是线程安全的。

让我们看看如何将 base.py 中的脚本改写为使用线程的版本。

# threads.py

import requests
import uuid
import concurrent.futures

import base # base.py

def download_image(img_url: str) -> None:
    img_bytes = requests.get(img_url).content
    img_name = f"{img_url.split('/')[3]}{uuid.uuid4()}"
    img_name = f'{img_name}.jpg'
    with open(img_name, 'wb') as img_file:
        img_file.write(img_bytes)
        print(f'{img_name} was downloaded...')


def run_download_images():
    with concurrent.futures.ThreadPoolExecutor() as executor:
        executor.map(download_image, base.IMG_URLS)

上面的代码只是创建了一个 ThreadPoolExecuter,由它负责处理线程。

threads.py 与 base.py 的性能对比:

运行 benchmark.py:

python3.10 benchmark.py

线程与普通 Python 的性能对比
线程与普通 Python 的性能对比

可以看到,使用线程后,图片下载速度约为普通代码的 ~1.7x。

线程安全与 GIL

在 Python 中使用线程时,线程安全是一个至关重要的考虑因素。当多个线程访问变量或数据结构等共享资源时,可能会产生竞态条件,从而导致意外的结果。 为了确保线程安全,开发人员需要使用锁、信号量和屏障等同步机制。这些机制可确保同一时间只有一个线程能够访问共享资源,从而防止竞态条件。

开发人员还需要了解全局解释器锁(GIL),这是 Python 用来确保同一时间只有一个线程能够执行 Python 字节码的机制。虽然 GIL 可能会限制多线程程序的性能,但它对于确保 Python 程序的一致性和正确性至关重要。

3. 多进程

多进程是利用 CPU 多核的另一种方式。与线程不同,多进程使用的是独立的进程而非线程。每个进程都在自己的内存空间中运行,因此非常适合需要大量处理能力的 CPU 密集型和 I/O 密集型任务。得益于 multiprocessing 模块,在 Python 中使用多进程非常简单。开发人员可以创建多个同时运行的进程,从而使代码运行得更快。

# multiprocessing_example.py

import uuid
import multiprocessing
import requests
import concurrent.futures

import base


def download_image(img_url: str) -> None:
    img_bytes = requests.get(img_url).content
    img_name = f"{img_url.split('/')[3]}{uuid.uuid4()}"
    img_name = f'{img_name}.jpg'
    with open(img_name, 'wb') as img_file:
        img_file.write(img_bytes)
        print(f'{img_name} was downloaded...')


def run_download_images() -> None:
    with concurrent.futures.ProcessPoolExecutor() as executor:
        executor.map(download_image, base.IMG_URLS)
        print(f"Using {len(multiprocessing.active_children())}/{multiprocessing.cpu_count()} CPUs")

multiprocessing_example.py 与 base.py 的性能对比:

运行 benchmark.py:

python3.10 benchmark.py

多进程与普通 Python 的性能对比
多进程与普通 Python 的性能对比

可以看到,使用多进程确实带来了一定的性能提升。虽然多进程也可以用于 I/O 密集型任务,但它更适合 CPU 密集型任务。

4. Asyncio

Python 的 Asyncio 模块允许使用 async/await 语法编写并发代码。Asyncio 使程序员能够编写非阻塞代码,无需使用多个线程或进程即可同时处理大量任务。需要等待外部资源响应的 I/O 密集型任务(例如网络请求)最适合使用 asyncio。尽管调试问题有时可能比较困难,但得益于 asyncio 的简洁性,开发人员无需操心底层细节即可编写并发代码。

# asyncio_example.py

import asyncio
import uuid
import aiohttp # the requests library is synchronous, and so we'll use `aiohttp` which works better with asyncio

import base


async def download_image(img_url: str) -> None:
    async with aiohttp.ClientSession() as session:
        async with session.get(img_url) as response:
            img_bytes = await response.read()
    img_name = f"{img_url.split('/')[3]}{uuid.uuid4()}"
    img_name = f'{img_name}.jpg'
    with open(img_name, 'wb') as img_file:
        img_file.write(img_bytes)
        print(f'{img_name} was downloaded...')


async def run_download_images() -> None:
    await asyncio.gather(*[download_image(url) for url in base.IMG_URLS])


def download_images_async() -> None:
    asyncio.run(run_download_images())

asyncio_example.py 与 base.py 的性能对比:

运行 benchmark.py:

python3.10 benchmark.py

asyncio_example.py 脚本的运行时间约为 ~9.32862888701493 秒,比 base.py 脚本(~14.631273603008594 秒)快 ~1.568x。

5. 使用 Cython 实现并行

借助 Python 的超集 Cython,开发人员可以编写能够转换为高度优化的 C 或 C++ 代码的 Python 代码。Cython 利用 C 的速度来实现高性能的 Python 程序。使用 Cython,程序员可以编写能够快速转换为 C 的 Python 代码。对于需要大量处理能力的 CPU 密集型任务,Cython 是理想之选。可以使用 Cython 语法编写 Python 代码,然后将其编译为 C,使其执行得更快。

一个使用 Cython 加速 Python 的实际项目示例是 uvloop,这是一个超快的 asyncio 事件循环。平均而言,uvloop 可使 asyncio 快 2-4x。

Uvloop 与普通 Asyncio 的性能对比
Uvloop 与普通 Asyncio 的性能对比

使用 Cython

与 Python 代码写在 .py 文件中一样,Cython 代码写在 .pyx 文件中。

类型声明

Cython 的主要优势之一是能够声明变量、函数参数和返回值的类型。这使 Cython 编译器能够生成高度优化的 C 代码。例如:

# base.py
import time

def fib(n: int) -> int:
    if n <= 1:
        return n
    else:
        return fib(n - 2) + fib(n - 1)

t0 = time.time()
fib(32)
print(f"Time: {time.time() - t0}")

n 的类型和返回类型都被声明为 int。这使 Cython 编译器能够为该函数生成高度优化的 C 代码。

使用命令 shell python3.10 base.py 运行该脚本。该函数的运行时间约为 ~0.37893152236938477 秒。

现在,为了测试我们的 Cython 代码,将 base.py 中的脚本复制到一个名为 base_cython.pyx 的新文件中。

接下来,我们创建一个包含以下代码的 setup.py 文件:

from setuptools import setup
from Cython.Build import cythonize

setup(
    name='Base script',
    ext_modules=cythonize("base_cython.pyx"),
    zip_safe=False,
)

要进行构建,请运行 shell python setup.py build_ext --inplace。

启动一个 Python shell 并执行 shell from base import fib 来运行该脚本。运行时间约为 ~0.09149384498596191 秒。可以看到,与纯 Python 代码相比,使用 Cython 获得了约 ~4x 的性能提升。

使用 C 库

Cython 允许开发人员在 Python 代码中直接使用 C 库。这可以带来显著的性能提升,尤其是对于计算密集型任务。例如:

cdef extern from "math.h":
    double sin(double)

def compute_sine(double x):
    return sin(x)

这里直接在 Python 代码中使用了 math.h C 库中的 sin 函数。与 Python 的 math 库相比,这可以带来显著的性能提升。

内存管理

Cython 允许开发人员直接管理内存,这可以带来显著的性能提升。例如:

cdef int *arr = <int *>malloc(sizeof(int) * n)

for i in range(n):
    arr[i] = i

free(arr)

在上面的示例中,使用 C 标准库中的 malloc 函数分配内存。在不再需要这块内存后,使用 free 函数将其释放。

6. Mypyc

Mypyc 是一个面向 Python 的静态编译器,可以从 Python 模块生成 C 代码。它是一个可用于优化和加速 Python 代码的工具。Mypyc 将 Python 代码编译为机器码,因此比解释执行的 Python 代码更快。它兼容 Python 3.5 及以上版本。

要使用 mypyc,您需要安装 mypy(link to the docs)

由于 Mypyc 依赖静态类型,我们将把基准脚本改为一个带类型标注的简单斐波那契函数:

# base.py
import time

def fib(n: int) -> int:
    if n <= 1:
        return n
    else:
        return fib(n - 2) + fib(n - 1)

t0 = time.time()
fib(32)
print(f"Time: {time.time() - t0}")

使用命令 shell python3.10 base.py 运行该脚本。该函数的运行时间约为 ~0.37893152236938477 秒。

为了编译代码,我们首先检查代码中是否存在类型问题。

mypy base.py

接下来,我们将程序编译为二进制 C 扩展。

mypyc base.py

为了测试编译后的代码,我们使用命令 shell python3.10 -c "import base",运行时间约为 ~0.018268108367919922 秒。可以看到,与纯 Python 代码相比,使用 mypyc 获得了约 ~20x 的性能提升。

我们使用 python3.10 -c 将编译后的模块作为程序运行。

7. Pypy

Pypy 是 Python 语言的另一种实现。它是一个面向 Python 的即时(JIT)编译器,这意味着它会在运行时即时将 Python 代码编译为机器码。Pypy 兼容 Python 2.7 和 Python 3.6。

Pypy 以在 CPU 密集型任务上的出色性能而著称。它具备多项特性,使其成为优化 Python 代码的强大工具。例如,它使用一种名为“JIT 特化”(JIT specialization)的技术,为代码中频繁执行的部分生成优化的机器码。

您可以通过此链接安装 Pypy

让我们使用 Pypy 来运行斐波那契脚本:

pypy3 base.py

该脚本的运行时间约为 ~0.02586054801940918 秒,比使用 python3.10 运行(约 0.37893152236938477 秒)快 ~14.6x。

8. Mamba

Mamba 是一个 Python 包管理器,其设计目标是比默认的 Python 包管理器 pip 更快。它使用 conda 包格式,可以与 Python 的 Anaconda 发行版配合使用。能否感受到性能提升取决于具体的包,因为使用 Mamba 安装的某些包,其性能可能与使用 pip 安装的包相同。

Numpy 就是使用 Mamba 时经过优化的包之一。安装和配置方法请参阅文档。我们将基准脚本改为:

import numpy as np
import time

arr1 = np.random.rand(1000000)
arr2 = np.random.rand(1000000)

t0 = time.time()
result = np.dot(arr1, arr2)
print(f"Time taken using mamba: {time.time() - t0}")

pip 与 mamba 的性能对比:

使用 pip 安装 numpy,并按如下方式运行基准脚本:

# install
pip install numpy
# run
python3.10 base.py

该脚本的运行时间约为 ~0.004205226898193359 秒。

接下来使用 mamba 安装 numpy:

# install
mamba install numpy
# run
python3.10 base.py

这一次,该脚本的运行时间约为 ~0.0014407634735107422,比 pip 快 ~2.92x。

需要注意的是,如果您使用的包在 mamba 中没有经过高度优化,您只会看到非常小的性能提升。

9. 原生代码

原生代码是指由计算机处理器直接执行的机器码。原生代码通常由编译器生成,编译器会将高级编程语言翻译为机器码。原生代码比 Python 代码等解释执行的代码更快、更高效,因为它不需要在运行时进行翻译。

在 Python 中,可以使用 MyPyC 和 Cython 等工具生成原生代码,它们会将 Python 代码编译为原生代码。原生代码也可以使用 C 或 C++ 等其他语言生成,并通过扩展模块从 Python 中调用。

原生代码的一个优势是比解释执行的代码更快、更高效。原生代码由处理器直接执行,无需解释,因此执行速度更快。此外,原生代码还可以针对特定的处理器架构进行优化,从而进一步提升性能。

然而,原生代码的一个缺点是,与解释执行的代码相比,它通常更难编写和调试。原生代码需要更多的底层编程知识,而且由于由处理器直接执行,调试起来也可能更加困难。

10. Codon

Codon 是一个 Python 库,提供了一种在运行时生成和执行原生代码的简便方法。Codon 允许开发人员编写高级 Python 代码,并在运行时将其翻译为原生代码,这比解释执行的 Python 代码更快、更高效。

Codon 使用即时(JIT)编译来生成原生代码。JIT 编译是一种在运行时而非提前编译代码的技术。这使编译器能够结合具体的运行时环境生成优化的代码。

要使用 codon,您首先需要通过此链接进行安装。

我们可以使用以下命令运行斐波那契 base.py 脚本:

codon run -release base.py 

使用 codon,我们的脚本只用了约 ~0.00603247 秒,比使用 Python3.10(~0.32192111015319824 秒)快 ~52.48 倍

Codon 的一个优势是,它提供了一种无需 MyPyC 或 Cython 等额外工具即可生成原生代码的简便方法。Codon 还提供了一个高级 API,使开发人员可以用 Python 编写代码,而无需学习新的语言或语法。

然而,Codon 的一个缺点是,它可能并不适用于所有用例。Codon 最适合能够从 JIT 编译中受益的代码,例如数值计算或机器学习。此外,在某些用例中,Codon 的速度可能不及 MyPyC 或 Cython 等其他原生代码生成工具。

基准测试结果

工具 平均时间(秒) 基准脚本 性能
Python3.10 ~15.19 base.py(Async) -
Threads ~10.015 base.py(Async) ~1.52x
Multiprocessing ~9.257 base.py(Async) ~1.64x
Asyncio ~9.32 base.py(Async) ~1.63x
- - - -
Python3.11 ~0.21 base.py(Fibonacci) ~1.65x
Cython ~0.09 base.py(Fibonacci) ~3.99x
Mypyc ~0.018 base.py(Fibonacci) ~19.99x
Pypy ~0.025 base.py(Fibonacci) ~14.12x
Codon ~0.006 base.py(Fibonacci) ~52.48x
- - - -
PiP ~0.004205 base.py(Numpy) -
Mamba ~0.001440 base.py(Numpy) 相比 pip ~2.92x

总之,有多种策略和工具可用于优化 Python 代码的性能。原生代码和 Cython 可以显著加快计算密集型任务的速度,而多线程和多进程则能更好地利用系统资源。还可以使用 Mamba 包管理器安装针对科学计算进行优化的程序。 最终选择哪种方法/工具,取决于您的代码要实现的目标。

但需要牢记的是,不应以牺牲可读性和可维护性为代价来追求速度优化。即使这意味着放弃一些性能收益,作为开发人员,我们也应致力于编写清晰、简洁且易于理解的代码。

标签:

Python, Performance