为啥不指定关键字 start 时枚举执行速度较慢?

Posted

技术标签:

【中文标题】为啥不指定关键字 start 时枚举执行速度较慢?【英文标题】:Why does enumerate execute slower when not specifying the keyword start?为什么不指定关键字 start 时枚举执行速度较慢? 【发布时间】:2016-04-13 03:04:24 【问题描述】:

当使用指定的默认 start 参数计时 enumerate 时,我注意到以下奇怪行为:

In [23]: %timeit enumerate([1, 2, 3, 4])
The slowest run took 7.18 times longer than the fastest. This could mean that an intermediate result is being cached 
1000000 loops, best of 3: 511 ns per loop

In [24]: %timeit enumerate([1, 2, 3, 4], start=0)
The slowest run took 12.45 times longer than the fastest. This could mean that an intermediate result is being cached 
1000000 loops, best of 3: 1.22 µs per loop

因此,在指定 start 的情况下,速度大约降低了 2 倍。

为每种情况发出的字节码并不能真正表明任何会导致速度显着差异的因素。例如,在使用dis.dis 检查不同的调用后,发出的附加命令是:

18 LOAD_CONST               5 ('start')
21 LOAD_CONST               6 (0)

这些以及具有 1 个关键字的 CALL_FUNCTION 是唯一的区别。

我尝试跟踪在CPythons cevalgdb 中进行的调用,并且两者似乎都在call_function 中使用了do_call,而不是我可以检测到的其他优化。

现在,我知道enumerate 只是创建了一个枚举迭代器,所以我们在这里处理对象创建(对吗?)。如果指定了start,我查看了Objects/enumobject.c,试图找出任何差异。 (我相信)唯一不同的是当start != NULL 发生以下情况时:

if (start != NULL) 
    start = PyNumber_Index(start);
    if (start == NULL) 
        Py_DECREF(en);
        return NULL;
    
    assert(PyInt_Check(start) || PyLong_Check(start));
    en->en_index = PyInt_AsSsize_t(start);
    if (en->en_index == -1 && PyErr_Occurred()) 
        PyErr_Clear();
        en->en_index = PY_SSIZE_T_MAX;
        en->en_longindex = start;
     else 
        en->en_longindex = NULL;
        Py_DECREF(start);
    

这看起来不像会引入 2 倍减速的东西。 (我认为,不确定。)

之前的代码段已在 Python 3.5 上执行,不过类似的结果也出现在 2.x 中。


这就是我被困住的地方,不知道该往哪里看。这可能只是第二种情况下额外调用的开销累积,但同样,我不太确定。 有人知道这背后的原因吗?

【问题讨论】:

您是否测量过更大输入尺寸的差异?更有可能是常数因子差异,而不是 2 倍速度回归。 @Rogalski:我们只是在创建迭代器,而不是对其进行迭代。速度根本不取决于输入大小。 抱歉标题,有点误导。至于输入大小,@user2357112 说的。 @user2357112 我确信 enumerate 在 Python2 中并不懒惰(比如 map 等),我的错。显然我的评论是多余的 - 恒定时间操作确实会被观察为n 倍差。 【参考方案1】:

其中一个原因可能是因为您在以下部分指定开始时调用了PyNumber_Index

if (start != NULL) 
    start = PyNumber_Index(start);

如果您查看abstract.c 模块中的PyNumber_Index 函数,您将在函数的顶层看到以下注释:

/* Return a Python int from the object item.
   Raise TypeError if the result is not an int
   or if the object cannot be interpreted as an index.
*/

所以这个函数必须检查对象是否不能被解释为索引并返回相关错误。如果您仔细查看源代码,您会看到所有这些检查和引用,特别是在以下部分中,为了检查索引类型,必须执行嵌套结构取消引用:

result = item->ob_type->tp_as_number->nb_index(item);
if (result &&
     !PyInt_Check(result) && !PyLong_Check(result)) 
                         ...

检查并返回期望结果会花费很多时间。


但是正如@user2357112 提到的,另一个也是最重要的原因是因为python 关键字参数匹配。

如果你对没有关键字参数的函数进行计时,你会看到差异时间将减少大约 ~2X 时间:

~$ python -m timeit "enumerate([1, 2, 3, 4])"
1000000 loops, best of 3: 0.251 usec per loop
~$ python -m timeit "enumerate([1, 2, 3, 4],start=0)"
1000000 loops, best of 3: 0.431 usec per loop
~$ python -m timeit "enumerate([1, 2, 3, 4],0)"
1000000 loops, best of 3: 0.275 usec per loop

与位置参数的区别是:

>>> 0.251 - 0.275
-0.024

这似乎是因为PyNumber_Index

【讨论】:

不,尝试将start 指定为位置参数,速度差异就会消失。我会说关键字参数 dict 是罪魁祸首。 @user2357112 让我试一试。 对此不太确定。用def foo(par=0): pass 指定一个简单的函数并定时它的不同调用产生:foo() -> 164nsfoo(par=0) -> 183ns。有什么想法吗? @Ariadni 我认为它是因为内置函数是在 C 中实现的,当你给它一个关键字参数时,它首先 python 尝试在解释器级别解析 args,然后将其传递给 C 级别 @Ariadni:供参考,the code that CALL_FUNCTION is delegating to。您会注意到第一个if 检查是检查PyCFunctions(即用C 实现的CPython 函数),其中nk == 0nk 是关键字的数量)。如果没有关键字传递给多参数函数,它使用通过PyCFunction_Call 的快速路径,并且该路径甚至不会为kwds 构造一个空的dict(通过NULL);通过不传递 kwds,您可以将 dict 构造、C 样式字符串保存到 str 构造/破坏、dict 查找等。【参考方案2】:

这可能只是导致整体放缓的因素的组合。

关键字参数:

当 Python 看到 CALL_FUNCTION 参数时,它会调用 call_function,正如您已经指出的那样。经过一些if 子句后,发出的调用是x = do_call(func, pp_stack, na, nk);。注意这里的nk 包含关键字参数的总count(在enumerate -> kw=1 的情况下)。

do_call 中,您将看到以下if 子句:

if (nk > 0) 
    kwdict = update_keyword_args(NULL, nk, pp_stack, func);
    if (kwdict == NULL)
        goto call_fail;

如果关键字 args 的数量不为零 (nk > 0),请调用 update_keyword_args。 现在,update_keyword_args 符合您的预期,if orig_kwdictNULL(确实如此,请查看对 update_keyword_args 的调用)创建一个新字典:

if (orig_kwdict == NULL)
    kwdict = PyDict_New();

然后使用值堆栈中存在的所有值填充字典:

while (--nk >= 0) 
// copy from stack

这些可能对整体延迟有很大影响。

创建enum 对象:

enum_new 是对的,如果用enumerate([1, 2, 3, 4], start=0) 调用,enum_new 内的变量start 将有一个值,因此是!= NULL。因此,if 子句将评估为 True 并且其中的代码将执行,从而增加调用时间。

if 子句中执行的工作并不是真正繁重的工作,但确实会增加所需的总时间


另外:

    您还需要考虑两个额外的字节码命令,它们可能只是两个,但由于我们正在为非常快的事情计时(在@987654354 的范围内@)。

    同样,从整体角度来看,这无关紧要,但是,解析带有 kws 的呼叫需要像以前一样多一点时间。

最后:

我可能遗漏了一些东西,但总的来说,这些是在创建指定 start 的新枚举对象时共同产生开销的一些因素。

【讨论】:

以上是关于为啥不指定关键字 start 时枚举执行速度较慢?的主要内容,如果未能解决你的问题,请参考以下文章

peewee 和 peewee-async:为啥异步速度较慢

为啥在 Android 4.0 上的 INSERT 和 UPDATE 速度较慢?

为啥使用开发或企业证书签名的 iOS 应用启动速度较慢?

CUDA - 为啥基于扭曲的并行减少速度较慢?

为啥较小的环形缓冲区破坏器速度较慢?

为啥我的 MATLAB 神经网络在使用并行处理时训练速度较慢?