簡體   English   中英

為什么這個循環比創建字典的字典理解更快?

[英]Why is this loop faster than a dictionary comprehension for creating a dictionary?

我不是來自軟件/計算機科學背景,但我喜歡用Python編寫代碼,並且通常可以理解為什么事情變得更快。 我真的很想知道為什么這個for循環比字典理解運行得更快。 任何見解?

問題:給定帶有這些鍵和值的字典a ,返回一個字典,其值為鍵,鍵為值。 (挑戰:在一行中做到這一點)

和代碼

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更快,但只是在這個范圍內。 鍵值對的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

當您考慮處理近100k鍵值對時這並沒有那么大的區別。 仍然, for循環顯然較慢

那么為什么3個元素的速度差異呢? 因為理解(字典,集合,列表推導或生成器表達式)是作為新函數實現的 ,並且調用該函數具有基本開銷,所以普通循環不必支付。

這是兩種替代方案的字節碼的反匯編; 請注意dict理解的頂級字節碼中的MAKE_FUNCTIONCALL_FUNCTION操作碼,有一個單獨的部分來說明該函數的作用,這兩種方法實際上差別很小:

>>> 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

材料差異:循環代碼在每次迭代時使用LOAD_NAME進行b ,並使用STORE_SUBSCR在加載的dict中存儲鍵值對。 字典理解使用MAP_ADD來實現與STORE_SUBSCR相同的功能,但不必每次都加載該b名稱。

但只有3次迭代MAKE_FUNCTION / CALL_FUNCTION組合dict理解必須執行才是對性能的真正拖累:

>>> 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用一個參數創建一個函數對象,然后調用它(我們傳入的None值有一個額外的LOAD_CONST )! 這就是3個鍵值對的循環和理解時序之間的差異。

你可以把這比作驚訝,一個拿着鏟子的人可以比反鏟挖掘機更快地挖一個小洞。 反鏟挖掘機當然可以快速挖掘,但是如果你需要開始反鏟並首先移動到位,那么帶鏟子的人可以更快地開始!

除了幾個鍵值對(挖掘更大的洞)之外,功能創建和調用成本逐漸消失。 在這一點上,字典理解和顯式循環基本上做同樣的事情:

  • 獲取下一個鍵值對,彈出堆棧中的那些鍵
  • 通過字節碼操作調用dict.__setitem__ hook與堆棧中的前兩項( STORE_SUBSCRMAP_ADD 。這不算作'函數調用',因為它在解釋器循環中全部內部處理。

這與列表list.append()不同,其中普通循環版本必須使用list.append() ,涉及屬性查找,以及函數調用每個循環迭代 列表理解速度優勢來自於這種差異; 看看Python列表理解昂貴

dict理解添加的是,當將b綁定到最終字典對象時,只需要查找目標字典名稱一次。 如果目標字典是全局變量而不是局部變量,那么理解就會獲勝,請放下:

>>> 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個元素的差異太小而無法關注,並且當您生成全局或有更多項目時,dict理解無論如何都會勝出。

從某種意義上說,這個問題非常類似於為什么列表理解比附加到列表要快得多? 我很久以前就回答過了。 但是,這種行為令你感到驚訝的原因顯然是因為你的字典太小而無法克服創建新功能框架並在堆棧中推/拉它的成本。 要了解更好的讓我們在你所擁有的兩個片段的皮膚下:

In [1]: a = {'a':'hi','b':'hey','c':'yo'}
   ...: 
   ...: def reg_loop(a):
   ...:     b = {}
   ...:     for i,j in a.items():
   ...:         b[j]=i
   ...:         

In [2]: def dict_comp(a):
   ...:     b = {v: k for k, v in a.items()}
   ...:     

In [3]: 

In [3]: %timeit reg_loop(a)
529 ns ± 7.89 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)

In [4]: 

In [4]: %timeit dict_comp(a)
656 ns ± 5.39 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)

In [5]: 

In [5]: import dis

In [6]: dis.dis(reg_loop)
  4           0 BUILD_MAP                0
              2 STORE_FAST               1 (b)

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

  6          22 LOAD_FAST                2 (i)
             24 LOAD_FAST                1 (b)
             26 LOAD_FAST                3 (j)
             28 STORE_SUBSCR
             30 JUMP_ABSOLUTE           14
        >>   32 POP_BLOCK
        >>   34 LOAD_CONST               0 (None)
             36 RETURN_VALUE

In [7]: 

In [7]: dis.dis(dict_comp)
  2           0 LOAD_CONST               1 (<code object <dictcomp> at 0x7fbada1adf60, file "<ipython-input-2-aac022159794>", line 2>)
              2 LOAD_CONST               2 ('dict_comp.<locals>.<dictcomp>')
              4 MAKE_FUNCTION            0
              6 LOAD_FAST                0 (a)
              8 LOAD_METHOD              0 (items)
             10 CALL_METHOD              0
             12 GET_ITER
             14 CALL_FUNCTION            1
             16 STORE_FAST               1 (b)
             18 LOAD_CONST               0 (None)
             20 RETURN_VALUE

在第二個反匯編代碼(dict comprehension)中,你有一個MAKE_FUNCTION操作碼,因為它在文檔中也說明了在堆棧上推送一個新的函數對象。 后來調用具有位置參數的可調用對象的 CALL_FUNCTION 然后:

將所有參數和可調用對象彈出堆棧,使用這些參數調用可調用對象,並推送可調用對象返回的返回值。

所有這些操作都有它們的成本,但是當字典變大時,將鍵值項目分配給字典的成本將大於創建一個功能。 換句話說,從某一點調用字典的__setitem__方法的成本將超過在運行中創建和暫停字典對象的成本。

另外,請注意,當然還有其他多個操作(在這種情況下為OP_CODES)在這個游戲中起着至關重要的作用,我認為值得調查並考慮我將把它作為一種練習來實現;)。

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM