私はソフトウェア/コンピューターサイエンスのバックグラウンドから来たわけではありませんが、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
小さすぎる入力でテストしています。辞書内包表記は、リスト内包表記と比較して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は同じことを行います。
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未満の要素との違いは気にするには小さすぎます。また、グローバルを生成したり、より多くのアイテムを持っている瞬間に、とにかく辞書の理解が勝ちます。