CPython解释器性能分析与优化

原文来自微信公众号“编程语言Lab”:CPython 解释器性能分析与优化
搜索关注 “编程语言Lab”公众号(HW-PLLab)获取更多技术内容!
欢迎加入 编程语言社区 SIG-元编程 参与交流讨论(加入方式:添加文末小助手微信,备注“加入 SIG-元编程”)。

作者 | 张强

整理 | Hana、IceY

作者简介

南京大学计算机科学与技术系四年级直博生,研究方向为“解释器性能分析与优化”,研究兴趣是偏底层、偏工程的项目编写与性能调优。

论文

https://doi.org/10.1016/j.scico.2021.102759

视频回顾

编程语言技术沙龙 | 第12期:CPython 解释器性能分析与优化

1 背景介绍

首先需要明确,Python 作为一门语言,其实只是一个存在于概念中的规范,它本身并没有限制开发者去怎样实现它。因此就有 IronPython、Jython、PyPy 和 Pyston 等具有不同特性的实现。不过在实践中,大部分情况下大家用的都还是 CPython。这是因为,首先它作为一个参考实现,能够支持全部语言特性。还有 PyPI 这个仓库可以 pip install 第三方包,其他的实现可能因为兼容性等问题用不了仓库里的包。最后还一个原因,某些行为到底是语言标准的要求,还是实现定义的,或者甚至是未定义的,Python 并没有一个非常明确且详细的描述,所以这时候开发者会以 CPython 作为事实上的标准。

接下来的报告也只关注 CPython。

CPython 解释器

CPython 可以看成由一个编译器和一个虚拟机构成。前者把将要执行的 Python 代码编译成一个中间表示,也就是字节码。后者执行的时候就不用再去理会复杂的语法结构。

不过 CPython 的这个编译器非常的简单甚至简陋。它把每个函数视为独立的编译单元,不会实施任何函数间优化。函数内优化也几乎没有,比如公共表达式提取这种,不存在的。甚至它还会舍弃掉类型信息,所以对象一律视为 object,哪怕使用了 type annotation 语法显式标注了类型也不例外。

在这里插入图片描述

CPython 字节码

这有一个阶乘函数和它的字节码。字节码中每个指令都固定为两字节,一字节的 opcode 和一字节的 oparg。
在这里插入图片描述

下图展示了 CPython 内部负责指令解释的函数,可以看到是基于栈式架构。
在这里插入图片描述

2 性能分析

接下来是性能分析部分。

采样法的应用

插桩法的问题

测量程序中某个部分的时间开销,最容易想得到的办法自然是插桩,开头结尾时间一测再一个减法就好。但是它有一些问题:

首先插入的测量代码本身有时间代价,然后插桩后的代码会在寄存器分配等各个方面和原来的代码有所不同。而且,现代 CPU 基本会采取乱序执行,插桩的位置在实际执行中可能就不会对应它那一段代码的开头结尾了。

当然,使用更加先进的插桩方法和工具可以缓解缓解前面的问题,但依然有两个难点。首先被干扰的部分就是被插桩的部分,程序中有插桩和没插桩的各个部分受到的干扰程度不一样,可能让结果产生畸变。另外,插桩需要提前设置位置,无法在没有假设的前提下进行探索性的实验。

插桩法不适用于对解释器进行整体上的性能分析。

采样法

因此我们使用采样法来对解释器进行性能分析。它的原理是,程序每执行一段给定时间就会被中断,然后采样器记录下当前的状态,比如寄存器值,或者某一段内存里的数据。在分析的时候,就用这些样本的比例,或者说分布,去近似程序实际的开销分布。实际上就是用一系列离散点代替一段连续的时间。
在这里插入图片描述

因此采样法不需要修改被测程序,直接用正常编译的版本就行。而且,周期性中断对被测程序而言是随机的,程序里每个部分都可能受到影响,结果不会被带偏。最后,除了时间(也就是 CPU 周期),还可以用其他事件执行采样,比如分支跳转、缓存失效等等,这样还可以得到其他性能事件在程序中的分布。

采样法(误差控制)

当然,采样就意味着误差是必然的,只能设法减小。最简单粗暴的是增加运行时间或次数,样本够,精度就够。但如果时间有限的话,就只能增大采样频率了,在同样的时间内更频繁地中断程序获取样本,不过这样对程序的干扰也就大了,要掌握火候。
在这里插入图片描述

最后还有一个值得注意的,也不是光样本数越多越好,要足够随机,样本才能有代表性。如果采样的节奏和程序运行的节奏刚好对上,产生 lockstep sampling 现象,结果就会很离谱了。
在这里插入图片描述

采样法(误差估计)

如果采样是随机的话,样本就服从超几何分布。用切比雪夫不等式推一下可以发现,误差与样本量根号的倒数成正比。
在这里插入图片描述

我们用的采样工具是 Linux perf,它采集一个样本的开销大致在 10000 个 CPU 周期。所以我们把采样周期 r r r 设置为 5000011,大两个多的数量级,保证在采样的影响相对较小的情况下可以收集更多样本。值得注意的是,这里用 5000011,而非整 5000000,因为这是一个质数,可以防止前面提到的 lockstep sampling 问题。单个 benchmark 运行 400 秒,大概获得 n = 3.8 × 1 0 5 n=3.8\times10^5 n=3.8×105 个样本。

数据代入上述公式可以确认,误差已经控制在合理的范围内,样本量足够了。
在这里插入图片描述

字节码开销

拆解

接下来是从字节码的角度分析 CPython 的性能。

首先是开销的拆解,后面还会有一些具体问题的分析。

从 C 栈帧到 opcode 开销

采样工具加上 addr2line 工具,可以帮我们还原中断发生时解释器本身的 C 语言调用链,那怎么知道当前正在处理的 Python 指令是哪种 opcode 呢?我们的方法是逆着调用链回溯,直到找到 _PyEval_EvalFrameDefault 函数,这个负责字节码指令解释的函数。
在这里插入图片描述

它有一个大的 switch-case 负责处理各种 opcode,看它当前正在执行哪个 case 的代码就行。因为只看最顶端的一个 Python 指令,所以像图 c 中带 Python 函数调用的,它的开销就被判定给 BINARY_ADD 而非 CALL_FUNCTION。然后有部分库函数是用 C 语言写的,我们也把它标记出来了,像图 d 这里,它的开销就不属于任何一个 Python 指令。

使用频率与时间开销

Python 3.9 定义了 119 种 opcode,如下左右两幅图分别列出了使用频率最高和运行时间开销最高的 20 个。所有数据都是在 48 个 benchmark 上独立收集的。Q1、Q2、Q3 是不同 benchmark 结果的四分位数,Q2 是中位数,图中按中位数排序。
在这里插入图片描述

  • 最突出的结果是各种 LOAD 还有 STORE,特别是其中的 LOAD_FAST,占了 27.5% 的使用量,排名垫底的 99 个指令使用频率加起来都没它一半多。
  • 然后是右边的时间开销,两个 CALL 排名第一第二。
  • 再来找找加减乘除,左移右移,取与取或等等这些运算符对应的 opcode,结果除了一个 BINARY_ADD(对应加法运算符),无一上榜。也就是说,从多数 benchmark 整体看来,运算符的使用量还真没有我们直觉中预期的那么多。
opcode 分类

直接列出来可能不好发现多少信息,接下来就把 opcode 分成六个类:

  • 首先是那一堆 LOAD 和 STORE,其实还有用的比较少的 DELETE,他们都是用来读、写、删某些目标位置,根据目标的种类的不同,他们占据了三个类:
    在这里插入图片描述
    • name access,名字访问,访问常量或者变量。
    • attribute access,属性访问,用 a.b 的形式访问对象的属性。
    • element access࿰
  • 2
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值