当前位置: 首页 > news >正文

网站建设活动策划重庆h5建站

网站建设活动策划,重庆h5建站,常德seo技术,建筑工程公司官网原文来自微信公众号“编程语言Lab”#xff1a;CPython 解释器性能分析与优化 搜索关注 “编程语言Lab”公众号#xff08;HW-PLLab#xff09;获取更多技术内容#xff01; 欢迎加入 编程语言社区 SIG-元编程 参与交流讨论#xff08;加入方式#xff1a;添加文末小助手… 原文来自微信公众号“编程语言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 周期。所以我们把采样周期 rrr 设置为 5000011大两个多的数量级保证在采样的影响相对较小的情况下可以收集更多样本。值得注意的是这里用 5000011而非整 5000000因为这是一个质数可以防止前面提到的 lockstep sampling 问题。单个 benchmark 运行 400 秒大概获得 n3.8×105n3.8\times10^5n3.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元素访问用 a[b] 的形式访问容器内的元素。 函数调用有 4 种不同的 opcode对应不同的调用语法把它们也归为名为 function call 的一类。 各种运算符号也归为 math operator 一类。 剩下 63 中 opcode不分了丢在一起就叫它杂类 miscellany 好了。 频率与开销分类占比 不同类别的使用频率和运行开销总结就是这个箱线图了。 name access 的频率最为突出占了一半了说明常量和变量的访问非常频繁开销也不小。Python 因为是动态类型attribute access 就意味着要查字典所以比较耗时。然后是 function call占了 16.0% 开销说明 Python 上下文切换代价挺大的。好消息是现在还是 beta 版本的 Python 3.11 加了一堆优化来缓解这个问题所以未来这部分的开销会低一点。math operator 这边中位数都不大但是有少数几个 benchmark 专门测试科学计算的数值拉得很高。element access 和 miscellany 数据都不怎么突出就不讨论了。 名字访问问题 讨论了整体上的情况再来看一些具体问题首先是关于名字访问。 名字访问这里的名字其实有两种不同的访问机制。 图里左边的是 array-style包括常量、局部变量、还有闭包变量这些名字是保存在数组中的访问的时候直接数组加下标就行了。 图里右边的是 dict-style包括全局变量和内置变量由于 Python 的语法限制他们不能用数组保存所以 CPython 用了哈希字体访问时候得查字典。 后者的复杂度比前者高多了但是除了访问数据这个核心操作外每个指令都有一堆共同的附加工作。所以最终表现就是左边 array-style 的访问不管是从频率还是开销都占了上风尤其是加载常量和读写局部比变量三种 opcode。那么有没有办法消除这几个指令有使用寄存器式解释器架构就是一种方法。这会在后面关于性能优化的部分展开讲解所以就不继续展开。 动态类型的问题 第二个问题与动态类型有关。 动态类型之负担 这里可以总结出两个方面的负担 其一是关于属性访问的。在 C 和 Java 等静态语言里属性访问就是首地址加上一个偏移当然如果有多态的话要利用虚函数表间接索引。而在 Python 中它需要依次查找对象本身、对象类型、对象父类乃至祖父类各自的哈希字典。如果类型里还定义了 __getattr__等魔法函数的话整个过程还会更加复杂。其二是关于数学运算的。因为类型不确定即使是两个 int 或者两个 float 相加也要和其他对象一样过一遍完整的流程依次检查左右类型是否定义了处理例程然后执行间接调用。无法转化为直接调用对应的底层过程。 静态推断之困难 很自然的一个想法是能不能在编译时候用静态分析的方法尽可能地推断出一些对象的类型然后利用类型信息生成优化过的字节码减小那两部分的开销。理论上是可以但是第一步类型推断就会很棘手。 首先是对全局变量的静态推断是无法做到安全可靠的他们可能在模块外或者通过反射的形式被意想不到地修改。然后普通的 Python 类型及其对象允许用户在它定义之后再添加或者删除属性这其中包括对运算符的重载所以就算推断出来了类型也没有用。 唯一能够安全地进行静态推断的就是从 123 和 “hello world” 这些 int 和 str 类型的字面值常量出发推断出的一些局部变量的类型。并且这些内置类型还都有一个优点就是不允许用户修改它们的属性。所以理论上来说推断出来了类型也就能够优化它们的属性访问和数学运算。 但是话也不能说得这么绝对比如看下面这个例子这两个局部变量不管怎么赋值在下一行打印出来的类型都表明它们是字符串。其实是因为我们在前面当然也可能是模块外配置了 settrace它还是可以反射地修改局部变量值。不过这个特性官方文档也没有说清楚到底是语言规范还是 CPython 自己的行为所以也不好说。 from sys import settracedef my_tracer(frame, event, arg None):if frame.f_code is foo.__code__:frame.f_locals[v] surprisereturn my_tracersettrace(my_tracer)def foo():v 42print(type(v))v 3.14159print(type(v))foo()输出 class str class str失败的尝试 那先不考虑 settrace 这种近乎于魔法的东西我们能不能进行静态优化呢很久之前有过这方面的尝试。 从字面值常量出发在函数内尽可能地推断出局部变量的类型然后为它们的生成一些类型特化之后的指令。比如上面表格第四行这里如果一个加号左右两边都是 str 类型那么就用 STR_CONCAT 指令替代 BINARY_ADD运行时就不必检查类型直接调用字符串连接过程。但是效果呢如下面的这个表格所示在添加了类型推断和字节码特化之后程序的运行时间消耗和 baseline 不相上下。还有另外几个 benchmark 的结果没有列出来总之最后的结果是比 baseline 还差一点点。所以这个尝试算是给了一个历史教训吧光靠静态推断是没用的。 属性访问 下面先来看用于属性访问的 4 种 opcode 的开销。 它们在 48 个 benchmark 的中位开销是 8.9%在两个 benchmark 上甚至占了超过一半的开销。其中又以 LOAD 的开销为主STORE 开销占比相对少些DELETE 是几乎不用的。 优化的余地还是很大的所以 CPython 3.10 加入了 per-opcode cache 机制来加速 LOAD_ATTR 的过程然后 3.11 又进一步再优化了一点。 可优化的余地那么大为什么前面那个尝试失败了请看下图。 我们把所有 benchmark 的 LOAD_ATTR 和 LOAD_METHOD 开销画了出来分别在横轴上方和下方。然后又统计了下访问 int 和 str 等这种内建类型属性在其中的次数占比对应为图中有颜色的区域。这意味着什么意味着 就算假设内建类型的单次属性访问耗时和自定义类型一样然后我们可以推断出所有属于内建类型的 Python 变量类型并且把它们的属性访问开销降低到 0整个过程还不带一点副作用 加上这一堆理想化的假设最后也就是能砍掉图中有颜色这部分的开销。所以想要降低属性访问开销还得关注用户自定义的类型。 再来看数学运算符部分的开销。其实它们只在多数 benchmark 上开销并不大中位数只有 4.6%不过在少数 benchmark 上还是举足轻重的。 比如 pidigits 上开销占比 96.5%。不过这么多开销并不完全是因为动态类型造成的我们把其中开销按照性质分为三种组成部分 第一个是 opcode handling也就是在前面那个 _PyEval_EvalFrameDefault 函数里面解释字节码所对应的开销第三个是 calculation是以及执行确定类型的底层计算的耗时夹在中间的第二个 overloading它的开销就是和动态类型有关的比如查找类型方法重载然后执行间接调用这种 可以看到开销主要来自于底层计算对特定类型的运算符操作添加一些 “捷径”收效会有但不会太多。而且也不要用静态推断的方法来添加这些捷径因为能推断成功的数量非常有限。CPython 3.11 用动态特化的方法尝试了下优化官网的数据只说了最好可以加速 10%没有提到平均效果。 小结 总的来说从字节码角度分析性能有以下一些小结论。 寄存器架构是我们后面性能优化部分会讲到的一个尝试。基于静态类型推断的优化可以认为是一条死胡同。然后优化一般类型的属性访问以及函数调用开销这方面CPython 确实在最近几个版本里正在改进它们。 解释器开销 拆解 接下类从另外一个角度来分析 CPython 的性能也就是把解释器本身看作一个普通的程序看看它的哪些模块开销占比最大。 CPython 解释器的构成 先来看 CPython 编译之后的组件构成。 它包括了一个解释器本体一堆动态链接库还有一些 Python 写的标准库代码。 然后从解释器虚拟机的视角标准库 Python 代码和用户 Python 代码其实没多大区别。 而那些二进制的动态链接库其实一般都是实现一些的特定事务比如 json 和 pickle 的处理当然 tensorflow 和 PyTorch 等机器学习库的二进制模块也属于此类。 二进制文件粒度 然后来看前面提到的那几个组件在运行时的开销占比如图所示 红色的解释器本体占比是最大的绿色的内置动态链接库只有在与测试 json、pickle、xml 性能有关的 benchmark 上才有所表现青色的外部动态链接库因为我们把 C 语言标准库等系统库也算在里面所以或多或少各个 benchmark 上都占一点黄色的是系统内核态的开销只有在一些需要频繁进行系统调用的 benchmark 上比较明显图里红色网格线部分是 _PyEval_EvalFrameDefault 函数的开销一个函数占比大概四分之一左右。 源文件粒度 把前面解释器本体的开销进一步分解从 CPython 的 C 语言源文件粒度来看。这个图是一个小提琴图表明不同 benchmark 上开销数值的分布左半部分是局部图用比较精细的比例尺展开 10% 以内的开销分布。 最突出的是 Python/ceval.c这是负责解释执行的。下面几乎全部是 Objects / 目录下的源文件也就是对各种对象的操作。这其中另类的一个是一个名为 Modules/gcmodule.c 的源文件它和 GC 有关。所有没有列出的源文件汇总在最下面一行它们单个文件开销中位数没超过 0.1%加起来也没超过 7.7%。所有如果是优化 CPython 的化关注列出来的源文件就好了。 函数粒度 然后是函数粒度。这部分其实倒也没什么新发现也就是印证了前面提到的自定义类型属性访问开销和函数调用上下文开销挺大的。 函数粒度列出内联函数 但是如果把内联函数独立出来那就有意思多了。这里可以看到有两个函数排名第二第三分别是 _Py_INCREF 和 _PY_DECREF。因为 CPython 使用了引用计数它们分别负责把引用计数 1 或者 - 1。非常简单的两个函数而且也内联起来了但是开销占比却不小我们后面会讨论它们。 语句粒度 最后是语句粒度也就是 _PyEval_EvalFrameDefault 函数内部的开销分解。这一部分比较琐碎如果不是专门从事 CPython 优化的开发人员可以不必在意其中细节。不过有一点值得留意就是这个 dispatch 的开销。什么是 dispatch 呢CPython 不是需要解释执行各个字节码指令么然后解释完一个指令需要取下一个指令然后解码再跳转这就是 dispatch。如下图所示CPython 就用一个名为 DISPATCH当然还要 FAST_DISPATH的宏来实现这一系列操作。它占了整个解释执行过程开销的三分之一。我们后面也会讨论和它有关的问题。 GC 问题 不过还是先从到前面提到的 GC 问题开始讨论。 各种各样的 GC 算法不说成千上万也得有成百上千。但是变来变去归其根本就两个思路 一个是基于追踪它从若干个根对象开始进行可达性分析不可达的对象就是垃圾执行回收。缺点呢就是不能逐个回收程序运行一段时间后就要停下来等垃圾回收器运行一遍。另一个是基于引用计数每个对象一个计数器计数器一旦变成 0 就回收。它很简单、也可以逐个回收对象但是有一点空间成本而且最为致命的是存在循环引用问题。 CPython 中的 GC 那 CPython 是怎么做的呢 最古老的版本Python 1.x只有引用计数如果有循环引用不好意思Python 程序员需要手动解决问题。到了 Python 2它终于加上了一个基于追踪的 GC 模块了所以不用再去操心循环引用了。因此CPython 用的是一种混合的 GC 实现引用计数有追踪也有。 那哪个部分对性能的影响最大呢我们对比了不同 benchmark 上二者的开销的大小以散点图的形式画了出来。这里的横座标 LOPC是我们自定义的一个度量就不展开只需要知道它代表了 benchmark 自身的某种特性即可。总的来说可以看到tracing 的开销处于较低水平而且不同 benchmark 之间变化不太明显。而引用计数的开销就大了一个数量级了而且基本上和这个 LOPC 度量正相关。 引用计数的IPC性能 引用计数明明就是把一个整型变量 1/-1 的事这么简单的操作为什么会有这么大的开销很自然地大家会想到内存访问的速度问题因为引用计数器在对象结构体里对象在堆内存中。很可能对象所在的内存并未出现在 CPU 缓存中然后内存这么一读一写速度自然就拉下来了。 但事实是么我们测量了修改引用计数这个两个操作的的 IPC 性能如果是因为缓存失效的话CPU 会失速IPC 应该明显降低。可是拿这两个操作的 IPC 和解释器整个运行周期全局平均的 IPC 对比发现差异好像没那么明显。 _Py_INCREF 的 IPC 相对整体确实还是偏低了些所以推测是还是有一些缓存问题的虽然不多。_Py_DECREF 这边它的 IPC 和整体 IPC 没有统计学差异说明几乎不存在缓存问题。我们的推测是一般来说增加引用计数要早于减少引用计数所以等它减少计数时候对象已经被 CPU cache 给缓存住了速度正常。另外它还要判断引用计数是否为 0进行有个条件跳转为 0 的话要发起回收对象的函数调用一般来说条件跳转和函数调用都是很费时的但是它们也没有降低 IPC。 总的来说引用计数费时主要就是因为使用过于频繁就这么简单直接的原因。 取消引用计数 在 GC 领域有一条经验法则是GC 开销占比超过 10%就说明用错了方法。引用计数中位数开销是 12.0% 了最高逼近 20%。因此我们认为至少对 CPython 而言它不是一个好方案更像是一个历史包袱。而且因为有引用计数CPython 里面还存在一个 GIL 的问题一个全局锁导致 CPython 的多线程目前只能并发不能并行。 所以也许可以考虑直接取消掉引用计数干脆用纯粹基于追踪的 GC 方案得了。JVM 和 JS 引擎是这么做的其他 Python 解释器实现比如 PyPy 也是如此。原理上并没有什么问题问题还是在于历史包袱太多特别是很多 C 语言写的第三方库依赖了目前引用计数的方案。不过有个名为 HPy 的项目试图解决这个过渡的问题也许未来 CPython 真的可以取消引用计数。 调整GC tracing阈值 那么 tracing 这边有没有问题呢有的我们发现有两个 benchmark 的 tracing 开销特别高具体一看发现是测试 Python 启动性能的两个 benchmark。所以我们猜测是不是 GC 阈值太低了调用得过于频繁明明没有垃圾还反复去收集浪费时间。所以我们做了个小实验把 GC 阈值设置成 2 倍、4 倍、8 倍等等还有 2 的 20 次方倍这就基本等同于关闭了追踪垃圾回收了。结果发现整个进程的内存占用基本不变但是时间消耗都降低了 3% 左右。也就是说至少对于 CPython 的启动过程来说tracing-based GC 是徒劳无功地调用得过于频繁了。那么其他 benchmark 呢不排除也有这种现象。 因为现在 CPython 的 GC 阈值是固定的所以一个优化建议是也许可以设计一套方案让 GC 的阈值变得动态可调节几次回收发现没有垃圾那接下来阈值就高一点别再反反复复调用了。 dispatch问题 GC 问题就分析到这接下来还是关于 dispatch 问题的。 dispatch 再介绍一遍 dispatch解释器解释完一个指令后“取指令、解码、跳转” 等这一流程称为 dispatch。不过可能在有些研究中dispatch 是指狭义的 dispatch只包括其中的跳转操作也就是图中这个 goto 语句。因为在传统的观点中这个跳转目的地址多变分支预测很难是整个过程中最耗时的环节是解释执行的性能瓶颈所以关注点都在它这。 threaded code 如果不是专门研究解释器性能的可能会有人问为什么要用 goto用这个 while 循环加一个 switch 不好么一个指令执行后break 出去进入下一次循环然后再次 switch 一下这是一种最直观的设计但是过去认为有一些问题。 因为每个指令都从 switch 这里跳转CPU 根本猜不出来你要 switch 到哪里去于是速度就不行了。 反之如果在每个指令的结束位置分别 goto就相当于从从原来 switch-case 1-N 的跳转变成了每个指令到下一个指令的 N-N 跳转。CPU 根据跳转发起的位置不同更有可能猜出来跳转的目的地在哪速度会高些。 这种方案叫 threaded codeCPython 很早就用上了并且当时发现可以让解释器速度快个 15-20%。 真的改进很多么 但是这一切都发生在很早之前现在呢分支预测还是那么容易失误么threaded code 还是带来了很大收益么 我们定义了一个度量MPKC也就是程序运行 1000 个周期CPU 分支预测失误了几次。 左下角这个散点图里红色的散点对比了不同 benchmark 启用和禁用 threaded code 的 MPKC横座标是启用纵座标是禁用。回归线斜率 1.227也就是禁用之后分支预测错误多了 22.7%还是有效果的不过效果有限。然后从另外一个方面看我们说threaded code 的好处是把跳转分散了开来。可是前面我们发现 LOAD_FAST 这个 opcode 的使用频率高达 27.5%也就是说 27.5% 的 dispatch 跳转还集中在它这。那它的 MPKC 是多少呢看图中绿色的散点做一条回归线斜率是 0.267也就是它的 MPKC 占比 26.7%和使用频率 27.5% 基本一致还小了一点。也就是说很多 dispatch 跳转都集中在它这却也没引发什么灾难。反过来其实也印证了把 dispatch 跳转分散开来效果也很小。 dispatch——并非瓶颈 除了相对对比再来看绝对值。 1 个 misprediction 浪费约 CPU 流水线长度个的 CPU 周期1MPKC 在我们的设备上大致等价于 1.6% 的运行开销。 图中绿色的部分是由 dispatch 导致的 MPKC 值黄色部分是由于解释器其他部分引起的这里不做讨论。从中位 benchmark 看dispatch 导致的 mispredition 对应的开销只有 1.1%最大的 benchmark 上为 6.2%。整体处于很低水平因此 threaded code 减少 misprediction 带来的收益更是有限。 再来做一次验证还是用 IPC 来度量发现 dispatch 部分的 IPC 性能与整体相比并无统计差异。并不是像很久前的研究中说的那样dispatch 的跳转部分很难预测导致 CPU 失速。主要也是因为现在 CPU 越来越先进了预测得越来越准哪怕是使用普通的 switch-case 方案也能预测得很准确。所以现在 dispatch 还是耗时了不少时间是因为取指、解码、跳转这一系列操作非常冗长并不单单是因为跳转操作的特殊性。 小结 解释视角的性能分析大概有这么些结论 首先是 CPython 的 GC 中引用计数开销占了上风高了一个数量级一个可能的优化是干脆取消引用计数纯粹使用基于 tracing 的 GC然后 tracing 这边可以设置自适应阈值来进一步提高性能最后关于 dispatch古早的研究都认为它是解释器性能的重中之重但是现在发现它的意义并没有那么突出。 3 性能优化 RegCPython寄存器架构的 CPython 前面的都是实证分析接下来谈谈优化。我们目前实现了一个优化尝试也就是改造 CPython 为基于寄存器架构就叫它 RegCPython。 架构之争栈与寄存器 所谓栈架构就是运算指令需要从一个栈上取出输入然后把运算输出放回栈顶。至于变量的读写则使用专门的 LOAD 和 STORE 指令。寄存器架构呢每个指令的输入和输出都显式地编码在指令参数中就不需要经过栈来中转。 两者架构优缺点正好相反。栈式设计简单IR 体积小且生成快解码速度有更胜一筹。寄存器式指令数量会少很多所以速度会快些。 栈式寄存器式设计编写易难IR生成速度快慢IR体积小大解码速度快慢指令数量多少运行速度慢快 CPython 用的是栈式我们想改成寄存器式的话这些优点和缺点会有多大的程度呢 RegCPython 我们对 CPython 的修改集中在两个部分 首先是前端也就是编译器部分需要修改字节码生成器AST 还是原来那个 AST但是现在需要生成寄存器式字节码。然后式后端也就是运行时部分需要修改负责字节码解释的执行器它需要接受寄存器式字节码。 其他的诸如词法、语法、语义分析部分或者 GC 系统和类型系统都不改变这样可以在最大程度上保证兼容性。 下面的图 c 展示了 RegCPython 编译出来的字节码它是一种三地址码结构并且把原来的 16 条指令缩减到 8 条。 benchmark 特质与分类 最值得关心的肯定是修改前后的运行速度。不过在这之前我们需要先把所有的 benchmark 分个类。 我们定义了一个度量叫做 P/VP/VP/V 比它意思是执行一个字节码指令平均消耗多少个 CPU 指令。 P/VNphysicalinstructionsNvirtualinstructionsP/V\frac{N_{physical\ instructions}}{N_{virtual\ instructions}}P/VNvirtual instructions​Nphysical instructions​​ P/VP/VP/V 值越低同样多时间内执行的字节码指令越多也就是说字节码指令被密集地执行。这 benchmark 就接近于所谓的 “纯 Python 程序”。比如用纯 Python 进行各自逻辑问题的求解。反之P/VP/VP/V 值很高的话一个 Python 指令背后是一大堆机器指令这基本说明程序主要在调用各种库函数Python 更多地充当为一种 “胶水语言”。因此按照 P/VP/VP/V 值我们把全部 benchmark 平均分为三类依次是python-intensiveneutral以及 binary-intensive。 研究解释执行的性能自然是 python-intensive 的 benchmark 更为重要。 实验结果 速度对比相对运行耗时 下图中横座标是不同的 benchmark以 CPython 的时间开销为 1纵座标是相对时间开销。其中有颜色的是 RegCPython 的没有颜色的是 CPython 的。之所以是一个小提琴图而不是一个点是因为我们把每个基准都重复运行了很多遍然后把这么多次重复运行的时间开销分布都画出来了。 在最好的一个 benchmark 上时间消耗大概减少了 25%。从所有 Python-intensive 的 benchmark 看时间消耗平均减少 12.0%。即使是从包括 binary-intensive 的所有 benchmark 平均看寄存器架构也是快了 6.2%。并且除了在少数几个 benchmark 上会略微慢一点点绝大多数情况使用寄存器架构都可以让程序运行得更快。 空间代价相对内存占用 时间代价之外是空间代价。我们继续把 CPython 的内存占用视为 1修改之后的相对内存占用的分布就是如下直方图。 内存消耗确实大了一点但是多数的 benchmark 而言增加的幅度都在 0.2% 到 2.6% 之间。不过有两个例外 mako 和 regex_dna 这两个 benchmark。因为对栈式架构来说一个临时变量从栈上被弹出它的引用计数立马会 - 1然后可以立即被回收。而寄存器架构把变量放在寄存器中临时变量只有在下一次写入时候才会被覆盖所以引用计数不会立马减少。这就导致可能有些对象在栈式解释器中被回收得很及时在寄存器式架构中被回收就有延迟。然后这两个 benchmark 刚好就是处理超长字符串的一个字符串就有 1MB 大小只要有两三字符串回收的慢点内存占用就这么多了 10% 左右。 不过值得注意的是引用计数并不是 Python 语言的标准所以前面我们才提出可以尝试使用纯粹基于 tracing-based GC。那如果未来真的没有引用计数的话这里空间上的相对劣势会小很多。 开发代价代码复杂度 然后是代码复杂度的对比。代码复杂度越低开发起来越快维护起来越容易。这里我们使用 C 语言语句数量和 McCabe’s cyclomatic complexity 两个度量分别对比 CPython 和 RegCPython 的字节码生成器和字节码执行器源文件的复杂度。 结果发现使用寄存器式架构RegCPython 字节码生成器的代码复杂度要比原来低很多也就是说从同样的 AST 出发编写一个生成寄存器式字节码的程序要比写一个栈式字节码的生成程序容易一点。这和一般的观点是完全相反的一般的观点是说栈式更容易设计一些。然后执行器方面二者复杂度差不多。不过 RegCPython 定义了好几个比较复杂的宏所以预处理宏展开之后复杂度高一点。但是作为程序员大家写的都是宏展开之前的代码所以不必也没有多大问题。 总而言之就是如果你是一门新语言的解释器开发人员一开始就不要为了实现方便而选择栈式架构因为它也没简单多少寄存器架构也没麻烦到哪里去。 编译速度与 IR 体积 最后一个对比关于编译的速度和生成的 IR 体积。我们取了 PyPI 仓库里下载量最高的 500 个包做实验。然后对比生成 pyc 文件的速度和 pyc 文件的相对体积。总的来看编译时间消耗小了一点点文件体积大了一点点简单概括就是半斤八两。所以一般观点认为的栈式代码体积小生成快对 Python 解释器这边并不成立。 小结 从 CPython 到 RegCPython我们定下了四个设计目标可以说都满足了 首先是速度要更快然后内存占用和编译代价等其他方面没有拖后腿的再后式兼容性从 API 到 ABI 都是兼容的最后是复杂度它也足够简单易于维护 其他优化讨论 最后一部分是稍微介绍一下其他 Python 性能优化工作。 JIT 解释器是有极限的对纯解释器性能做优化要说精益求精还可以但要说改头换面基本不可能。最根本的解决方案还得看 JIT。围绕 Python 的 JIT 尝试其实一直都不少但也一直都不温不火。 最早的尝试应该是 Psyco04 年的但是后来维护不下去了开发者让大家转投 PyPy。PyPy 可以说是目前实践应用的最多的带 JIT 的 Python 解释器但是它不兼容 C-API导致二进制库不能直接移植所以还差点火候。Unladen Swallow 曾经可以说是众望所归它是谷歌赞助的而且还一度有 PEP 计划把它纳入 CPython 主线但是最后还是维护不下去了项目终止。Pyston 和 Pyjion这两个命运比较类似都是开发着开发着就没人继续维护了然后到了最近的 2020 和 2021 年又双双复活了github 上的提交也变得活跃起来。Numba 的话专用于科学计算不能算是普适的 JIT。 总的来说就是Python 的动态性太强了导致 JIT 的开发比较困难。然后又要兼顾语言的兼容性和底层二进制接口的兼容性历史包袱又很重开发 JIT 属实有些 “劝退”所以好几个项目做着做着就做不下去了。 Faster CPython与Python 3.11 然后如果是 Python 性能的研究者个人觉得一定要关注的就是这个 Faster CPython 项目。 它是一个由 CPython 核心开发人员发起和参与的一个旨在改善 CPython 运行速度的项目计划是四年之内把 CPython 速度提高到 5 倍而且还不会破坏 Python 兼容性也不会再极端情况让性能变得更差甚至准备在 Python 3.13 的时候支持 JIT。 前几天 CPython 3.11 已经发布了第一个 beta 版应用了来自 Faster CPython 的几个优化方案速度达到了 CPython 3.10 的 1.25 倍性能提升幅度比之前的 CPython 版本更新强了不少。不过正式版的话按照以往的开发节奏大概是要等到年底。 4 结语 以上就是我关于 CPython 性能分析和优化的全部报告内容感谢大家的关注和倾听欢迎进行探讨。
http://www.dnsts.com.cn/news/187657.html

相关文章:

  • 深圳公明网站制作大庆市工程建设信息去哪个网站
  • 北京网站开发制作公司公众号开发合同
  • 怎么用壳域名做网站360建筑网官网网址
  • 网站备案在哪里备案wordpress 主题 knowhow
  • 酒类网站该怎么做山东省中国建设银行网站
  • 网站建设的个人条件做程序的软件
  • 小马厂网站建设仿站定制模板建站
  • 有没有帮人做数学题的网站哪个网站可以帮忙做简历
  • 广州网站建设乐云seocj联盟wordpress
  • 织梦网站加网站地图旅游电子商务网站建设规划方案
  • 网站内置多语言wordpress调取文章
  • 长沙正规制作网站公司网站模板
  • 郑州pc网站开发地方门户信息网站建设方案
  • 四川建设厅电话网站沧州网站优化
  • 广州企业建设网站简单官网模板
  • wordpress退货插件北京seo网站推广费用
  • 北京免费模板建站廊坊哪里有做网站的
  • 兰州吸引用户的网站设计白城网页制作
  • 做企业网站建设的公司广东住房建设厅网站
  • 苏州网站制作 网站logo在线设计生成器万动力
  • 中卫网站设计公司湖南3合1网站建设公司
  • 广州免费建站哪里有可以用来做简单的网络验证的网站
  • asp网站有哪些网页制作与网站建设实战大全 豆瓣
  • 为每个中小学建设网站电子商务网站建设常用工具
  • 专业做汽车零部件平台的网站建设信用卡激活中心网站
  • 深圳网站推广优化开发一款app需要多少钱?
  • 网站 续费广东新闻联播片尾
  • 无法进入建设银行网站公司网站建设解决方案
  • 网站的seo怎么做北京软件外包公司
  • 济南网站建设v芯企优互联不错h5免费制作平台易企秀