Python 3.13 启动自由线程,性能会下降吗?

CPython 3.13 已经在两周前发布了,该版本是一段时间以来最注重性能的版本。我在快速阅读发行说明后,了解以下几点对性能的影响显而易见:

  • CPython 现在可以在自由线程模式下运行,并禁用全局解释器锁 (GIL)
  • 添加了全新的即时(JIT) 编译器
  • CPython 现在捆绑了mimalloc开箱即用的分配器

在本文中,我们将重点关注自由线程模式,了解如何利用这一变化以及它如何通过使用 CodSpeed 测量性能来影响 Python 应用程序的性能。

自由线程的 CPython

自由线程是 Python 3.13 中的一项实验性功能,允许 CPython 在没有全局解释器锁 (GIL) 的情况下运行。

GIL 是一个互斥锁,可防止多个线程同时执行 Python 字节码。这种设计选择简化了 CPython 的内存管理,并使 C API 更易于使用。然而,它也是有效利用现代多核处理器的最大障碍之一。

multiprocessing 解决方法

传统的解决方案是使用multiprocessing模块,它会生成单独的 Python 进程而不是线程,虽然这种方法有效,但它有很大的局限性:

内存开销:每个进程都需要自己的 Python 解释器实例和内存空间。对于数据密集型应用程序,这很快就会成为瓶颈。

通信成本:进程之间不能直接共享内存。数据在进程间传递时必须进行序列化和反序列化,这增加了开销和复杂性。

启动时间:创建新进程的速度明显慢于创建线程,因此对于需要频繁生成工作进程的任务来说,它并不实用。

现实世界的影响:PageRank 示例

为了说明这些限制,让我们来实践一个功能叫 PageRank,这是 Google 早期搜索引擎所采用的算法。

PageRank 是一个非常理想的安全,因为它有如下的特性:

  • 计算密集型(矩阵运算)
  • 适用于大型数据集(网络图)
  • 可以从并行化中获益匪浅

Python 3.12 或更早版本中的简单多线程实现在矩阵操作期间会因 GIL 而成为瓶颈,而多处理版本则会遇到以下问题:

  • 将图复制到每个进程的内存开销
  • 在流程之间转移部分结果的成本
  • 管理共享状态的复杂性

在我们继续之前,需要澄清的是,我们的重点不是 PageRank 算法本身的细节,而是并行化,因此我们不会在这里讨论算法本身的细节。

让我们看看如何使用这些不同的并发模型来实现这一点。

教科书案例实现(单线程)

def pagerank_single(matrix: np.ndarray, num_iterations: int) -> np.ndarray:
    """
    Single-threaded PageRank implementation
    """

    size = matrix.shape[0]
    # Initialize scores
    scores = np.ones(size) / size
    for _ in range(num_iterations):
        new_scores = np.zeros(size)
        for i in range(size):
            # Get nodes that point to current node
            incoming = np.where(matrix[:, i])[0]
            for j in incoming:
                # Add score contribution from incoming node
                new_scores[i] += scores[j] / np.sum(matrix[j])
        # Apply damping factor
        scores = (1 - DAMPING) / size + DAMPING * new_scoresx
    return scores

代码中突出显示的行,显示了算法中计算最密集的两个部分。第一部分计算来自传入节点的分数贡献,而第二部分应用阻尼因子,将新分数纳入最终结果。

对第一部分进行并行化将是最有益且最容易实现的,因为我们可以划分范围,从而有效地使用多个线程来计算数组new_scores。

多线程实现

对于多线程实现,我们首先将矩阵分成多个块:

chunk_size = size // num_threads

chunks = [(i, min(i + chunk_size, size)) for i in range(0, size, chunk_size)]

然后,每个线程将处理矩阵的不同块,更新new_scores数组:

def _thread_worker(
        matrix: np.ndarray,
        scores: np.ndarray,
        new_scores: np.ndarray,
        start_idx: int,
        end_idx: int,
        lock: threading.Lock,
):
    size = matrix.shape[0]
    local_scores = np.zeros(size)
    for i in range(start_idx, end_idx):
        incoming = np.where(matrix[:, i])[0]
        for j in incoming:
            local_scores[i] += scores[j] / np.sum(matrix[j])
    with lock:
        new_scores += local_scores

需要注意的是,数组的更新new_scores是在锁定后进行的,以防止出现竞争条件。如果锁定时间过长,这可能会成为瓶颈,但实际上,并行化算法的第一部分应该已经可以显著提高速度。

最后,我们将把这些代码块提供给每个线程:

new_scores = np.zeros(size)
lock = threading.Lock()
with concurrent.futures.ThreadPoolExecutor(max_workers=num_threads) as executor:
    # Process chunks in parallel
    futures = executor.map(
        lambda args: _thread_worker(*args),  # starmap isn't available on ThreadPoolExecutor
        [
            (matrix, scores, new_scores, start_idx, end_idx, lock)
            for start_idx, end_idx in chunks
        ],
    )
new_scores = (1 - DAMPING) / size + DAMPING * new_scores
scores = new_scores

多处理实现

本质上,multiprocessing实现方式与这个 threading进程处理极其相似,让我们关注一下不同之处:

由于进程无法直接共享内存,因此每个工作进程现在将返回local_scores数组,而不是更新共享new_scores数组。

然后,本地分数将在主进程中汇总:

# Combine results
new_scores = sum(chunk_results)

虽然这应该比线程版本更快,但它仍然会产生进程间通信的开销,这对于大型数据集来说可能非常显著。

它不使用ThreadPoolExecutor,而是使用multiprocessing.Pool。两个API 非常相似,但multiprocessing.Pool会生成进程池而不是线程池:

with multiprocessing.Pool(processes=num_processes) as pool:
    # Process chunks in parallel
    chunk_results = pool.starmap(_process_chunk, chunks)
    # Combine results
    new_scores = sum(chunk_results)
    new_scores = (1 - DAMPING) / size + DAMPING * new_scores
    scores = new_scores

衡量绩效

为了测量实际的性能变化,让我们构建一个性能测试。首先需要生成一些测试数据:

def create_test_graph(size: int) -> np.ndarray:
    # Fixed seed
    np.random.seed(0)
    # Create random adjacency matrix with ~5 outgoing edges per node
    matrix = np.random.choice([0, 1], size=(size, size), p=[1 - 5 / size, 5 / size])
    # Find nodes with no outgoing edges
    zero_outdegree = ~matrix.any(axis=1)
    zero_indices = np.where(zero_outdegree)[0]
    # For each node with no outgoing edges, add a random edge
    if len(zero_indices) > 0:
        random_targets = np.random.randint(0, size, size=len(zero_indices))
        matrix[zero_indices, random_targets] = 1
    return matrix

正如所强调的,我们使用固定种子来确保从一次运行到下一次运行的可重复性。

这个在比较不同实现的性能时很重要。在这里,我们在页面之间建立一些假连接来构建真实的图形,但只要大小相同,数学运算与空矩阵完全相同。

接下来,我们将使用 pytest-codspeed插件pytest 来测量具有各种参数和多个 CPython 构建/版本的不同实现的性能。

首先,让我们定义基准案例:

@pytest.mark.parametrize(
    "pagerank",
    [
        pagerank_single,
        partial(pagerank_multiprocess, num_processes=8),
        partial(pagerank_multithread, num_threads=8),
    ],
    ids=["single", "8-processes", "8-threads"],
)
@pytest.mark.parametrize(
    "graph",
    [
        create_test_graph(100),
        create_test_graph(1000),
        create_test_graph(2000),
    ],
    ids=["XS", "L", "XL"],
)
def test_pagerank(
        benchmark: BenchmarkFixture,
        pagerank: PagerankFunc,
        graph: np.ndarray,
):
    benchmark(pagerank, graph, num_iterations=10)

这里我们测试了 3 种具有 3 种不同图形大小的实现。该 benchmark装置由提供,pytest-codspeed并将测量具有pagerank给定参数的函数的执行时间。

然后,让我们编写一个 GitHub Actions 工作流程来测量 CodSpeed 基础架构上各种 CPython 版本的性能:

on:
  push:

jobs:
  codspeed:
    runs-on: codspeed-macro # requests a CodSpeed Macro runner for the jobs
    strategy:
      matrix:
        python-version: [ "3.12", "3.13" ]
        include:
          - { python-version: "3.13t", gil: "1" }
          - { python-version: "3.13t", gil: "0" }
    env:
      UV_PYTHON: ${{ matrix.python-version }}
    steps:
      - uses: actions/checkout@v4
      - name: Install uv
        uses: astral-sh/setup-uv@v3
      - name: Install CPython & dependencies
        run: uv sync --all-extras
      - name: Run benchmarks
        uses: CodSpeedHQ/action@v3
        env:
          PYTHON_GIL: ${{ matrix.gil }}
        with:
          run: uv run pytest --codspeed --codspeed-max-time 10 -vs src/tests.py

这里我们使用 Python 3.12、3.13 和支持自由线程 ( 3.13t) 的 3.13 版本运行基准测试,有 GIL 和无 GIL 版本均有。运行支持和不支持自由线程的 3.13 版本将使我们能够看到其对性能的影响,即使启用了 GIL 也是如此。

uv使用的 Python 版本直接从 python-build-standalone中提取, 使用GCC 6.3.0 20170516。

这些作业将在 CodSpeed Macro运行器上运行,它们是专用于每个作业的 ARM64 裸机实例,具有 16 个内核和 32GB 内存。

结果请见下图:

如果不启用新的构建选项,3.12和 3.13的性能非常相似。我们还清楚地看到,multiprocessing由于进程间通信的开销,实现速度甚至比单线程实现还要慢。

正如预期的那样,在运行没有 GIL 的 3.13tthreading时,基于的实现是最快的 ,并且 GIL 实际上不再限制线程的并行执行。

不过,在使用自由线程构建( 无论是否使用GIL)运行时,我们都会发现其他所有实现的速度都显著降低。这主要是因为自由线程构建需要禁用专门的自适应解释器,从而明显降低了其他实现的性能。在 3.14 版本中,这种开销应该会减少,因为专门的自适应解释器将是线程安全的,因此将重新启用。到那时,对于许多并行应用程序来说,迁移到自由线程构建应该是一件轻而易举的事,衡量性能变化将会很有趣。

对于所有其他图大小,结果非常相似,结论也相同。从这个测量结果中,我们可以看到 CPython 3.13 的新自由线程版本可以对并行应用程序的性能产生重大影响,为 带来了非常相关的替代方案multiprocessing。由于它引入了整体减速,因此它仍然是实验性的,尚未准备好用于生产,但这是朝着正确方向迈出的非常有希望的一步!

后记

该基准测试不包括子解释器,这是另一种无需使用 Python 3.12 中引入的 GIL 即可并行运行 Python 代码的方法。

事实证明,在大多数情况下,子解释器比其他方法慢,主要是因为数据共享和工作器间通信尚未完全解决。但是一旦有了子解释器,它绝对可能是一个不错的替代方案multiprocessing。

原文:

https://codspeed.io/blog/state-of-python-3-13-performance-free-threading

相关文章

python 锁Lock功能及多线程程序锁的使用和常见功能示例

锁(Lock)是Python中的一个同步原语,用于线程之间的互斥访问。它可以用来保护共享资源,确保在任意时刻只有一个线程可以访问共享资源,从而避免多线程并发访问引发的数据竞争和不一致性。下面分别详细说...

一文扫盲!Python 多线程的正确打开方式

一、多线程:程序世界的 "多面手"(一)啥是多线程?咱先打个比方,你去餐厅吃饭,一个服务员同时接待好几桌客人,每桌客人就是一个 "线程",服务员同时处理多桌事务就是 &...

python 多线程程序加锁、解锁、锁应用场景示例

锁(Lock)是Python中的一个同步原语,用于线程之间的互斥访问。它可以用来保护共享资源,确保在任意时刻只有一个线程可以访问共享资源,从而避免多线程并发访问引发的数据竞争和不一致性。下面分别详细说...

Python中的“锁”艺术:解锁Lock与RLock的秘密

Python中的“锁”艺术:解锁Lock与RLock的秘密引言随着计算机性能的不断提升以及多核处理器的普及,多线程编程已成为现代软件开发不可或缺的一部分。然而,当多个线程试图同时修改同一份数据时,就可...

24-2-Python多线程-线程操作(python多线程怎么用)

2-线程操作在Python程序中,可以通过“_thread”和“threading(推荐使用)”这两个模块来处理线程。在Python 3程序中,thread模块已废弃。可以使用 threading 模...

Python 如何通过 threading 模块实现多线程。

先熟悉下相关概念多线程是并发编程的一种方式,多线程在 CPU 密集型任务中无法充分利用多核性能,但在 I/O 操作(如文件读写、网络请求)等待期间,线程会释放 GIL,此时其他线程可以运行。GIL是P...