Python3.13启动自由线程,性能会下降吗?

程序员咋不秃头 2024-11-15 04:31:57

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_threadschunks = [(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_scoresscores = new_scores

多处理实现

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

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

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

# Combine resultsnew_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 内存。

结果请见下图:

L 图大小的时间(以秒为单位),越少越好

如果不启用新的构建选项,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

0 阅读:0