8

回答这个问题时,我遇到了一个有趣的情况 2 个类似的代码片段的执行方式完全不同。我在这里问只是为了了解原因并提高我对这种情况的直觉。

我将为 Python 2.7 改编代码片段(在 Python 3 中,性能差异是相同的)。

from collections import OrderedDict
from operator import itemgetter
from itertools import izip

items = OrderedDict([('a', 10), ('b', 9), ('c', 4), ('d', 7), ('e', 3), ('f', 0), ('g', -5), ('h', 9)])

def f1():
    return min(items, key=items.get)

def f2():
    return min(items.iteritems(), key=itemgetter(1))[0]


from timeit import Timer
N = 100000

print(Timer(stmt='f1()', setup='from __main__ import f1').timeit(number = N))
print(Timer(stmt='f2()', setup='from __main__ import f2').timeit(number = N))

输出:

0.603327797248
1.21580172899

第一个解决方案必须进行查找OrderedDictionary以获取value每个key. 第二种解决方案只是遍历OrderedDictionary键值对,它们必须打包成元组。

第二种解决方案慢了 2 倍。

这是为什么?

我最近看了这个视频,Raymond Hettinger 说 Python 倾向于重用元组,所以没有额外的分配。

那么,这个性能问题归结为什么呢?


我想详细说明我为什么要问。

第一个解决方案有字典查找。这意味着获取key哈希,然后通过该哈希找到 bin,然后从该 bin 中获取密钥(希望不会有密钥冲突),然后获取与该密钥关联的值。

第二种解决方案只是遍历所有 bin 并产生这些 bin 中的所有密钥。它一个接一个地遍历所有的箱子,而无需计算要采用哪个箱子的开销。是的,它必须访问与这些键关联的值,但该值距离键仅一步之遥,而第一个解决方案必须通过 hash-bin-key-value 链在需要时获取值。每个解决方案都必须获取值,第一个解决方案通过 hash-bin-key-value 链获取它,第二个解决方案在访问 key 时跟随一个指针。第二种解决方案的唯一开销是它必须将此值与键一起临时存储在元组中。事实证明,这种存储是开销的主要原因。鉴于存在所谓的“元组重用”这一事实,我仍然不完全理解为什么会这样

在我看来,第二种解决方案必须与键一起保存值,但它避免了我们必须进行哈希-bin-键计算和访问以获取该键的值。

4

4 回答 4

6

性能差异主要是由OrderedDict.
OrderedDict使用dict's getand __getitem__,但重新定义了自己的__iter__and iteritems


    def __iter__(self):
        'od.__iter__()  iter(od)'
        # Traverse the linked list in order.
        root = self.__root
        curr = root[1]                                  # start at the first node
        while curr is not root:
            yield curr[2]                               # yield the curr[KEY]
            curr = curr[1]                              # move to next node

    def iteritems(self):
        'od.iteritems -> an iterator over the (key, value) pairs in od'
        for k in self:
            yield (k, self[k])

看看我们发现了什么:self[k]
您的第二个解决方案没有帮助我们避免 hash-bin-key 计算。虽然由 生成的迭代器dict,更准确地说,items.iteritems().next()如果items是 a dict,则不会进行该计算。

而且,iteritems也比较贵。

from timeit import Timer
N = 1000

d = {i:i for i in range(10000)}

def f1():
    for k in d: pass

def f2():
    for k in d.iterkeys(): pass

def f3():
    for v in d.itervalues(): pass

def f4():
    for t in d.iteritems(): pass

print(Timer(stmt='f1()', setup='from __main__ import f1').timeit(number=N))
print(Timer(stmt='f2()', setup='from __main__ import f2').timeit(number=N))
print(Timer(stmt='f3()', setup='from __main__ import f3').timeit(number=N))
print(Timer(stmt='f4()', setup='from __main__ import f4').timeit(number=N))

输出

0.256800375467
0.265079360645
0.260599391822
0.492333103788

iterkeys'dictiter_iternextkeyitervalues'相比dictiter_iternextvalueiteritems'dictiter_iternextitem有额外的部分。


    if (result->ob_refcnt == 1) {
        Py_INCREF(result);
        Py_DECREF(PyTuple_GET_ITEM(result, 0));
        Py_DECREF(PyTuple_GET_ITEM(result, 1));
    } else {
        result = PyTuple_New(2);
        if (result == NULL)
            return NULL;
    }
    di->len--;
    key = ep[i].me_key;
    value = ep[i].me_value;
    Py_INCREF(key);
    Py_INCREF(value);
    PyTuple_SET_ITEM(result, 0, key);
    PyTuple_SET_ITEM(result, 1, value);

我认为创建元组可能会降低性能。

Python 确实倾向于重用元组。
tupleobject.c节目

/* Speed optimization to avoid frequent malloc/free of small tuples */
#ifndef PyTuple_MAXSAVESIZE
#define PyTuple_MAXSAVESIZE     20  /* Largest tuple to save on free list */
#endif
#ifndef PyTuple_MAXFREELIST
#define PyTuple_MAXFREELIST  2000  /* Maximum number of tuples of each size to save */
#endif

这种优化只是意味着 Python 不会从头开始构建一些元组。但是还有很多工作要做。


案例:字典

如果OrderedDict替换为dict,我认为第二种解决方案总体上要好一些。
Python 字典是使用哈希表实现的。所以查找速度很快。查找的平均时间复杂度是 O(1),而最差的是 O(n) 1。第一个解决方案的平均时间复杂度与第二个解决方案的时间复杂度相同。它们都是 O(n)。因此,第二种解决方案没有任何优势,有时甚至更慢,尤其是在输入数据较小的情况下。在这种情况下,造成的额外费用iteritems无法得到补偿。

from collections import OrderedDict
from operator import itemgetter
from timeit import Timer
from random import randint, random

N = 100000
xs = [('a', 10), ('b', 9), ('c', 4), ('d', 7), ('e', 3), ('f', 0), ('g', -5), ('h', 9)]

od = OrderedDict(xs)
d = dict(xs)

def f1od_min():
    return min(od, key=od.get)

def f2od_min():
    return min(od.iteritems(), key=itemgetter(1))[0]

def f1d_min():
    return min(d, key=d.get)

def f2d_min():
    return min(d.iteritems(), key=itemgetter(1))[0]

def f1od():
    for k in od: pass

def f2od():
    for t in od.iteritems(): pass

def f1d():
    for k in d: pass

def f2d():
    for t in d.iteritems(): pass

print 'min'
print(Timer(stmt='f1od_min()', setup='from __main__ import f1od_min').timeit(number=N))
print(Timer(stmt='f2od_min()', setup='from __main__ import f2od_min').timeit(number=N))
print(Timer(stmt='f1d_min()', setup='from __main__ import f1d_min').timeit(number=N))
print(Timer(stmt='f2d_min()', setup='from __main__ import f2d_min').timeit(number=N))
print
print 'traverse'
print(Timer(stmt='f1od()', setup='from __main__ import f1od').timeit(number=N))
print(Timer(stmt='f2od()', setup='from __main__ import f2od').timeit(number=N))
print(Timer(stmt='f1d()', setup='from __main__ import f1d').timeit(number=N))
print(Timer(stmt='f2d()', setup='from __main__ import f2d').timeit(number=N))

输出

min
0.398274431527
0.813040903243
0.185168156847
0.249574387248    <-- dict/the second solution

traverse
0.251634216081
0.642283865687
0.0565099754298
0.0958057518483

然后替换N并由xs

N = 50
xs = [(x, randint(1, 100)) for x in range(100000)]

输出

min
1.5148923257
3.47020082161
0.712828585756
0.70823812803    <-- dict/the second solution

traverse
0.975989336634
2.92283956481
0.127676073356
0.253622387762

现在替换Nxs

N = 10
xs = [(random(), random()) for x in range(1000000)]

输出

min
6.23311265817
10.702984667
4.32852708934
2.87853889251    <-- dict/the second solution

traverse
2.06231783648
9.49360449443
1.33297618831
1.73723008092

最后,第二种解决方案开始大放异彩。


第一个解决方案的最坏情况:哈希冲突

N = 10000
xs = [(2 ** (32 + x) - 2 ** x + 1, 1) for x in range(100)]
# hash(2 ** (32 + x) - 2 ** x + 1) is always 1

输出

min
2.44175265292    <-- lookup is slow
2.76424538594    <-- lookup is slow
2.26508627493    <-- lookup is slow
0.199363955475

traverse
0.200654482623
2.59635966303    <-- lookup is slow
0.0454684184722
0.0733798569371

1为 dict 对象列出的平均案例时间假定对象的散列函数足够稳健以使冲突不常见。平均情况假设参数中使用的键是从所有键的集合中均匀随机选择的。请参阅时间复杂度

于 2013-07-24T22:01:33.380 回答
3

对于元组重用,我不相信:

>>> a = (1,2)
>>> b = (1,2)
>>> id(a)
139912909456232
>>> id(b)
139912909456304
>>> 

从 int 或 string 可以看出:

>>> a = 1
>>> b = 1
>>> id(a)
34961336
>>> id(b)
34961336
>>> 
>>> a = 'a'
>>> b = 'a'
>>> id(a)
139912910202240
>>> id(b)
139912910202240
>>> 

编辑:

对于dict,你的两种方法是相似的。我们试试看:

>>> a = {'a':1, 'b':2, 'c':3}
>>> N = 100000
# really quick to use []
>>> Timer(stmt='for x in a: z = a[x]', setup='from __main__ import a').timeit(number=N)
0.0524289608001709
# use get method
>>> Timer(stmt='for x in a: z = a.get(x)', setup='from __main__ import a').timeit(number=N)
0.10028195381164551
# use iterator and []
>>> Timer(stmt='for x in a.iteritems(): z = x[1]', setup='from __main__ import a').timeit(number=N)
0.08019709587097168
# use itemgetter and iterator
>>> b = itemgetter(1)
>>> Timer(stmt='for x in a.iteritems(): z = b(x)', setup='from __main__ import a, b').timeit(number=N)
0.09941697120666504

虽然时间可能会改变,但它们总体上是准确的。使用iteritems和 和itemgetter一样快get

但是对于OrderedDict,让我们再试一次:

>>> a
OrderedDict([('a', 1), ('c', 3), ('b', 2)])
>>> N = 100000
#Use []
>>> Timer(stmt='for x in a: z = a[x]', setup='from __main__ import a').timeit(number=N)
0.2354598045349121
#Use get
>>> Timer(stmt='for x in a: z = a.get(x)', setup='from __main__ import a').timeit(number=N)
0.21950387954711914
#Use iterator
>>> Timer(stmt='for x in a.iteritems(): z = x[1]', setup='from __main__ import a').timeit(number=N)
0.29949188232421875
#Use iterator and itemgetter
>>> b = itemgetter(1)
>>> Timer(stmt='for x in a.iteritems(): z = b(x)', setup='from __main__ import a, b').timeit(number=N)
0.32039499282836914

您可以看到,对于OrderedDict,使用get和一个使用iterator,并itemgetter随时间变化。

所以,我认为时间差异是因为OrderedDict. 但很抱歉,我不知道为什么。

于 2013-07-14T14:52:33.513 回答
2

正如您自己提到的,功能之间存在差异。

第一个函数迭代一个字符串列表,对于每个字符串,它都会进入字典并查找它以获取值,然后找到最小值并返回。

第二个函数迭代字符串/整数对的元组。然后对于每一个它访问第二个项目(int / value),然后找到最小值(在这种情况下是一个元组),然后它返回第一个项目的结果。

第二个函数在需要更多处理的对象上做更多的工作, (tuples > strings) 然后 (tuples > ints) ,加上额外的项目检索。

你为什么惊讶?

于 2013-07-14T14:35:19.573 回答
1

扩展我之前的答案。为了更好地了解正在发生的事情,您可以随时使用该dis模块。

>>> import dis
>>> dis.dis(f1)
              0 LOAD_GLOBAL              0 (min)
              3 LOAD_GLOBAL              1 (items)
              6 LOAD_CONST               1 ('key')
              9 LOAD_GLOBAL              1 (items)
             12 LOAD_ATTR                2 (get)
             15 CALL_FUNCTION          257
             18 RETURN_VALUE    
>>> dis.dis(f2)
              0 LOAD_GLOBAL              0 (min)
              3 LOAD_GLOBAL              1 (items)
              6 LOAD_ATTR                2 (iteritems)
              9 CALL_FUNCTION            0
             12 LOAD_CONST               1 ('key')
             15 LOAD_GLOBAL              3 (itemgetter)
             18 LOAD_CONST               2 (1)
             21 CALL_FUNCTION            1
             24 CALL_FUNCTION          257
             27 LOAD_CONST               3 (0)
             30 BINARY_SUBSCR       
             31 RETURN_VALUE   

如您所见,发生了更多事情f2(因此有理由认为它更慢)

您总是可以使用该dis模块来检查 Python 中的任何内容,它通常可以很好地表明哪些内容会表现得更好。


人们总是可以使用该timeit模块来检查某些方法或函数在特定类型的输入上执行时的时序或性能,但有时时序可能会关闭,因为所使用的数据样本集比另一个更适合某个函数,例如,在检查排序函数时,已经排序最多的列表会偏爱某种类型的函数而不是另一个可能更快地对排序较少的列表进行排序的函数,但是这些都没有考虑到不同类型的数据可能自己在列表中。使用该dis模块可以避免大部分情况,因为它能够直接看到 Python 在幕后(或引擎盖下)正在做什么,这可以更清楚地指示哪种方法可能最适合执行某些任务

于 2013-07-23T09:37:19.400 回答