使用 pstats 分析 Python 剖析统计:Stats、排序键、过滤器与交互式命令行完全指南
【免费下载链接】cpythonThe Python programming language项目地址: https://gitcode.com/GitHub_Trending/cp/cpython
导读:
pstats是 CPython 标准库中专门用于读取、合并、排序、过滤与展示剖析(profiling)结果的模块。它既能解析确定性追踪剖析器(profiling.tracing,兼容cProfile)的输出,也支持统计采样剖析器(profiling.sampling)生成的数据。读完本文,你将掌握pstats.Stats的全部核心 API、14 种排序键及其选择策略、三类过滤限制的精确用法、多份剖析文件的合并技巧,以及python -m pstats交互式浏览器的每一个命令。
本文以仓库中的官方文档 Doc/library/pstats.rst 为主体,结合其完整实现 Lib/pstats.py(共 840 行)与单元测试 Lib/test/test_pstats.py 展开源码级讲解。
pstats 在 Python 性能剖析体系中的位置
现代 CPython 中,性能剖析工具被组织为一个完整流水线:先由剖析器采集数据写入文件,再由 pstats 负责后续所有"读、算、排、筛、印"环节。这一点可以从本仓库的结构看出:
- 采集端:确定性(追踪式)剖析器位于
profiling.tracing子包,底层由 C 扩展_lsprof驱动;统计采样式剖析器位于profiling.sampling子包。两者介绍见 Doc/library/profiling.rst、Doc/library/profiling.tracing.rst 与 Doc/library/profiling.sampling.rst。 - 分析端:即本文主角
pstats,实现文件为 Lib/pstats.py,模块公开接口为Stats、SortKey、FunctionProfile、StatsProfile(见 Lib/pstats.py 的__all__)。
官方文档在"参见(see also)"一节中把 pstats 定义为三条配套文档的公共下游:profiling提供整体概览,profiling.tracing是确定性追踪剖析器,profiling.sampling是统计采样剖析器。也就是说,无论你用哪一端产出数据文件,都可以用同一个pstats工具链完成分析。
需要特别说明的是:剖析数据文件格式只对生成它的 Python 版本有效。官方文档明确写道,不同 Python 版本之间、不同剖析器之间不存在格式兼容性保证。原因可以从实现找到——Stats.load_stats和dump_stats直接使用marshal序列化统计字典(Lib/pstats.py),而marshal格式本身就与解释器版本绑定。
快速上手:加载文件并打印报告
最基本的用法只需两行:
import pstats p = pstats.Stats('profile_output.prof') p.print_stats()如果想直接看累计耗时最高(cumulative time)的前 10 个函数——这是性能分析最高频的诉求之一——则先排序再截取:
from pstats import SortKey p = pstats.Stats('profile_output.prof') p.sort_stats(SortKey.CUMULATIVE).print_stats(10)print_stats(10)中的10是"限制(restriction)",含义是只输出排序后前 10 条。Stats对象的设计天然支持方法链,例如官方文档给出的惯用法:
p = pstats.Stats('restats') p.strip_dirs().sort_stats(-1).print_stats()这里strip_dirs()去除文件名中的目录前缀让输出更紧凑,sort_stats(-1)是旧式数值参数,等价于'stdname'(详见下文排序一节)。所有修改方法都返回self,这正是 Lib/pstats.py 类注释中示例链式调用得以成立的原因。
Stats 类的完整 API 剖析
构造:Stats(*filenames_or_profile, stream=sys.stdout)
Stats的构造参数可以是文件名(字符串或 path-like 对象),也可以是剖析器对象(如profiling.tracing.Profile的实例)。同时给出多个来源时,它们的统计会被自动合并。
构造逻辑对应源码 Lib/pstats.py:第一个参数交给init()加载,其余参数全部交给add()追加合并。init()内部会完成全量统计(total_calls总调用数、prim_calls原始调用数、total_tt总耗时)与顶层函数识别,并维护用于对齐输出的最长函数名字节数max_name_len。
stream参数决定print_stats及其同类方法把报告写到哪个输出流,默认为sys.stdout。这在测试与嵌入式报告中非常有用:单元测试 Lib/test/test_pstats.py 就用StringIO()充当流来静默接收全部输出。你也可以用同样的手法把报告捕获进字符串再做后续处理。
在 Lib/pstats.py 的load_stats中可以看到一个重要的自动分派逻辑:如果文件内容里含('__sampled__',)标记,说明这是统计采样剖析器生成的数据,pstats会弹出该标记并把对象类切换为SampledStats(采样统计专用子类),从而让表头、排序键和含义整体切换为"样本(sample)"语义。这印证了文档所述"pstats 同时支持两类剖析器输出"。
strip_dirs():压缩文件名
strip_dirs()把所有文件名中前导目录信息去掉(保留basename)。它是就地修改并返回self以便链式调用。源码 Lib/pstats.py 展示了两个值得注意的副作用:
- 目录被剥离后,函数之间的调用者/被调用者关系会一并重算(调用者的文件名同样被 strip,
func_strip_path使用os.path.basename)。 - 若不同目录下出现"同名文件、同行号、同函数名"的两个条目,它们会通过
add_func_stats被合并为同一条统计。 - 文档特别提示:执行完
strip_dirs()后,若尚未sort_stats,统计数据处于"随机顺序"状态——源码中strip_dirs()会把fcn_list置None,而打印时的排序列表正是由sort_stats()生成的fcn_list。
add(*filenames):增量合并剖析数据
add()可以从更多文件追加剖析数据,官方文档指出这些文件必须由同一类剖析器产生;来自相同函数(文件、行号、函数名三者一致)的统计会被累加。实现上add()(Lib/pstats.py)不仅逐条目调用add_func_stats求和(cc, nc, tt, ct)四元组,还会用add_callers把双方的调用者字典逐项相加(元组格式逐位相加、旧式计数格式直接加和),同时累计总调用数与总耗时。
dump_stats(filename):把当前统计写回磁盘
dump_stats将当前self.stats字典以marshal.dump序列化保存(Lib/pstats.py):文件不存在则创建,已存在则覆盖。保存结果可以用Stats(filename)重新载入。测试 Lib/test/test_pstats.py 验证了"dump 后再 load 得到的 stats 字典与原对象完全相等"。这一能力用于剖析阶段的"采集与分析分离":线上采集文件,事后离线分析。
sort_stats(*keys):排序与全部排序键
sort_stats接受一个或多个键,每个键可以是字符串,也可以是SortKey枚举成员;传入多个键时,靠后的键用于打破靠前键的平局(次排序键)。官方文档建议优先使用SortKey枚举,因为相比字符串它提供更好的错误检查(编译器/编辑器层面即可拦截拼写错误)。
完整排序键对照表(官方文档原表,逐项继承):
| 字符串写法 | 枚举成员 | 含义 |
|---|---|---|
'calls' | SortKey.CALLS | 调用次数 |
'cumulative' | SortKey.CUMULATIVE | 累计时间(含子调用) |
'cumtime' | 无 | 累计时间(同上) |
'file' | 无 | 文件名 |
'filename' | SortKey.FILENAME | 文件名 |
'module' | 无 | 文件名(同上) |
'ncalls' | 无 | 调用次数(同上) |
'pcalls' | SortKey.PCALLS | 原始(非递归)调用次数 |
'line' | SortKey.LINE | 行号 |
'name' | SortKey.NAME | 函数名 |
'nfl' | SortKey.NFL | 函数名/文件名/行号 |
'stdname' | SortKey.STDNAME | 标准名 |
'time' | SortKey.TIME | 内部时间(不含子调用) |
'tottime' | 无 | 内部时间(同上) |
源码层面的对照关系清晰可见:枚举定义位于 Lib/pstats.py,其中多个枚举成员同时注册了别名值(如CALLS同时对应'calls'与'ncalls',TIME对应'time'与'tottime',CUMULATIVE对应'cumulative'与'cumtime',FILENAME对应'filename'与'module'),因此表格中那些"N/A"的字符串与对应枚举是等价键。
排序方向规则:所有基于耗时的排序都是降序(耗时最长的排最前),而基于名称、文件、行号的排序是升序(字母序)。这一规则由 Lib/pstats.py 的sort_arg_dict_default中每个键的方向标记(-1降序 /1升序)决定,最终由TupleComp.compare(Lib/pstats.py)执行多级比较。
关于NFL与STDNAME的差异:两者都按"名称→文件→行号"排序,但NFL把行号当作数值比较,而STDNAME按整个标准名字符串"文件:行号(函数名)"做字符串比较。此外,sort_stats(SortKey.NFL)与sort_stats(SortKey.NAME, SortKey.FILENAME, SortKey.LINE)完全等价。
兼容旧版 profile 的数值参数:为保证向后兼容,-1、0、1、2四个整数仍被接受,分别对应'stdname'、'calls'、'time'、'cumulative'。该映射直接写在 Lib/pstats.py,并由测试 Lib/test/test_pstats.py 逐项验证。
唯一的缩写前缀自动补全:get_sort_arg_defs()(Lib/pstats.py)会把每个合法键按"逐字缩短前缀"展开注册(如'c'→'calls'、'f'→'filename'),前提是该前缀不会产生歧义;若有歧义则整段前缀被剔除。这解释了交互式浏览器里sort命令支持"唯一前缀"的机制。注意:即使只用字符串键,sort_stats也会校验键的合法性与参数类型一致性——混用字符串与枚举(如'calls'与SortKey.TIME)会抛出TypeError,这一约束被测试 Lib/test/test_pstats.py 明确覆盖。
reverse_order():反转排序方向
reverse_order()就地反转当前排序方向并返回self。默认方向已按排序键自动选择(时间类降序、名称类升序),此方法用于产生相反视图。实现位于 Lib/pstats.py:直接把fcn_list反转。
print_stats(*restrictions):打印统计报告
print_stats输出报告。从源码(Lib/pstats.py)可以看到报告头部由以下几行组成:
- 数据来源文件名(含文件修改时间)列表;
- 被判定为"顶层入口"的函数名列表;
- 汇总行:
总函数调用数 in 总耗时秒,当总调用数与原始(非递归)调用数不等时还会额外标注(N primitive calls); - 随后是按最后一次
sort_stats排序的函数统计表。
表体标题行固定为(见print_title,Lib/pstats.py):
ncalls tottime percall cumtime percall filename:lineno(function)各列含义与剖析内部模型一一对应。Stats内部每条记录是五元组(cc, nc, tt, ct, callers):cc为原始(非递归)调用次数,nc为含递归的全部调用次数,tt为函数自身内部耗时(inlinetime),ct为含子调用的累计耗时(totaltime)。当nc != cc时,ncalls列显示为nc/cc的形式(见print_line,Lib/pstats.py)。该五元组结构在追踪剖析器端由 Lib/profiling/tracing/init.py 的snapshot_stats组装,cc = nc - reccallcount正是对递归调用的剔除。
限制(restriction)的三种类型(由 Lib/pstats.py 的eval_print_amount实现):
| 限制类型 | 行为 | 说明 |
|---|---|---|
整数int | 只输出前 N 条 | 例如print_stats(10)输出前 10 条 |
浮点数0.0 ≤ x < 1.0 | 输出前 x% 条 | 例如print_stats(.1)输出前 10% |
| 字符串 | 正则表达式过滤 | 对函数"标准名"执行regex.search,命中才保留 |
多个限制按顺序依次施加。官方文档示例:
# 先截取前 10%,再过滤出名字里含 "init" 的函数 p.print_stats(.1, 'init') # 先按文件名排序,再匹配含 "foo:" 的文件,最后截取前 50% p.sort_stats(SortKey.FILENAME).print_stats('foo:', .5)关于字符串匹配的底层细节值得注意:eval_print_amount是把正则表达式作用于func_std_string(func)返回的完整标准名(形如文件:行号(函数名),内置函数形如{...}),而不仅是函数名本身。因此'init'这类子串可以命中路径或模块名中的任意一段。若正则不合法,报告会附加<Invalid regular expression ...>提示而不会崩溃。
print_callers(*restrictions)与print_callees(*restrictions):调用关系视图
print_callers展示"每个被展示函数是被谁调用的";print_callees是它的逆视图,展示"每个被展示函数调用了谁"。两者接受与print_stats完全相同的限制参数。
调用者输出时表头为Function was called by...,被调用者视图表头为Function called...(见 Lib/pstats.py)。对profiling.tracing(即cProfile兼容路径)生成的数据,每个调用者行会给出三个数字:该调用者发起的调用次数、这批调用自身的 tottime 与 cumtime。当ncalls与原始调用数不同时会以nc/cc显示。这三种数字仅对"新式"调用者格式(元组类型)存在——print_call_heading会检测首个调用者值是否为元组来决定是否打印子表头ncalls tottime cumtime(Lib/pstats.py)。
从实现角度,调用者关系直接取自每条记录的callers字典;print_callees需要先把所有记录的 callers 关系"反查"并缓存为all_callees,这一倒排计算由calc_callees(Lib/pstats.py)惰性完成(首次调用才计算,之后复用)。
get_stats_profile():程序化访问统计
get_stats_profile()(自 Python 3.9 加入,见 Lib/pstats.py)返回一个StatsProfile对象,供**程序化(非文本)**方式消费剖析数据,典型用途是构建自定义报表或接入可视化前端。
两个新增公开数据结构定义在同文件顶部:
StatsProfile:包含total_tt(总内部耗时)与func_profiles——一个"函数名 →FunctionProfile"的字典(Lib/pstats.py);FunctionProfile:一个@dataclass,字段为ncalls、tottime、percall_tottime、cumtime、percall_cumtime、file_name、line_number(Lib/pstats.py)。
注意ncalls字段在存在递归时是形如"120/100"的字符串(nc/cc),而percall时间在调用次数为 0 时取-1作哨兵值;所有时间值都经f8(保留 3 位小数)舍入成浮点数。若当前没有可用的函数列表则返回空的StatsProfile(0, {})。单元测试 Lib/test/test_pstats.py 演示了标准用法:用cProfile.Profile记录三个空函数调用,再断言func_profiles中确实包含pass1/pass2/pass3。
不同剖析器数据的输出差异:SampledStats
pstats对统计采样剖析器数据做了专门适配。当读入带('__sampled__',)标记的文件后,实例会自动切换为SampledStats子类(Lib/pstats.py),此时:
- 排序键体系替换为采样语义:
samples/nsamples(样本计数)、psamples、以及同样可用的cumtime/filename/line/name/nfl/stdname/time等; - 表头改为
nsamples tottime persample cumtime persample; - 调用者子表头也相应变为
nsamples tottime cumtime。
也就是说,同一个Stats打印管线在检测到采样数据后自动切换了"量纲",阅读报告时不会再出现把采样数误当调用次数的混淆。
排序与过滤的实际选择建议
综合排序键语义,可归纳出针对不同问题的选键策略(均可直接复制运行):
from pstats import SortKey # 想找"谁最该优化":看累计耗时,找出自身+子孙调用最耗时的函数 p.sort_stats(SortKey.CUMULATIVE).print_stats(20) # 想找"某个函数自身太慢":看内部时间(排除被它调用的子函数) p.sort_stats(SortKey.TIME).print_stats(20) # 想看哪些函数被调用最频繁:按调用次数 p.sort_stats(SortKey.CALLS).print_stats(20) # 想定位递归开销:按原始(非递归)调用次数 p.sort_stats(SortKey.PCALLS).print_stats(20) # 想按代码位置浏览:按函数名(名称排序为升序) p.sort_stats(SortKey.NAME).print_stats()当两个函数的首要指标并列时,可追加次键打破平局,例如sort_stats(SortKey.CUMULATIVE, SortKey.TIME)让累计耗时相同的函数再按自身耗时细分。想"从另一头看"则可再接reverse_order()。
合并多份剖析数据:聚合多次运行的统计
性能分析常需跨多次运行聚合,例如对同一基准重复执行若干次以平滑噪声。官方文档给出的两种等价做法:
# 方式一:构造时一次性加载 p = pstats.Stats('run1.prof', 'run2.prof', 'run3.prof') # 方式二:先建对象,再增量 add p = pstats.Stats('run1.prof') p.add('run2.prof') p.add('run3.prof')合并时相同函数(文件、行号、函数名一致)的统计会被累加,形成跨多次剖析的聚合视图;各文件的来源路径也会记录在self.files中,打印报告时头部会逐一列出。合并后调用者字典按 Lib/pstats.py 的add_func_stats/add_callers完成逐位累加,这正是 Lib/test/test_pstats.pyAddCallersTestCase所断言的行为。命令行浏览器同样支持一次打开多个文件(首文件作为初始数据,其余自动add,见 Lib/pstats.py)。
需要再次提醒:仅当这些文件由同一类剖析器生成时,合并才有意义——混合合并不同剖析器格式的数据会因内部结构不一致而产生误导性结果。
命令行交互式界面:python -m pstats
pstats可以作为脚本运行,进入一个基于cmd模块的行交互式浏览器:
python -m pstats profile_output.prof启动后提示符为%,浏览器会打印欢迎语并进入命令循环。键入help可随时查看全部命令帮助。核心实现是主程序内的ProfileBrowser(cmd.Cmd)类(Lib/pstats.py),并且会尝试导入readline以获得行编辑与历史记录支持。
命令速查表(命令名与其调用的Stats方法一一对应):
| 命令 | 等价操作 | 说明 |
|---|---|---|
stats | print_stats | 打印统计报告 |
callers | print_callers | 打印每个函数的调用者 |
callees | print_callees | 打印每个函数的被调用者 |
sort | sort_stats | 按给定键排序;不带参数时列出全部合法键 |
strip | strip_dirs | 去除文件名中的目录 |
reverse | reverse_order | 反转排序方向 |
add <file> | add | 向当前对象追加另一份剖析文件 |
read [file] | 重新加载 | 读取(或重新读取)剖析文件;无参数时重载当前文件 |
quit/ EOF | 退出 | 结束浏览器(Ctrl-C 亦可中断) |
stats、callers、callees三个命令的参数与Stats方法中的限制完全一致——整数、[0,1]内小数、正则字符串三种均可混用,浏览器会在内部按"整数→浮点→字符串"的顺序解析每个词元(见ProfileBrowser.generic,Lib/pstats.py),并会拒绝超出[0,1]的小数限制参数。例如在提示符下输入:
% stats .1 init # 输出前 10% 中名字含 init 的函数 % sort cumulative # 切换到累计时间排序 % stats 20 # 打印前 20 条 % callers .5 # 打印前 50% 条目的调用者sort命令支持与库接口一致的唯一前缀缩写,并带 tab 补全(complete_sort会按已输入文本从全部合法键中筛选,Lib/pstats.py);直接输入sort(不带参数)则打印全部合法键及其含义。add遇到加载失败(如文件不存在)会打印Failed to load statistics for ...而不会退出浏览器。
结合源码印证:pstats 的完整调用链
把文档描述与源码对应起来,可以完整还原一条"采集 → 落盘 → 分析"链路,便于读者在仓库中继续追溯:
- 数据产生:追踪剖析器执行
run/runctx/Profile并(可选)通过dump_stats或run(..., filename)输出.prof文件;文件内容即一份经marshal序列化、以函数五元组为值的stats字典(Lib/profiling/tracing/init.py 及其中_lsprof底层 C 采集)。需要说明的是,本仓库中历史cProfile模块仍以向后兼容别名形式存在(import cProfile可继续使用,参见 Doc/library/profile.rst),其数据格式与profiling.tracing一致,因此pstats同样可以直接消费cProfile的产出。 - 数据载入:
Stats.__init__→init()→load_stats():对文件走marshal.load,对剖析器对象先调用create_stats()再直接取stats属性(Lib/pstats.py)。 - 分析加工:
sort_stats(构造排序元组 +TupleComp多级比较)→strip_dirs/add/reverse_order等就地变换。 - 渲染输出:
print_stats/print_callers/print_callees通过get_print_list与eval_print_amount统一完成限制过滤,再逐行输出到stream;get_stats_profile则把同一份数据转成结构化的StatsProfile/FunctionProfile对象。 - 持久化:
dump_stats把内存中的stats字典再次marshal落盘,实现分析与采集分离。
对strip_dirs、add、整数/字符串/枚举三种sort_stats参数、限制参数解析、正则过滤、get_stats_profile、SortKey枚举取值等内容,仓库自带测试 Lib/test/test_pstats.py 均提供了可独立运行的最小验证样例(数据样例取自Lib/test/pstats.pck,测试通过support.findfile('pstats.pck')定位)。深入阅读该文件可快速理解每个 API 的确切契约。
小结
pstats承担着 Python 剖析体系的"后半程":读取(文件或剖析器对象)、变换(strip/add/sort/reverse)、过滤(整数/百分比/正则)、展示(表格与调用关系视图)与持久化(dump/load)一应俱全。从源码看,它的设计核心是"把每条函数记录抽象为(cc, nc, tt, ct, callers)五元组,其余全部操作围绕元组展开"——排序、合并、求调用关系、打印、结构化导出均基于这一模型,因此对追踪式与采样式两种剖析器能够复用同一套管线,仅在量纲上通过SortKey与SampledStats自动切换。
实用层面的要点浓缩为四条:用SortKey.CUMULATIVE找优化热点、用.1/整数/正则做多级过滤、用add聚合多次运行、用python -m pstats做无需写代码的交互式下钻。相关配套文档可继续在仓库中阅读 Doc/library/profiling.rst、Doc/library/profiling.tracing.rst、Doc/library/profiling.sampling.rst 以及实现文件 Lib/pstats.py。
【免费下载链接】cpythonThe Python programming language项目地址: https://gitcode.com/GitHub_Trending/cp/cpython
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考