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を取得するためにルックアップを行う必要があります。2 番目のソリューションは、タプルにパックする必要があるキーと値のペアを反復するだけです。valuekeyOrderedDictionary

2 番目のソリューションは 2 倍遅くなります。

何故ですか?

私は最近、このビデオを見ました。レイモンド ヘッティンガーは、Python はタプルを再利用する傾向があるため、余分な割り当てはないと述べています。

では、このパフォーマンスの問題は何に帰着するのでしょうか?


なぜ私が質問したのか、少し詳しく説明したいと思います。

最初のソリューションには辞書検索があります。これkeyは、ハッシュを取得し、このハッシュでビンを見つけ、そのビンからキーを取得し (キーの衝突がないことを願っています)、そのキーに関連付けられた値を取得することを意味します。

2 番目のソリューションは、すべてのビンを通過し、それらのビン内のすべてのキーを生成します。どのビンを取るかという計算のオーバーヘッドなしに、すべてのビンを 1 つずつ通過します。はい、それらのキーに関連付けられた値にアクセスする必要がありますが、値はキーから 1 ステップしか離れていませんが、最初のソリューションは、必要なときに値を取得するために hash-bin-key-value チェーンを経由する必要があります。各ソリューションは値を取得する必要があります。最初のソリューションは hash-bin-key-value チェーンを介して取得し、2 つ目はキーにアクセスするときにもう 1 つのポインターに続いて値を取得します。2 番目のソリューションの唯一のオーバーヘッドは、この値をキーと共に一時タプルに格納する必要があることです。この格納がオーバーヘッドの主な原因であることが判明しました。いわゆる「タプルの再利用」があるという事実を考えると、なぜそうなのかはまだ完全にはわかりません

私の考えでは、2 番目の解決策はキーと共に値を保存する必要がありますが、ハッシュ ビン キーの計算と、そのキーの値を取得するためのアクセスを行う必要がなくなります。

4

4 に答える 4

6

パフォーマンスの違いは、主にOrderedDict.
OrderedDictdictgetandを使用しています__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].
2 番目の解決策は、ハッシュ ビン キーの計算を回避するのに役立ちませんでした。によって生成された反復子はdict、より正確にはitems.iteritems().next()ifitemsが adictである場合、その計算を行いません。

また、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_iternextvalue, iteritems'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 が最初からいくつかのタプルを作成しないことを意味します。しかし、やるべきことはまだたくさんあります。


ケース: dict

OrderedDictが に置き換えられた場合dict、一般的には 2 番目の解決策の方がわずかに優れていると思います。
Python 辞書は、ハッシュ テーブルを使用して実装されます。そのため、検索は高速です。検索の平均時間の複雑さは O(1) ですが、最悪の場合は O(n) 1です。最初の解の平均時間計算量は、2 番目の解の時間計算量と同じです。どちらも O(n) です。したがって、2 番目のソリューションには利点がなく、特に入力データが小さい場合はさらに遅くなることがあります。その場合、発生した追加費用は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

最後に、2 番目のソリューションが輝き始めます。


最初の解決策の最悪のケース:ハッシュ衝突

させて

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 オブジェクトについてリストされている平均ケース時間は、オブジェクトのハッシュ関数が十分に堅牢であり、衝突が一般的ではないことを前提としています。Average Case は、パラメータで使用されるキーがすべてのキーのセットから一様にランダムに選択されることを前提としています。TimeComplexityを参照してください。

于 2013-07-24T22:01:33.380 に答える
2

ご自身がおっしゃる通り、機能の違いがあります。

最初の関数が文字列のリストを反復処理する場合、文字列ごとに辞書を参照して値を取得し、最小値を見つけて返します。

2 番目の関数は、string/int ペアのタプルを繰り返し処理します。次に、それぞれについて 2 番目の項目 (int/value) にアクセスし、最小値 (この場合はタプル) を見つけて、その結果の最初の項目を返します。

2 番目の関数は、より多くの処理を必要とするオブジェクトに対して (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 に答える