首页
/ CPython 确定性剖析入门:使用 profiling.tracing(cProfile 的新家)精确测量每一次函数调用

CPython 确定性剖析入门:使用 profiling.tracing(cProfile 的新家)精确测量每一次函数调用

2026-09-07 16:45:30作者:钟日瑜

profiling.tracing 是 CPython 自 3.15 起随新版 profiling 包引入的确定性(deterministic)剖析器,为保持生态兼容,它同时以经典的 cProfile 名字对外提供服务。本文以官方文档 profiling.tracing 参考 为骨架,结合其纯 Python 入口实现(Lib/profiling/tracing/)与底层 C 扩展 _lsprofModules/_lsprof.c),讲解确定性剖析原理、命令行与编程两种用法、Profile 对象完整 API,以及计时精度相关的已知局限,帮助你在开发与测试阶段精确定位热点函数与调用关系。


什么是确定性剖析(Deterministic Profiling)

确定性剖析会在程序执行期间捕获每一次函数调用、函数返回和异常事件,并测量相邻事件之间的精确时间间隔,从而得到关于程序行为的精确统计——而不是基于采样估算的近似值。它尤其适合回答下面几类问题:

  • 某个函数总共被调用了多少次?
  • 程序的完整调用图(call graph)长什么样?
  • 某个函数具体调用了哪些子函数?
  • 是否存在意料之外的多余函数调用?

调用次数统计可以帮助发现"异常计数"这类 bug,也可以定位适合内联展开的高频小函数;内部时间(internal/tottime)统计能暴露值得优化的"热点循环";累计时间(cumulative/cumtime)统计则有助于发现算法层面的低效。由于该剖析器正确处理了递归场景下的累计时间,还可以直接对比递归实现与迭代实现的耗时差异。

与统计剖析(Statistical Profiling)的对比

与之相对的 :ref:statistical profilingprofiling.sampling)是周期性采样调用栈来估算时间分布。两种方案的取舍如下:

维度 确定性剖析(profiling.tracing) 统计剖析(profiling.sampling)
事件记录 记录每一次调用/返回/异常 周期性采样调用栈
调用计数 精确数值 统计估算
开销 每个事件都有插桩开销,程序会变慢 低开销,适合线上生产环境
适用场景 开发与测试阶段 生产环境低扰动观测

为什么 Python 适合确定性剖析

Python 的解释器在执行函数调用、返回时本身就会分发事件,剖析器只需钩入这套既有机制即可,无需修改被测代码。由于插桩开销相对解释执行本身的开销较为温和,确定性剖析可以满足大多数日常开发工作流。

版本提示:本模块及整个 profiling 顶层包为 3.15 新增(.. versionadded:: 3.15),模块介绍见 profiling 总览。同时,旧的纯 Python 剖析器 profile 已被标记为 deprecated。


cProfile 名字:向后兼容的别名

为了让既有代码无需改动即可迁移,本模块同时以两个名字暴露:

# 推荐(新风格)
import profiling.tracing
profiling.tracing.run('my_function()')

# 同样可用(向后兼容)
import cProfile
cProfile.run('my_function()')

官方承诺 cProfile 这个名字在未来所有 Python 版本中都会继续工作,具体采用哪种 import 风格取决于你的代码库。注意 cProfile 文档入口位于 Doc/library/profile.rst(该文档同时承载 pstats 与旧 profile 模块的说明),而新文档入口在 Doc/library/profiling.tracing.rst,两处 :module: 定义相互关联。


命令行接口

与模块同名的命令脚本可以把另一个脚本或模块纳入剖析:

python -m profiling.tracing [-o output_file] [-s sort_order] (-m module | script.py)

运行后剖析结果打印到标准输出(或按选项保存到文件)。该入口由 Lib/profiling/tracing/main.py 转调模块内的 main(),其参数解析逻辑位于 Lib/profiling/tracing/init.pymain() 函数(使用 optparse.OptionParser)。

参数说明

选项 含义 补充说明
-o <output_file> 把剖析结果写入文件而非标准输出 文件可被 pstats 模块 读入做后续分析。实现会在启动时用 os.path.abspath 固化输出路径,防止被测脚本 chdir 造成写错位置(见 __init__.pymain() 的注释)
-s <sort_order> 指定输出排序键 接受 pstats.Stats.sort_stats 认识的任意键,如 cumulativetimecallsname仅在未指定 -o 时生效。命令行实现里用 choices=sorted(pstats.Stats.sort_arg_dict_default) 做合法值校验,默认值为 2
-m <module> 剖析一个模块而非脚本 模块通过标准导入机制定位;通过 runpy.run_module(modname, run_name='__main__') 执行。cProfile-m 自 3.7 起支持,profile-m 自 3.8 起支持

脚本模式与模块模式的内部差异

从源码看(Lib/profiling/tracing/init.py),-m 模式直接构造 run_module 调用;脚本模式则先读取并 compile 脚本文件,再通过 importlib.machinery.ModuleSpec 手工构造名为 __main__ 的模块对象并替换 sys.modules['__main__']。这样被测代码内 import __main__ 会拿到同一命名空间,且全局变量更新能同步回模块 __dict__。代码还显式把 __spec__ 置为 None,让被测程序表现得像被直接执行的原生脚本(对应 CPython 问题 gh-140729)。命令行出口还对 BrokenPipeError 做了专门处理,避免解释器关停阶段出现 "Exception ignored"。


编程式用法示例

需要更细粒度控制时,可直接调用模块函数与类。

最简路径:run()

import profiling.tracing

# 剖析一段代码字符串,结果打印到标准输出
profiling.tracing.run('my_function()')

# 保存结果供后续分析(pstats 可读)
profiling.tracing.run('my_function()', 'output.prof')

剖析内部通过 exec__main__ 模块命名空间中执行命令(等价于 exec(command, __main__.__dict__, __main__.__dict__))。实现上,runrunctx 都委托给 _utils.py 中的 _Utils 辅助类:先创建 Profile 实例执行语句,期间捕获并吞掉 SystemExit,最后在 finally 中按有无 filename 决定 dump_statsprint_stats(见 Lib/profiling/tracing/_utils.py)。

使用 Profile 类做细粒度控制

import profiling.tracing
import pstats
from io import StringIO

pr = profiling.tracing.Profile()
pr.enable()
# ... 需要剖析的代码 ...
pr.disable()

# 打印结果
s = StringIO()
ps = pstats.Stats(pr, stream=s).sort_stats(pstats.SortKey.CUMULATIVE)
ps.print_stats()
print(s.getvalue())

Profile 同时也是上下文管理器(3.8 加入),上面的 enable/disable 可写成:

import profiling.tracing

with profiling.tracing.Profile() as pr:
    # ... 需要剖析的代码 ...

pr.print_stats()

对应实现见 Profile.__enter__/__exit__(见 Lib/profiling/tracing/init.py)。


模块参考(API 详解)

顶层函数

函数签名 行为说明
run(command, filename=None, sort=-1) exec__main__ 命名空间执行 command 并剖析。未给 filename 时创建 pstats.Stats 实例把摘要打印到标准输出;给了 filename 则把原始剖析数据以 marshal 格式落盘供 pstats 后续分析。sort 指定打印排序,接受任何 pstats.Stats.sort_stats 认识的取值
runctx(command, globals, locals, filename=None, sort=-1) run 类似,但按给定的 globals/locals 映射执行:exec(command, globals, locals),便于隔离命名空间剖析

Profile

Profile(timer=None, timeunit=0.0, subcalls=True, builtins=True)

剖析器对象,负责收集执行统计。构造参数:

  • timer:自定义计时函数。缺省时使用平台合适的内置默认计时器。若提供自定义计时器,它必须返回一个表示当前时间的数值;
  • timeunit:当计时器返回整数时,用它指定一个时间单位对应的秒数(例如毫秒级计时传 0.001)。文档默认值写为 0.0,源码 docstring 中为 timeunit=None,语义上仅在自定义整数计时器场景下生效;
  • subcalls:是否跟踪函数之间的调用关系(子调用);
  • builtins:是否剖析内建函数。

从源码看,Profile 几乎不重复实现剖析逻辑,而是继承自 C 扩展 _lsprof.Profiler,只额外补充便捷且向后兼容的方法(见 Lib/profiling/tracing/init.py)。_lsprofProfilerType 定义了 _pystart_callback_pyreturn_callback_pythrow_callback_ccall_callback_creturn_callback 等回调,分别对应 Python 函数调用/返回/异常与 C 调用/返回事件(见 Modules/_lsprof.c),印证了文档中"钩入解释器既有事件机制"的描述。

实例方法:

方法 作用
enable() 开始收集剖析数据
disable() 停止收集剖析数据
create_stats() 停止收集数据并把当前结果内部记录为"当前 profile"(实现为 disable() + snapshot_stats()
print_stats(sort=-1) 由当前 profile 创建 pstats.Stats 并打印到标准输出。sort 接受单个键或键的元组做多级排序,取值与 pstats.Stats.sort_stats 一致(多键元组支持于 3.13 加入)。实现中额外做了 strip_dirs() 并展开为 sort_stats(*sort)(见 Lib/profiling/tracing/init.py
dump_stats(filename) 把当前 profile 数据写入文件,可供 pstats.Stats 读取。实现用 marshal.dump(self.stats, f) 写入统计字典(见 Lib/profiling/tracing/init.py
run(cmd) 通过 exec 剖析命令字符串(复用 __main__ 命名空间)
runctx(cmd, globals, locals) 用指定的 globals/locals 命名空间通过 exec 剖析命令字符串
runcall(func, /, *args, **kwargs) 剖析一次函数调用并返回其返回值。使用位置限定参数语法
result = pr.runcall(my_function, arg1, arg2, keyword=value)

重要限制:剖析要求被测代码能够正常返回。若剖析期间解释器被终止(例如调用 sys.exit()),将不会有任何结果产出。

统计数据的内部形态

snapshot_stats()(见 Lib/profiling/tracing/init.py)展示了剖析数据如何在 Python 层组织:对每个代码对象条目构造 stats[func] = (cc, nc, tt, ct, callers),其中 nc 对应 pstats 的 ncalls 列(/ 前),cc = nc - reccallcount 是剔除递归后的非递归次数(/ 后),tt = inlinetime 即 tottime,ct = totaltime 即 cumtime;随后再遍历子调用条目把 caller/callee 关系聚合进 callers 字典。函数标签 label(code)str 类型的代码对象(内建函数)返回 ('~', 0, code)——'~' 保证其排序时排到最后(见 Lib/profiling/tracing/init.py)。


使用自定义计时器

Profile 构造函数接受自定义计时函数,从而度量执行的不同侧面(墙钟时间或 CPU 时间等)。只需把计时函数传给构造函数:

pr = profiling.tracing.Profile(my_timer_function)

计时函数必须返回表示当前时间的单一数值;若返回整数,还需给出 timeunit 说明每个整数单位对应的时长:

# 计时器以毫秒为单位返回时间
pr = profiling.tracing.Profile(my_ms_timer, 0.001)

为了最佳性能,计时函数应尽可能快——剖析器会非常频繁地调用它,计时开销会直接叠加到剖析开销上。

time 模块提供了若干适合做自定义计时器的函数:

  • time.perf_counter()——高分辨率墙钟时间;
  • time.process_time()——CPU 时间(不含睡眠);
  • time.monotonic()——单调时钟时间。

已知局限

确定性剖析在计时精度上存在固有局限:

  • 计时器分辨率:底层计时器分辨率通常约 1 毫秒,单次测量精度不可能超过该分辨率。好在测量样本足够多时误差会相互抵消,但个别测量值仍可能不精确;
  • 捕获延迟:事件发生与剖析器读取时间戳之间存在延迟,读取时间戳之后到用户代码恢复执行也存在延迟。被高频调用的函数会不断累积这类延迟,使其看起来比实际更慢——单次误差通常小于一个时钟滴答,但对调用次数极多的函数会变得显著。

由于本模块(及 cProfile 别名)是低开销的 C 扩展实现(即 _lsprof,见 Modules/_lsprof.c),上述计时问题比已废弃的纯 Python profile 模块轻微得多。


相关资源导航

在 CPython 仓库中可继续深入:

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
915
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388