Python性能:不仅仅是解释器

2020-05-19 23:50:49

根据我在Pyston项目上的经验,我对Python的性能有一个特别的看法,因为这个观点有点不标准,所以我想花一些时间来解释它,并给出一个鼓舞人心的例子。

Python性能低下的一个常见解释是它是一种解释型语言。在这篇文章中,我希望表明,虽然解释器增加了开销,但它并不是即使是一个小的微基准测试的主要因素。相反,我们将看到动态特性--特别是运行库内的特性--是罪魁祸首。

我选择了一个特定的微基准,我知道它的参数传递效率很低,但有趣的是,它的成本几乎是使用解释器成本的3倍。我还感到惊讶的是,递归限制检查起到了这样的作用,因为它并不经常被提及。

我正在调用传递动态功能的参数,因为在这种情况下它是完全可以避免的。Python参数传递可能代价很高,因为它需要将参数打包到为此分配的元组中,并需要解释格式字符串以确定如何在被调用方解析参数。

我从一个简单的微基准中收集了这些数据,这是我从一个真实的用例(Django模板)中提取的;我并不是说它特别有代表性,只是说它是一个说明性的、有趣的数据点。

通常,通过运行分析器来分析程序的性能,以查看程序的哪些部分占用的时间最多。我将采取一种不同的方法:我将反复优化基准测试,看看每个优化有多大帮助。我相信这会让我们更清楚地了解我们优化掉的每个功能的成本,同时也提供了自动进行这些优化需要做些什么的提示。

基准测试是将数字转换成字符串,在Python中,这是非常昂贵的,这是我们将要讨论的原因。这样做的动机是假设您有一个模板,其中有许多变量,所有这些变量都需要转换为字符串以进行呈现。

就方法而言,我只是在我的开发机器上运行所有这些基准测试,每个基准测试运行三次,并报告中位数。性能差异相当明显,所以我们不需要非常精确或精确的基准测试,所以我让它保持简单。除非另有说明,否则我将通过我的Ubuntu提供的python3二进制文件(即Python3.7.3)运行所有基准测试。

我们可以进行简单的优化:每次循环迭代引用字符串都会强制解释器执行代价较高的全局变量查找,这比局部变量查找要慢。我们可以将str对象缓存到一个局部变量中,这将基准测试降低到2.07秒。

下一个目标是将for循环从Python移到C中,在本例中,我们可以使用map()函数来实现这一点。基准现在是。

这个版本做了更多的工作,因为它创建了所有字符串的列表。也许令人惊讶的是,删除解释器带来的好处几乎超过了额外的工作,而这个新版本是在2.11s中发布的。

作为参考,如果我们通过列表理解创建相同的列表,则基准测试需要2.34秒。通过使用MAP从2.34s优化到2.11s,我们可以将解释器开销计算为程序执行的10%。10%在许多情况下是很大的,但远不足以解释Python速度慢的名声。

要继续进行下去,我们将需要进入C扩展区域。我在前一个基准测试中运行了Cython(一种Python->;C转换器),它的运行时间完全相同:2.11秒。与Cython的3600相比,我用36行编写了一个简化的C扩展,它也可以在2.11s中运行。[基准运行的方差约为0.03秒,因此获得完全相同的中值时间似乎很幸运。]。

for(int i=0;i<;20;i++){PyObject*a=PyTuple_Pack(1,PyLong_FromLong(1000000));PyObject*r=PyObject_Call((PyObject*)&;PyRange_Type,a,NULL);Py_DECREF(A);a=PyTuple_Pack(2,(PyObject*)&;PyUnicode_Type,r);Py。Py_DECREF(A);a=PyTuple_Pack(1,m);Py_DECREF(M);PyObject*l=PyObject_Call((PyObject*)&;PyList_Type,a,NULL);Py_DECREF(A);Py_DECREF(L);}。

我做的下一件事是再次删除列表,并简单地迭代地图对象。这样就把时间降到了1.86s。

然后,我包含并内联了map迭代函数。这并没有影响运行时,现在是1.87s。

下一件要优化的事情是map()可以接受可变数量的参数的功能,这会略微减慢其处理速度。硬编码该映射只有一个参数,这会将时间略微减少到1.82s。

我做的下一件事是将一些map迭代代码移出循环:map_next()通常在每次迭代时都必须从内存中重新读取相关变量,所以我认为在循环之外执行一次会有所帮助。令人惊讶的是,运行时是相同的:1.82s。

接下来,我复制并内联_PyObject_FastCallDict和RANGETER_NEXT。令人惊讶的是,切换到_PyObject_FastCallDict的复制版本会显著降低程序速度,降至1.88s。我的假设是,这是因为我的扩展模块没有启用PGO,而我相信Ubuntu Python构建确实启用了PGO,所以复制的代码现在优化较少。

接下来,我针对此特定情况优化了_PyObject_FastCallDict,删除了一些在我们知道要调用的函数后保持不变的检查。这模拟静态类型,或者在JIT情况下的推测性类型推断。这并没有带来太大的不同:1.87秒。

现在我们要谈到问题的核心了。str()很慢,因为它不是一个函数:它是一个类型。Python中的类型具有复杂的调用行为,因为它们执行Python的两阶段构造,允许在每一步重写。这对于普通类型转换来说是相当不幸的,因为它们不会做太多工作,但会调用相当昂贵的构造函数行为。我内联了TYPE_CALL和UNICODE_NEW,性能大致相同:1.86s。

现在我们已经将所有代码放在一个位置,我们可以看到参数打包和解析+解包。Python有一个传统的调用约定,以及一个更快、更新的调用约定。不幸的是,调用类型会退回到较慢的调用约定,需要分配一个args元组,然后在接收端解析它。我通过对参数解析进行硬编码来优化当前基准测试。这产生了很大的影响:运行时现在是1.46s。

我内联了PyObject_Str,这再次降低了性能:1.52秒。再说一次,我认为这是由于缺乏PGO。

接下来,我基于str(Int)返回String对象的假设进行了优化。这将运行时间减少到1.40s

既然我们已经删除了足够的代码,我们终于可以进行另一个大的优化了:不再分配args元组,这将运行时间缩短到1.15s。

作为参考:NodeJS运行等价的基准测试需要1.38秒。令人印象深刻的是,PyPy只需要0.27秒就可以运行原始版本的基准测试。因此,它们不仅消除了大部分开销,而且在Unicode转换方面也明显更快。

我相信这些发现为Python优化指明了一定的方向。我在这个领域有一些想法,希望你能在这个博客上听到更多关于它们的信息!