为什么在一个小字符串上迭代比在一个小列表上迭代慢?

我当时在玩 timeit,发现在一个小字符串上做一个简单的列表内涵操作要比在一个小的单个字符串列表上做同样的操作花费更多的时间。有什么解释吗?差不多是1.35倍的时间。

>>> from timeit import timeit
>>> timeit("[x for x in 'abc']")
2.0691067844831528
>>> timeit("[x for x in ['a', 'b', 'c']]")
1.5286479570345861

底层发生了什么导致了这一切?

15504 次浏览

为字符串创建迭代器可能会产生额外的开销。而数组在实例化时已经包含迭代器。

编辑:

>>> timeit("[x for x in ['a','b','c']]")
0.3818681240081787
>>> timeit("[x for x in 'abc']")
0.3732869625091553

这是用2.7运行的,但是在我的 Mac 笔记本 Pro i7上。这可能是系统配置差异的结果。

当您迭代大多数容器对象(列表、元组、字典、 ...)时,迭代器将对象 进去作为容器交付。

但是,当您迭代一个字符串时,必须为所交付的每个字符创建一个 新的对象——字符串不是“容器”,就像列表是容器一样。在迭代创建这些对象之前,字符串中的各个字符并不作为不同的对象存在。

DR

  • 对于 Python2,一旦消除了大量开销,实际的速度差距将接近70% (或更多)。

  • 对象创建是 没有的错误。两个方法都没有创建新的对象,因为缓存的是单字符字符串。

  • 这种差异并不明显,但很可能是由于对字符串索引进行了更多的检查(关于类型和格式良好性)而产生的。这也很可能是由于需要检查返回的内容。

  • 列表索引非常快。



>>> python3 -m timeit '[x for x in "abc"]'
1000000 loops, best of 3: 0.388 usec per loop


>>> python3 -m timeit '[x for x in ["a", "b", "c"]]'
1000000 loops, best of 3: 0.436 usec per loop

这和你的发现不符。

那么您必须使用 Python2。

>>> python2 -m timeit '[x for x in "abc"]'
1000000 loops, best of 3: 0.309 usec per loop


>>> python2 -m timeit '[x for x in ["a", "b", "c"]]'
1000000 loops, best of 3: 0.212 usec per loop

让我们来解释一下版本之间的区别,我将检查编译后的代码。

对于 Python 3:

import dis


def list_iterate():
[item for item in ["a", "b", "c"]]


dis.dis(list_iterate)
#>>>   4           0 LOAD_CONST               1 (<code object <listcomp> at 0x7f4d06b118a0, file "", line 4>)
#>>>               3 LOAD_CONST               2 ('list_iterate.<locals>.<listcomp>')
#>>>               6 MAKE_FUNCTION            0
#>>>               9 LOAD_CONST               3 ('a')
#>>>              12 LOAD_CONST               4 ('b')
#>>>              15 LOAD_CONST               5 ('c')
#>>>              18 BUILD_LIST               3
#>>>              21 GET_ITER
#>>>              22 CALL_FUNCTION            1 (1 positional, 0 keyword pair)
#>>>              25 POP_TOP
#>>>              26 LOAD_CONST               0 (None)
#>>>              29 RETURN_VALUE


def string_iterate():
[item for item in "abc"]


dis.dis(string_iterate)
#>>>  21           0 LOAD_CONST               1 (<code object <listcomp> at 0x7f4d06b17150, file "", line 21>)
#>>>               3 LOAD_CONST               2 ('string_iterate.<locals>.<listcomp>')
#>>>               6 MAKE_FUNCTION            0
#>>>               9 LOAD_CONST               3 ('abc')
#>>>              12 GET_ITER
#>>>              13 CALL_FUNCTION            1 (1 positional, 0 keyword pair)
#>>>              16 POP_TOP
#>>>              17 LOAD_CONST               0 (None)
#>>>              20 RETURN_VALUE

您可以在这里看到,由于每次构建列表,列表变体可能会慢一些。

这是

 9 LOAD_CONST   3 ('a')
12 LOAD_CONST   4 ('b')
15 LOAD_CONST   5 ('c')
18 BUILD_LIST   3

字符串变体只有

 9 LOAD_CONST   3 ('abc')

你可以检查一下,这似乎确实有所不同:

def string_iterate():
[item for item in ("a", "b", "c")]


dis.dis(string_iterate)
#>>>  35           0 LOAD_CONST               1 (<code object <listcomp> at 0x7f4d068be660, file "", line 35>)
#>>>               3 LOAD_CONST               2 ('string_iterate.<locals>.<listcomp>')
#>>>               6 MAKE_FUNCTION            0
#>>>               9 LOAD_CONST               6 (('a', 'b', 'c'))
#>>>              12 GET_ITER
#>>>              13 CALL_FUNCTION            1 (1 positional, 0 keyword pair)
#>>>              16 POP_TOP
#>>>              17 LOAD_CONST               0 (None)
#>>>              20 RETURN_VALUE

这才刚刚生产出来

 9 LOAD_CONST               6 (('a', 'b', 'c'))

因为元组是不可变的。测试:

>>> python3 -m timeit '[x for x in ("a", "b", "c")]'
1000000 loops, best of 3: 0.369 usec per loop

很好,回到正题。

对于 Python 2:

def list_iterate():
[item for item in ["a", "b", "c"]]


dis.dis(list_iterate)
#>>>   2           0 BUILD_LIST               0
#>>>               3 LOAD_CONST               1 ('a')
#>>>               6 LOAD_CONST               2 ('b')
#>>>               9 LOAD_CONST               3 ('c')
#>>>              12 BUILD_LIST               3
#>>>              15 GET_ITER
#>>>         >>   16 FOR_ITER                12 (to 31)
#>>>              19 STORE_FAST               0 (item)
#>>>              22 LOAD_FAST                0 (item)
#>>>              25 LIST_APPEND              2
#>>>              28 JUMP_ABSOLUTE           16
#>>>         >>   31 POP_TOP
#>>>              32 LOAD_CONST               0 (None)
#>>>              35 RETURN_VALUE


def string_iterate():
[item for item in "abc"]


dis.dis(string_iterate)
#>>>   2           0 BUILD_LIST               0
#>>>               3 LOAD_CONST               1 ('abc')
#>>>               6 GET_ITER
#>>>         >>    7 FOR_ITER                12 (to 22)
#>>>              10 STORE_FAST               0 (item)
#>>>              13 LOAD_FAST                0 (item)
#>>>              16 LIST_APPEND              2
#>>>              19 JUMP_ABSOLUTE            7
#>>>         >>   22 POP_TOP
#>>>              23 LOAD_CONST               0 (None)
#>>>              26 RETURN_VALUE

奇怪的是,我们有 一样建筑的名单,但它仍然为此更快。Python2的行动异常迅速。

让我们删除理解和重新计时。 _ =是为了防止它被优化出来。

>>> python3 -m timeit '_ = ["a", "b", "c"]'
10000000 loops, best of 3: 0.0707 usec per loop


>>> python3 -m timeit '_ = "abc"'
100000000 loops, best of 3: 0.0171 usec per loop

我们可以看到,初始化的重要性不足以解释版本之间的差异(这些数字很小) !因此,我们可以得出这样的结论: Python3的理解速度较慢。这是有意义的,因为 Python3改变了理解,使其具有更安全的作用域。

现在改进基准测试(我只是移除了不是迭代的开销)。这会通过预先赋值来移除迭代器的构建:

>>> python3 -m timeit -s 'iterable = "abc"'           '[x for x in iterable]'
1000000 loops, best of 3: 0.387 usec per loop


>>> python3 -m timeit -s 'iterable = ["a", "b", "c"]' '[x for x in iterable]'
1000000 loops, best of 3: 0.368 usec per loop
>>> python2 -m timeit -s 'iterable = "abc"'           '[x for x in iterable]'
1000000 loops, best of 3: 0.309 usec per loop


>>> python2 -m timeit -s 'iterable = ["a", "b", "c"]' '[x for x in iterable]'
10000000 loops, best of 3: 0.164 usec per loop

我们可以检查打电话给 iter是否是开销:

>>> python3 -m timeit -s 'iterable = "abc"'           'iter(iterable)'
10000000 loops, best of 3: 0.099 usec per loop


>>> python3 -m timeit -s 'iterable = ["a", "b", "c"]' 'iter(iterable)'
10000000 loops, best of 3: 0.1 usec per loop
>>> python2 -m timeit -s 'iterable = "abc"'           'iter(iterable)'
10000000 loops, best of 3: 0.0913 usec per loop


>>> python2 -m timeit -s 'iterable = ["a", "b", "c"]' 'iter(iterable)'
10000000 loops, best of 3: 0.0854 usec per loop

不,不是这样的。差别太小了,特别是对于 Python3来说。

因此,让我们移除更多不必要的开销... 通过使整个事情变慢!这样做的目的只是为了有一个更长的迭代,这样时间就隐藏了开销。

>>> python3 -m timeit -s 'import random; iterable = "".join(chr(random.randint(0, 127)) for _ in range(100000))' '[x for x in iterable]'
100 loops, best of 3: 3.12 msec per loop


>>> python3 -m timeit -s 'import random; iterable =        [chr(random.randint(0, 127)) for _ in range(100000)]' '[x for x in iterable]'
100 loops, best of 3: 2.77 msec per loop
>>> python2 -m timeit -s 'import random; iterable = "".join(chr(random.randint(0, 127)) for _ in range(100000))' '[x for x in iterable]'
100 loops, best of 3: 2.32 msec per loop


>>> python2 -m timeit -s 'import random; iterable =        [chr(random.randint(0, 127)) for _ in range(100000)]' '[x for x in iterable]'
100 loops, best of 3: 2.09 msec per loop

这实际上并没有改变 很多,但是有一点帮助。

所以去掉理解,这不是问题的一部分:

>>> python3 -m timeit -s 'import random; iterable = "".join(chr(random.randint(0, 127)) for _ in range(100000))' 'for x in iterable: pass'
1000 loops, best of 3: 1.71 msec per loop


>>> python3 -m timeit -s 'import random; iterable =        [chr(random.randint(0, 127)) for _ in range(100000)]' 'for x in iterable: pass'
1000 loops, best of 3: 1.36 msec per loop
>>> python2 -m timeit -s 'import random; iterable = "".join(chr(random.randint(0, 127)) for _ in range(100000))' 'for x in iterable: pass'
1000 loops, best of 3: 1.27 msec per loop


>>> python2 -m timeit -s 'import random; iterable =        [chr(random.randint(0, 127)) for _ in range(100000)]' 'for x in iterable: pass'
1000 loops, best of 3: 935 usec per loop

这才像话!通过使用 deque迭代,我们可以获得更快的速度。基本上是一样的,但是它是 再快点:

>>> python3 -m timeit -s 'import random; from collections import deque; iterable = "".join(chr(random.randint(0, 127)) for _ in range(100000))' 'deque(iterable, maxlen=0)'
1000 loops, best of 3: 777 usec per loop


>>> python3 -m timeit -s 'import random; from collections import deque; iterable =        [chr(random.randint(0, 127)) for _ in range(100000)]' 'deque(iterable, maxlen=0)'
1000 loops, best of 3: 405 usec per loop
>>> python2 -m timeit -s 'import random; from collections import deque; iterable = "".join(chr(random.randint(0, 127)) for _ in range(100000))' 'deque(iterable, maxlen=0)'
1000 loops, best of 3: 805 usec per loop


>>> python2 -m timeit -s 'import random; from collections import deque; iterable =        [chr(random.randint(0, 127)) for _ in range(100000)]' 'deque(iterable, maxlen=0)'
1000 loops, best of 3: 438 usec per loop

给我留下深刻印象的是 Unicode 与字节串竞争。我们可以通过尝试 bytesunicode来明确地检查这一点:

  • bytes

    >>> python3 -m timeit -s 'import random; from collections import deque; iterable = b"".join(chr(random.randint(0, 127)).encode("ascii") for _ in range(100000))' 'deque(iterable, maxlen=0)'                                                                    :(
    1000 loops, best of 3: 571 usec per loop
    
    
    >>> python3 -m timeit -s 'import random; from collections import deque; iterable =         [chr(random.randint(0, 127)).encode("ascii") for _ in range(100000)]' 'deque(iterable, maxlen=0)'
    1000 loops, best of 3: 394 usec per loop
    
    >>> python2 -m timeit -s 'import random; from collections import deque; iterable = b"".join(chr(random.randint(0, 127))                 for _ in range(100000))' 'deque(iterable, maxlen=0)'
    1000 loops, best of 3: 757 usec per loop
    
    
    >>> python2 -m timeit -s 'import random; from collections import deque; iterable =         [chr(random.randint(0, 127))                 for _ in range(100000)]' 'deque(iterable, maxlen=0)'
    1000 loops, best of 3: 438 usec per loop
    

    在这里您可以看到 Python 3实际上是 再快点,而不是 Python 2。

  • unicode

    >>> python3 -m timeit -s 'import random; from collections import deque; iterable = u"".join(   chr(random.randint(0, 127)) for _ in range(100000))' 'deque(iterable, maxlen=0)'
    1000 loops, best of 3: 800 usec per loop
    
    
    >>> python3 -m timeit -s 'import random; from collections import deque; iterable =         [   chr(random.randint(0, 127)) for _ in range(100000)]' 'deque(iterable, maxlen=0)'
    1000 loops, best of 3: 394 usec per loop
    
    >>> python2 -m timeit -s 'import random; from collections import deque; iterable = u"".join(unichr(random.randint(0, 127)) for _ in range(100000))' 'deque(iterable, maxlen=0)'
    1000 loops, best of 3: 1.07 msec per loop
    
    
    >>> python2 -m timeit -s 'import random; from collections import deque; iterable =         [unichr(random.randint(0, 127)) for _ in range(100000)]' 'deque(iterable, maxlen=0)'
    1000 loops, best of 3: 469 usec per loop
    

    同样,Python3更快,尽管这是意料之中的(str在 Python3中得到了很多关注)。

事实上,这个 unicode-bytes的差异非常小,这是令人印象深刻的。

让我们分析一下这个案子,因为它对我来说既快又方便:

>>> python3 -m timeit -s 'import random; from collections import deque; iterable = "".join(chr(random.randint(0, 127)) for _ in range(100000))' 'deque(iterable, maxlen=0)'
1000 loops, best of 3: 777 usec per loop


>>> python3 -m timeit -s 'import random; from collections import deque; iterable =        [chr(random.randint(0, 127)) for _ in range(100000)]' 'deque(iterable, maxlen=0)'
1000 loops, best of 3: 405 usec per loop

实际上我们可以排除 Tim Peter 的10倍赞成的答案!

>>> foo = iterable[123]
>>> iterable[36] is foo
True

这些不是新的东西!

但是值得一提的是: 对 成本进行索引。不同之处很可能在于索引,所以删除迭代和索引:

>>> python3 -m timeit -s 'import random; iterable = "".join(chr(random.randint(0, 127)) for _ in range(100000))' 'iterable[123]'
10000000 loops, best of 3: 0.0397 usec per loop


>>> python3 -m timeit -s 'import random; iterable =        [chr(random.randint(0, 127)) for _ in range(100000)]' 'iterable[123]'
10000000 loops, best of 3: 0.0374 usec per loop

差别似乎很小,但 至少成本的一半是管理费用:

>>> python3 -m timeit -s 'import random; iterable =        [chr(random.randint(0, 127)) for _ in range(100000)]' 'iterable; 123'
100000000 loops, best of 3: 0.0173 usec per loop

所以速度上的差异足以让我们责怪它,我认为。

那么,为什么索引列表的速度要快得多呢?

好吧,我会回来给你,但我的猜测是,这是下降到检查 被拘留字符串(或缓存字符,如果它是一个单独的机制)。这不会比最佳速度快。但是我会去检查来源(虽然我不舒服在 C。.):).


来源如下:

static PyObject *
unicode_getitem(PyObject *self, Py_ssize_t index)
{
void *data;
enum PyUnicode_Kind kind;
Py_UCS4 ch;
PyObject *res;


if (!PyUnicode_Check(self) || PyUnicode_READY(self) == -1) {
PyErr_BadArgument();
return NULL;
}
if (index < 0 || index >= PyUnicode_GET_LENGTH(self)) {
PyErr_SetString(PyExc_IndexError, "string index out of range");
return NULL;
}
kind = PyUnicode_KIND(self);
data = PyUnicode_DATA(self);
ch = PyUnicode_READ(kind, data, index);
if (ch < 256)
return get_latin1_char(ch);


res = PyUnicode_New(1, ch);
if (res == NULL)
return NULL;
kind = PyUnicode_KIND(res);
data = PyUnicode_DATA(res);
PyUnicode_WRITE(kind, data, 0, ch);
assert(_PyUnicode_CheckConsistency(res, 1));
return res;
}

从上面走,我们会有一些检查。这些太无聊了。然后是一些作业,应该也很无聊。第一句有趣的台词是

ch = PyUnicode_READ(kind, data, index);

但是我们的 希望很快,因为我们是通过索引从一个连续的 C 数组中读取数据。结果 ch将小于256,因此我们将返回 get_latin1_char(ch)中的缓存字符。

因此,我们将运行(放弃第一个检查)

kind = PyUnicode_KIND(self);
data = PyUnicode_DATA(self);
ch = PyUnicode_READ(kind, data, index);
return get_latin1_char(ch);

在哪里

#define PyUnicode_KIND(op) \
(assert(PyUnicode_Check(op)), \
assert(PyUnicode_IS_READY(op)),            \
((PyASCIIObject *)(op))->state.kind)

(这很无聊,因为在调试中断言被忽略了[所以我可以检查它们是否快速] ,而且 ((PyASCIIObject *)(op))->state.kind)(我认为)是一个间接转换和 C 级强制转换) ;

#define PyUnicode_DATA(op) \
(assert(PyUnicode_Check(op)), \
PyUnicode_IS_COMPACT(op) ? _PyUnicode_COMPACT_DATA(op) :   \
_PyUnicode_NONCOMPACT_DATA(op))

(假设宏(Something_CAPITALIZED)都是快速的,出于类似的原因,这也很无聊) ,

#define PyUnicode_READ(kind, data, index) \
((Py_UCS4) \
((kind) == PyUnicode_1BYTE_KIND ? \
((const Py_UCS1 *)(data))[(index)] : \
((kind) == PyUnicode_2BYTE_KIND ? \
((const Py_UCS2 *)(data))[(index)] : \
((const Py_UCS4 *)(data))[(index)] \
) \
))

(其中包括索引,但实际上一点也不慢)和

static PyObject*
get_latin1_char(unsigned char ch)
{
PyObject *unicode = unicode_latin1[ch];
if (!unicode) {
unicode = PyUnicode_New(1, ch);
if (!unicode)
return NULL;
PyUnicode_1BYTE_DATA(unicode)[0] = ch;
assert(_PyUnicode_CheckConsistency(unicode, 1));
unicode_latin1[ch] = unicode;
}
Py_INCREF(unicode);
return unicode;
}

这证实了我的猜测:

  • 这是缓存的:

    PyObject *unicode = unicode_latin1[ch];
    
  • This should be fast. The if (!unicode) is not run, so it's literally equivalent in this case to

    PyObject *unicode = unicode_latin1[ch];
    Py_INCREF(unicode);
    return unicode;
    

Honestly, after testing the asserts are fast (by disabling them [I think it works on the C-level asserts...]), the only plausibly-slow parts are:

PyUnicode_IS_COMPACT(op)
_PyUnicode_COMPACT_DATA(op)
_PyUnicode_NONCOMPACT_DATA(op)

它们是:

#define PyUnicode_IS_COMPACT(op) \
(((PyASCIIObject*)(op))->state.compact)

(快,像以前一样) ,

#define _PyUnicode_COMPACT_DATA(op)                     \
(PyUnicode_IS_ASCII(op) ?                   \
((void*)((PyASCIIObject*)(op) + 1)) :              \
((void*)((PyCompactUnicodeObject*)(op) + 1)))

(如果宏 IS_ASCII是快速的,则为快速) ,以及

#define _PyUnicode_NONCOMPACT_DATA(op)                  \
(assert(((PyUnicodeObject*)(op))->data.any),        \
((((PyUnicodeObject *)(op))->data.any)))

(同样快,因为它是一个断言加上一个间接加上一个强制转换)。

因此,我们陷入了(兔子洞) :

PyUnicode_IS_ASCII

也就是

#define PyUnicode_IS_ASCII(op)                   \
(assert(PyUnicode_Check(op)),                \
assert(PyUnicode_IS_READY(op)),             \
((PyASCIIObject*)op)->state.ascii)

这也太快了吧。


嗯,好吧,但是让我们把它和 PyList_GetItem比较一下。(是的,谢谢蒂姆 · 彼得斯给了我更多的工作要做: P。)

PyObject *
PyList_GetItem(PyObject *op, Py_ssize_t i)
{
if (!PyList_Check(op)) {
PyErr_BadInternalCall();
return NULL;
}
if (i < 0 || i >= Py_SIZE(op)) {
if (indexerr == NULL) {
indexerr = PyUnicode_FromString(
"list index out of range");
if (indexerr == NULL)
return NULL;
}
PyErr_SetObject(PyExc_IndexError, indexerr);
return NULL;
}
return ((PyListObject *)op) -> ob_item[i];
}

我们可以看到,在非错误情况下,这只是运行:

PyList_Check(op)
Py_SIZE(op)
((PyListObject *)op) -> ob_item[i]

PyList_Check在哪里

#define PyList_Check(op) \
PyType_FastSubclass(Py_TYPE(op), Py_TPFLAGS_LIST_SUBCLASS)

(TABS! TABS! ! )(第21587期) 那个被修复并且合并在 五分钟。就像... 是的。该死的。他们让 Skeet 感到羞耻。

#define Py_SIZE(ob)             (((PyVarObject*)(ob))->ob_size)
#define PyType_FastSubclass(t,f)  PyType_HasFeature(t,f)
#ifdef Py_LIMITED_API
#define PyType_HasFeature(t,f)  ((PyType_GetFlags(t) & (f)) != 0)
#else
#define PyType_HasFeature(t,f)  (((t)->tp_flags & (f)) != 0)
#endif

因此,这通常是非常琐碎的(两个间接检查和两个布尔检查) ,除非 Py_LIMITED_API开启,在这种情况下... ? ? ?

然后是索引和转换(((PyListObject *)op) -> ob_item[i]) ,我们就完成了。

因此,肯定有 更少检查列表,和小的速度差异肯定意味着它可能是相关的。


我认为一般来说,Unicode 有更多的类型检查和间接 (->)。我好像漏掉了一点,但是 什么