17611538698
webmaster@21cto.com

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

编程语言 0 145 2024-11-14 08:25:11

图片

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_scores
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 内存。

结果请见下图:

图片


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

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

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

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

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

后记

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

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

⏭️ JIT 和mimalloc性能将在下一篇文章中介绍。敬请大家期待!

作者:Alex
原文:
https://codspeed.io/blog/state-of-python-3-13-performance-free-threading

评论