Почему этот цикл быстрее, чем понимание словаря для создания словаря? - PullRequest
0 голосов
/ 27 сентября 2018

Я не из области программного обеспечения / компьютерных наук, но я люблю программировать на 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

Ответы [ 2 ]

0 голосов
/ 27 сентября 2018

Этот вопрос в некотором смысле очень похож на Почему понимание списка намного быстрее, чем добавление в список? , на который я давным-давно ответил.Однако причина того, что это поведение вас удивляет, заключается в том, что ваш словарь слишком мал, чтобы преодолеть затраты на создание нового функционального фрейма и его размещение в стеке.Чтобы понять, что лучше, давайте перейдем под скин буксировочных фрагментов, которые у вас есть:

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

Во втором разобранном коде (понимание текста) у вас есть код операции MAKE_FUNCTION, который, как это также указано в документации нажимаетновый объект функции в стеке. и позже CALL_FUNCTION, который вызывает вызываемый объект с позиционными аргументами. , а затем:

извлекает все аргументы и вызываемый объектвне стека, вызывает вызываемый объект с этими аргументами и отправляет возвращаемое значение, возвращаемое вызываемым объектом.

Все эти операции имеют свои затраты, но когда словарь становится больше, стоимость назначения ключа-значение элементов в словаре станет больше, чем при создании функции под капотом.Другими словами, стоимость вызова метода __setitem__ словаря из определенной точки будет превышать стоимость создания и приостановки объекта словаря на лету.

Кроме того, обратите внимание, что, безусловно, существует множество других операций (OP_CODES в данном случае), которые играют решающую роль в этой игре, которую, я думаю, стоит изучить и обдумать, которую я собираюсь передать вам в качестве практики;).

0 голосов
/ 27 сентября 2018

Вы тестируете со слишком маленьким входом;в то время как понимание словаря не имеет такого большого преимущества в производительности по сравнению с циклом 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

, которая не , а большая разница, если учесть, что обе обработанные пары ключей-значений составляют почти 100 тыс.Тем не менее, цикл for явно медленнее .

Так почему же разница в скорости с 3 элементами?Поскольку понимание (словарь, набор, списочные выражения или выражение генератора) находится под капотом, реализованным как новая функция , и вызов этой функции имеет базовую стоимость, которую простой цикл не должен платить.

Вот разборка для байт-кода для обеих альтернатив;обратите внимание на коды операций MAKE_FUNCTION и CALL_FUNCTION в байт-коде верхнего уровня для понимания dict, есть отдельный раздел о том, что тогда делает эта функция, и здесь на самом деле очень мало различий между двумя подходами:

>>> 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 мкс, чтобы создать объект функции с одним аргументом и вызвать его (с дополнительным LOAD_CONST для значения None, которое мы передаем)!В этом и заключается разница между зацикленным временем и временем понимания для трех пар ключ-значение.

Можно сравнить это с удивлением, что человек с лопатой может вырыть маленькую яму быстрее, чем экскаватор.Экскаватор может, конечно, быстро копать, но человек с лопатой может начать быстрее, если вам нужно запустить экскаватор и сначала переместить его в положение!

За несколькими парами ключ-значение (рытье большую яму), функция создания и вызова стоимость исчезает в ничто.На этом этапе понимание dict и явный цикл в основном делают одно и то же:

  • принимают следующую пару ключ-значение, вставляют их в стек
  • вызовите хук dict.__setitem__ с помощью операции байт-кода с двумя верхними элементами в стеке (либо STORE_SUBSCR, либо MAP_ADD. Это не считается "вызовом функции", поскольку все это внутренне обрабатывается в цикле интерпретатора.

Это отличается от понимания списка, когда версия простого цикла должна использовать list.append(), включая поиск атрибутов, и вызов функции каждую итерацию цикла . СписокПреимущество в скорости понимания связано с этим различием, см. Понимание списка Python дорого

Что добавляет сложное понимание, так это то, что имя целевого словаря нужно искать только один раз, когда привязка b к конечному объекту словаря. Если целевой словарь является 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 элементами для обработки слишком мала, чтобы о них заботиться, и в тот момент, когда вы генерируете глобал или имеете больше элементов, понимание диктата в любом случае побеждает. </p>

...