web-dev-qa-db-ja.com

辞書を作成するために、このループが辞書の理解よりも速いのはなぜですか?

私はソフトウェア/コンピューターサイエンスのバックグラウンドから来たわけではありませんが、Pythonでコーディングするのが大好きであり、一般に高速化の理由を理解できます。このforループが辞書の理解よりも速く実行される理由を知りたいと思います。洞察はありますか?

問題:これらのキーと値を持つ辞書aが与えられた場合、値をキーとして、キーを値として辞書を返します。 (チャレンジ:これを1行で行います)

そしてコード

a = {'a':'hi','b':'hey','c':'yo'}

b = {}
for i,j in a.items():
    b[j]=i

%% timeit 932 ns ± 37.2 ns per loop

b = {v: k for k, v in a.items()}

%% timeit 1.08 µs ± 16.4 ns per loop
39
Nadim Younes

小さすぎる入力でテストしています。辞書内包表記は、リスト内包表記と比較してforループに比べてパフォーマンス上の利点があまりありませんが、現実的な問題サイズでは、特にグローバルを対象とする場合、forループよりも優れています。名前。

入力は、わずか3つのキーと値のペアで構成されます。代わりに1000個の要素でテストすると、代わりにタイミングが非常に近いことがわかります。

>>> import timeit
>>> from random import choice, randint; from string import ascii_lowercase as letters
>>> looped = '''\
... b = {}
... for i,j in a.items():
...     b[j]=i
... '''
>>> dictcomp = '''b = {v: k for k, v in a.items()}'''
>>> def rs(): return ''.join([choice(letters) for _ in range(randint(3, 15))])
...
>>> a = {rs(): rs() for _ in range(1000)}
>>> len(a)
1000
>>> count, total = timeit.Timer(looped, 'from __main__ import a').autorange()
>>> (total / count) * 1000000   # microseconds per run
66.62004760000855
>>> count, total = timeit.Timer(dictcomp, 'from __main__ import a').autorange()
>>> (total / count) * 1000000   # microseconds per run
64.5464928005822

違いはありますが、dict compは高速ですが、このスケールではjustのみです。 100倍のキーと値のペアでは、違いは少し大きくなります。

>>> a = {rs(): rs() for _ in range(100000)}
>>> len(a)
98476
>>> count, total = timeit.Timer(looped, 'from __main__ import a').autorange()
>>> total / count * 1000  # milliseconds, different scale!
15.48140200029593
>>> count, total = timeit.Timer(dictcomp, 'from __main__ import a').autorange()
>>> total / count * 1000  # milliseconds, different scale!
13.674790799996117

that処理されたキーと値のペアの両方がほぼ100,000であると考えると、大きな違いではありません。それでも、forループは明らかにslowerです。

では、なぜ3つの要素の速度の違いですか?内包表記(辞書、セット、リスト内包表記またはジェネレーター式)は、新しいfunctionとして実装され、その関数の呼び出しにはベースがありますプレーンループの費用はかかりません。

両方の選択肢のバイトコードの逆アセンブリを次に示します。 dict内包表記の最上位バイトコードのMAKE_FUNCTIONおよびCALL_FUNCTIONオペコードに注意してください。その関数が行うことについては別のセクションがあり、実際には2つのアプローチの違いはほとんどありません:

>>> import dis
>>> dis.dis(looped)
  1           0 BUILD_MAP                0
              2 STORE_NAME               0 (b)

  2           4 SETUP_LOOP              28 (to 34)
              6 LOAD_NAME                1 (a)
              8 LOAD_METHOD              2 (items)
             10 CALL_METHOD              0
             12 GET_ITER
        >>   14 FOR_ITER                16 (to 32)
             16 UNPACK_SEQUENCE          2
             18 STORE_NAME               3 (i)
             20 STORE_NAME               4 (j)

  3          22 LOAD_NAME                3 (i)
             24 LOAD_NAME                0 (b)
             26 LOAD_NAME                4 (j)
             28 STORE_SUBSCR
             30 JUMP_ABSOLUTE           14
        >>   32 POP_BLOCK
        >>   34 LOAD_CONST               0 (None)
             36 RETURN_VALUE
>>> dis.dis(dictcomp)
  1           0 LOAD_CONST               0 (<code object <dictcomp> at 0x11d6ade40, file "<dis>", line 1>)
              2 LOAD_CONST               1 ('<dictcomp>')
              4 MAKE_FUNCTION            0
              6 LOAD_NAME                0 (a)
              8 LOAD_METHOD              1 (items)
             10 CALL_METHOD              0
             12 GET_ITER
             14 CALL_FUNCTION            1
             16 STORE_NAME               2 (b)
             18 LOAD_CONST               2 (None)
             20 RETURN_VALUE

Disassembly of <code object <dictcomp> at 0x11d6ade40, file "<dis>", line 1>:
  1           0 BUILD_MAP                0
              2 LOAD_FAST                0 (.0)
        >>    4 FOR_ITER                14 (to 20)
              6 UNPACK_SEQUENCE          2
              8 STORE_FAST               1 (k)
             10 STORE_FAST               2 (v)
             12 LOAD_FAST                1 (k)
             14 LOAD_FAST                2 (v)
             16 MAP_ADD                  2
             18 JUMP_ABSOLUTE            4
        >>   20 RETURN_VALUE

重要な違い:ループコードでは、bごとにLOAD_NAMEを使用し、読み込まれたdictにキーと値のペアを格納するためにSTORE_SUBSCRを使用します。辞書内包表記はMAP_ADDを使用してSTORE_SUBSCRと同じことを実現しますが、そのたびにb名をロードする必要はありません。

ただし、3回の反復のみでは、dict内包表記を実行する必要があるMAKE_FUNCTION/CALL_FUNCTIONコンボは、パフォーマンスの真のドラッグです。 :

>>> make_and_call = '(lambda i: None)(None)'
>>> dis.dis(make_and_call)
  1           0 LOAD_CONST               0 (<code object <lambda> at 0x11d6ab270, file "<dis>", line 1>)
              2 LOAD_CONST               1 ('<lambda>')
              4 MAKE_FUNCTION            0
              6 LOAD_CONST               2 (None)
              8 CALL_FUNCTION            1
             10 RETURN_VALUE

Disassembly of <code object <lambda> at 0x11d6ab270, file "<dis>", line 1>:
  1           0 LOAD_CONST               0 (None)
              2 RETURN_VALUE
>>> count, total = timeit.Timer(make_and_call).autorange()
>>> total / count * 1000000
0.12945385499915574

0.1μs以上で、1つの引数を持つ関数オブジェクトを作成し、呼び出します(渡すNone値にLOAD_CONSTを追加)。そして、それはちょうど3つのキーと値のペアのループと理解のタイミングの違いについてです。

これは、シャベルを持っている人がバックホウよりも速く小さな穴を掘ることができることに驚くことにたとえることができます。バックホウは確かに速く掘ることができますが、シャベルを持っている人は、バックホウを開始して最初に位置に移動する必要がある場合、より早く開始できます!

いくつかのキーと値のペア(大きな穴を掘る)を超えると、関数createおよびcallのコストはゼロになります。この時点で、dictの理解と明示的なループbasicallyは同じことを行います。

  • 次のキーと値のペアを取得し、それらをスタックにポップします
  • スタックの上位2つの項目(dict.__setitem__またはSTORE_SUBSCRのいずれか)でバイトコード操作を介してMAP_ADDフックを呼び出します。これはすべて内部で処理されるため、「関数呼び出し」としてはカウントされませんインタプリタループ内。

これは、リストの内包表記とは異なり、プレーンループバージョンでは、属性検索と関数呼び出しを含むlist.append()を使用する必要があります。各ループの繰り返し。リストの理解速度の利点は、この違いにあります。 Pythonリストの理解に費用がかかる を参照

Dict内包表記が追加するのは、bを最終的な辞書オブジェクトにバインドするときに、ターゲット辞書名を1回検索するだけでよいということです。ターゲット辞書がローカル変数の代わりにglobalである場合、理解が勝ち、伝わります:

>>> a = {rs(): rs() for _ in range(1000)}
>>> len(a)
1000
>>> namespace = {}
>>> count, total = timeit.Timer(looped, 'from __main__ import a; global b', globals=namespace).autorange()
>>> (total / count) * 1000000
76.72348440100905
>>> count, total = timeit.Timer(dictcomp, 'from __main__ import a; global b', globals=namespace).autorange()
>>> (total / count) * 1000000
64.72114819916897
>>> len(namespace['b'])
1000

だから、辞書の理解を使用してください。処理する30未満の要素との違いは気にするには小さすぎます。また、グローバルを生成したり、より多くのアイテムを持っている瞬間に、とにかく辞書の理解が勝ちます。

70
Martijn Pieters