Towards Data Science

Python 3.15 Has a New Profiler

6.9内容质量

TL;DR · AI 摘要

Python 3.15 Has a New Profiler Towards Data Science Programming Python 3.15 Has a New Profiler Here's what it finds that...

核心要点

  • 主题聚焦:Python 3.15 Has a New Profiler
  • 来源:Towards Data Science,建议结合原文判断细节。
  • AI 分析暂不可用,本条为保底评分与摘要。
#AI#编程#后端#安全#产品
打开原文

Python 3.15 新增性能分析工具 | Towards Data Science

编程

Python 3.15 新增性能分析工具

它能发现 cProfile 遗漏的问题

Thomas Reid

2026年10月24日

13分钟阅读

更喜欢在 Google 上查看我们

分享

图片由作者提供

Python 开发者长期以来一直可以使用代码性能分析工具。cProfile 随 Python 一起提供,记录每个函数调用并精确告知每个函数的运行次数。这使其适用于你能够控制执行的脚本和批处理任务。

在无法控制的场景中就不太方便。例如当 Web 服务已经在运行并开始出现异常行为时,你无法直接将 cProfile 附加到该进程来观察情况。在分析器下重启服务可能会让问题消失,而且跟踪开销本身可能改变你试图测量的行为。

Python 3.15 提供了另一个选择。新的 profiling.sampling 模块(称为 Tachyon)可以自行启动程序或附加到已运行的进程。它通过外部读取进程,因此应用无需导入分析库或重启。

Tachyon 还能检查每个线程,区分 CPU 计算与等待状态,显示持有 GIL 的代码位置,并标记原生扩展中的执行路径。当进程出现卡顿时,它可以打印每个线程的当前堆栈而无需进行完整分析。

在本文中,我们将针对一个小型 HTTP 服务测试每个功能。每个分析命令都有对应的接口端点,因此结果清晰可见。

Python 现在拥有两个内置分析工具

Python 3.15 将其分析工具归入新包:

markdown

code
profiling.sampling
profiling.tracing

profiling.tracing 是之前通过 cProfile 提供的确定性分析器。它记录每个函数调用、返回和异常。生成的调用次数统计是精确的,包括那些在几微秒内完成的函数。

profiling.sampling 会定期检查程序的调用堆栈。在多个采样中频繁出现的函数很可能占据了大量执行时间。

这两种方法回答了不同的问题。

code
+--------------------------------------+----------------------+-------------------------------+
| 能力                           | profiling.sampling   | profiling.tracing (cProfile)  |
+--------------------------------------+----------------------+-------------------------------+
| 精确的调用次数                    | 否                   | 是                           |
| 附加到正在运行的进程          | 是                  | 否                            |
| 分析所有线程                  | 是                  | 非自动             |
| 仅 CPU 的分析                   | 是                  | 否                            |
| GIL 分析                        | 是                  | 否                            |
| 原生代码和垃圾回收帧           | 是                  | 否                            |
| 火焰图和热力图            | 是                  | 否                            |
| 运行时开销                     | 非常低             | 更高                        |
+-----------------------------------+----------------------+----------------------------------+

采样不会记录每个事件,因此其时间是估算值。如果某个函数占用了 10% 的采样,它可能约占用了采样时间的 10%。不同运行结果可能会略有差异。

追踪是精确的,但性能分析器必须处理每个调用。这种额外的开销在调用大量小型 Python 函数的应用程序中可能会变得明显。

Python 官方文档建议在大多数调查中使用统计采样,而在需要精确调用次数时使用确定性追踪。现有的 cProfile 模块仍作为 profiling.tracing 的别名可用。旧版纯 Python 的 profile 模块已被弃用,预计在 Python 3.17 中将被移除。

设置开发环境并安装 Python 3.15

在编写任何代码之前,让我们先设置一个开发环境以保持项目隔离。我使用 uv 工具来实现这一点,但你可以使用你最熟悉的方法。

powershell

code
REM 创建新项目文件夹并切换到该目录
c:\> mkdir C:\py-profiler
c:\> cd /d C:\py-profiler
REM 更新 uv 工具
C:\python-profiler> uv self update
info: 检查更新...
success: 你已使用 uv 的最新版本 v0.12.5

我用于这些示例的版本是 Python 3.15.0rc1。这不是最终版本,但在这个阶段不太可能添加更多语言特性。Python 3.15.0 预计将在今年十月发布。

code
REM 安装 Python
c:\py-profiler> uv python install 3.15
已安装 Python 3.15.0rc1,耗时 3.23 秒
+ cpython-3.15.0rc1-windows-x86_64-none (python3.15.exe)
C:\python-profiler> uv venv --python 3.15
(py_profiler) C:\py-profiler> .venv\Scripts\activate
(py-profiler) C:\\py-profiler> python --version
Python 3.15.0rc1

当你阅读本文时,如果存在更新的 3.15 版本,请安装该版本。

在继续阅读文章其余部分时,请保持这个虚拟环境处于激活状态。从现在起,python 命令将指向 Python 3.15,而不会替换或影响你的正常 Python 安装。

创建一个可以进行性能分析的服务

该示例仅使用 Python 标准库。这可以避免在 Python 3.15 仍为预发布版本时,与第三方包可能出现的兼容性问题。

slow_server.py 在本地主机 8000 端口运行一个小型线程 HTTP 服务器,并打印其进程 ID 以便你附加性能分析器。它的两个端点故意创建了不同的性能问题:

该服务器有两种类型的工作(端点):

  • /report - 混合等待与 CPU 密集型 Python 代码。
  • /native - 在编译代码中执行长时间计算。

创建一个名为 slow_server.py 的文件,并将以下代码添加到其中:

python

python
code
import hashlib
import json
import math
import os
import time
from http.server import BaseHTTPRequestHandler
from http.server import ThreadingHTTPServer
from urllib.parse import urlparse
def fetch_reference_data() -> list[int]:
"""作为数据库查询或HTTP请求的替代方案。"""
time.sleep(0.04)
return list(range(1, 5_001))
def calculate_score(values: list[int]) -> float:
"""执行足够的Python操作以在CPU分析中清晰显示。"""
total = 0.0
for value in values:
for offset in range(100):
total += math.sqrt(value * value + offset)
return total
def build_report() -> dict:
values = fetch_reference_data()
score = calculate_score(values)
return {
"items": len(values),
"score": round(score, 2),
}
def run_native_hash() -> str:
"""在编译代码中执行长时间计算。"""
digest = hashlib.pbkdf2_hmac(
"sha256",
b"example-password",
b"example-salt",
2_000_000,
)
return digest.hex()
class ReportHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
path = urlparse(self.path).path
if path == "/report":
result = build_report()
elif path == "/native":
result = {"digest": run_native_hash()}
else:
self.send_error(404)
return
body = json.dumps(result).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, format: str, *args) -> None:
pass
if __name__ == "__main__":
address = ("127.0.0.1", 8000)
server = ThreadingHTTPServer(address, ReportHandler)
server.daemon_threads = True
print(f"Server running at http://{address[0]}:{address[1]}")
print(f"Process ID: {os.getpid()}", flush=True)
server.serve_forever()

启动服务器:

code
(py_profiler) c:\py-profiler> python slow_server.py

它会打印地址和进程ID,例如:

plaintext

code
Server running at http://127.0.0.1:8000
Process ID: 53432

向服务器生成流量

我们需要在性能分析工具收集样本时访问每个端点,因此在新终端中创建以下 generate_load.py 文件。

code
import sys
from concurrent.futures import ThreadPoolExecutor
from time import monotonic
from urllib.request import urlopen
HOST = "127.0.0.1:8000"
HOST = "http://" + HOST
def send_requests(url: str, deadline: float) -> int:
completed = 0
while monotonic() < deadline:
with urlopen(url, timeout=30) as response:
response.read()
completed += 1
return completed
def main() -> None:
path = sys.argv[1] if len(sys.argv) > 1 else "/report"
duration = int(sys.argv[2]) if len(sys.argv) > 2 else 30
workers = int(sys.argv[3]) if len(sys.argv) > 3 else 6
url = f"{HOST}{path}"
deadline = monotonic() + duration
with ThreadPoolExecutor(max_workers=workers) as executor:
futures = [
executor.submit(send_requests, url, deadline)
for _ in range(workers)
]
completed = sum(future.result() for future in futures)
print(f"Completed {completed} requests to {path}")
if __name__ == "__main__":
main()

第一个参数选择端点。其他两个参数设置持续时间和工作线程数。例如,以下命令会使用六个线程向服务器的 /report 端点发送30秒的流量:

code
(py_profiler) c:\py-profiler> python generate_load.py /report 30 6

在服务器仍在运行时,打开第二个终端,激活你的 Python 3.15 环境,并使用上述命令运行负载生成器。

在 slow_server 和负载生成器运行时,打开第三个终端,激活 Python 3.15 环境,并运行以下命令。

code
(py_profiler) c:\py-profiler> python -m profiling.sampling attach -a -d 20 --flamegraph -o report-wall.html 53432
在 20.00 秒内捕获了 20,000 个样本
采样率: 999.99 个样本/秒
错误率: 16.27
火焰图数据: 2 个根函数, 107241 个总样本, 292 个唯一字符串
火焰图已保存至: report-wall.html

-a 标志包含所有线程,-d 20 表示记录 20 秒,-o 用于命名输出文件。包含所有线程非常重要,因为 ThreadingHTTPServer 会在主线程之外处理请求。

服务器并非由分析器启动。Tachyon 连接到现有进程,收集 20 秒样本后断开连接。服务器会继续运行。

在浏览器中打开 report-wall.html。生成的文件是自包含的,因此不需要单独的查看器或网络连接。默认情况下会生成火焰图。我的火焰图如下所示。

框体不是按时间水平排列的。颜色比例使更热和更冷的帧更容易识别。

解读火焰图

在火焰图中,调用栈从上到下延伸。一个框体位于另一个框体下方,表示该函数被上方的函数调用。宽度是关键部分:框体越宽,表示该函数在样本中出现的频率越高,因此占用的执行时间越多。

在这里,重要的调用路径是:

code
ReportHandler.do_GET
└── build_report
├── calculate_score
└── fetch_reference_data

calculate_score 立即显眼,因为它是图中最宽的叶子节点。因此,大部分请求时间都花在了这个嵌套的 Python 计算中。

fetch_reference_data 也有一个显著的块。该函数包含 time.sleep(0.04),因此分析器仍然会记录这 40 毫秒,即使进程在等待而不是主动使用 CPU。这是因为分析基于墙钟时间。

右侧以 SelectSelector.select 结尾的组是服务器主线程在等待其他连接。这是正常现象,不需要优化。总体来看,火焰图显示 /report 大部分时间用于计算分数,在获取其模拟参考数据时有较小但可见的延迟。

区分等待时间与计算时间

第一个分析使用了 Tachyon 的默认墙钟模式。它记录线程在采样时正在执行的操作。这包括执行 Python 代码、睡眠、等待 I/O 和等待锁。

这很有用,因为用户体验的是经过的时间。即使 Python 进程在等待时消耗的 CPU 很少,缓慢的数据库查询仍然会使请求变慢。

在我们的服务器中,fetch_reference_data() 睡眠 40 毫秒以表示外部调用。calculate_score() 执行重复计算。两者都应出现在墙钟分析中。

现在收集一个仅包含 CPU 的分析。再次启动负载生成器:

code
(py_profiler) c:\py-profiler> python generate_load.py /report 30 6

当负载生成器运行时,运行:

code
(py_profiler) c:\py-profiler> python -m profiling.sampling attach -a -r 100 --mode cpu -d 20 --flamegraph -o report-cpu.html 53432

CPU 模式会丢弃线程在睡眠、阻塞或等待 I/O 时采集的样本。

打开 report-cpu.html 并与墙钟分析进行比较。

calculate_score() 现在以 94% 的 HotSpot 分数占据主导地位。fetch_reference_data() 消失了,因为其延迟来自 time.sleep(),而非 CPU 计算。

如果某个函数在时钟图中很宽泛但在 CPU 图中很小,很可能它在等待。重写其 Python 代码无法消除数据库延迟、网络往返或锁等待。

在两个性能分析图中都显著出现的函数正在消耗处理器时间。这时可以通过算法优化、使用本地库或进程级并行来改进。

查找持有 GIL 的代码

服务器通过多个线程处理请求,但 calculate_score() 仍然是 Python 代码。在标准 CPython 构建中,同一时间只能有一个线程执行 Python 字节码。

按之前方式运行负载生成器:

在运行期间,收集 GIL 分析报告:

code
(py_profiler) c:\py-profiler> python -m profiling.sampling attach -a --mode gil -d 20 --flamegraph -o report-gil.html 53432

GIL 模式仅在线程持有全局解释器锁时记录采样。睡眠线程和释放 GIL 的大部分本地操作不会在该视图中占据主导。

在生成的报告文件 report-gil.html 中搜索 calculate_score()。其嵌套循环执行 Python 字节码并持有 GIL。虽然可能有多个工作线程已准备好处理请求,但它们无法同时执行这些循环。

因此增加线程数量不会使该服务部分按线程数成比例扩展。可能的解决方案包括减少工作量、优化算法、将计算转移到进程池或使用可释放 GIL 的本地库。

查找 Python 之外的耗时

/native 接口执行着略有不同的操作。它故意使用高迭代次数调用 hashlib.pbkdf2_hmac(),将大部分工作转移到编译后的本地代码,而非 Python 本身。

这使其成为性能分析器的有用测试案例:该调用持续时间足够长,即使 Python 本身未执行大部分实际计算,也能在分析报告中清晰可见。

启动一个更小的负载测试:

code
(py_profiler) c:\py-profiler> python generate_load.py /native 30 2

在负载生成器仍在运行时,启用本地帧收集 CPU 分析报告:

code
(py_profiler) c:\py-profiler> python -m profiling.sampling attach -a --native --mode cpu -d 15 --flamegraph -o native.html 53432

打开 native.html 并搜索 run_native_hash。该函数应主导分析报告,因为 Tachyon 将 hashlib.pbkdf2_hmac() 的耗时归因于其 Python 调用方。使用 --native 参数后,报告中可能包含标记 Python 退出点的合成 <native> 帧。它们的位置因平台和 Python 构建版本不同,因此 <native> 帧可能不会直接出现在 run_native_hash 之下。

生成源码热力图

火焰图保留了调用层次结构。有时你已经知道哪个函数耗时严重,但需要定位具体代码行。这时热力图就能派上用场。

再次运行 /report 负载:

在负载生成器仍在运行时收集热力图:

code
(py_profiler) c:\py-profiler> python -m profiling.sampling attach -a -d 20 --heatmap -o report-heatmap 53432

输出是一个目录而非单个 HTML 文件。打开 report-heatmap/index.html,然后选择 slow_server.py。

页面可以显示自身时间或总时间。自身时间统计直接归属于某一行的采样数量。在本例中,它应使 calculate_score() 函数内部的计算过程更加突出。总时间还包括从某一行调用的函数执行的工作。切换到该视图后,build_report() 函数内部对 fetch_reference_data() 和 calculate_score() 的调用会变得更有参考价值,因为它们的总时间包含了这些函数内部执行的工作。

颜色从冷色调渐变到热色调。在所选视图中,热色调的行出现在更多采样中。顶部的数字显示了自身采样和累计采样的数量、被命中的行数以及该文件在完整性能分析中的占比。

如果热力图几乎为空,最简单的解释是服务器在分析器运行期间几乎没有执行任何操作。看到 server.serve_forever() 自行收集采样通常意味着进程只是在那里等待请求。

我会先检查时间设置。确保负载测试确实与分析器同时运行,然后重新捕获热力图。

使用 Tachyon 时需要注意输出目录。如果 report-heatmap 已经存在,Tachyon 不会覆盖它。新报告可能会出现在子目录中,例如 report-heatmap/heatmap_18472。

如果发生这种情况,请打开最新创建的 index.html 文件。我发现为每次运行指定一个全新的输出目录通常更简单。

cProfile 仍然有用之处

Tachyon 并不会使 cProfile 过时。

采样分析可能会遗漏那些在两次采样之间开始和结束的极短函数。假设解析器调用一个小型转换函数两百万次。单次调用持续时间很短,因此该函数可能在统计分析中并不突出。

确定性分析器会记录每一次调用。如果你需要验证重构是否将调用次数从两百万次减少到两万次,跟踪分析是更优选择。

Python 3.15 允许你通过新名称运行确定性分析器:

code
(py_profiler) c:\py-profiler> python -m profiling.tracing batch_job.py

熟悉的命令依然可用:

code
(py_profiler) c:\py-profiler> python -m cProfile batch_job.py

跟踪分析也适用于那些在采样分析器收集足够观测数据前就完成的短脚本。

不要将性能分析与基准测试混淆。分析器告诉你程序花费时间的位置,它不是判断一个实现是否比另一个更快的合适工具。

采样结果总会存在一些噪声,因此小差异未必具有实际意义。跟踪分析则相反:它记录所有内容,但额外的插桩可能会使 Python 代码的性能下降比原生代码更明显。

如果我想知道某个实现是否比另一个快 2%,我会停止分析,转而使用 timeit 或 pyperf 工具对两个版本进行测试。这种微小差异很容易被采样噪声掩盖。

结论

当需要精确的调用次数或想从头到尾分析一个短程序时,cProfile 仍然很有用,但它从未被设计为回答所有性能问题。

对于已经运行了一段时间后突然变慢的服务,它帮助不大。根据使用方式,它可能还会遗漏其他线程中的工作,而且跟踪每一个函数调用会在测量过程中改变程序的性能表现。

Python 3.15 的采样分析器能够处理这些情况。它可以检测当前正在运行的进程,展示线程的运行状态,并区分计算和等待状态。

现在,仅通过标准库中的 Python 3.15 即可在问题发生时直接检查正在运行的进程,无需事先对其进行重写或重启。

欲了解 Python 3.15 中性能分析的更多细节,请查阅官方文档:

https://docs.python.org/3.15/library/profiling.html#choosing-a-profiler