Почему создание python dict из списка кортежей 3x медленнее, чем из kwargs

существует несколько способов создания словаря на python, например:

keyvals = [('foo', 1), ('bar', 'bar'), ('baz', 100)]

dict(keyvals)

и

dkwargs = {'foo': 1, 'bar': 'bar', 'baz': 100}

dict(**dkwargs)

когда вы проверяете эти

In [0]: %timeit dict(keyvals)
667 ns ± 38 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)

In [1]: %timeit dict(**dkwargs)
225 ns ± 7.09 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)

вы видите, что первый способ почти в 3 раза медленнее, чем второй. Почему так?

3 ответов


dict(**kwargs) проходит в готовом словаре, поэтому Python может просто скопировать уже существующую внутреннюю структуру.

список кортежей, с другой стороны, требует итерации, проверки, хэширования и прорезания результатов в новую пустую таблицу. Это не так быстро.

словарь Python реализован как хэш-таблицы, и выращивается динамически как ключи добавляются с течением времени, они начинают с малого и по мере необходимости в строится новая, более крупная хэш-таблица, копируются данные (ключи, значения и хэши). Это все невидимо из кода Python, но изменение размера требует времени. Но когда вы используете dict(**kwargs) (или dict(other_dict) CPython (реализация Python по умолчанию, которую вы использовали для тестирования) может использовать ярлык:начните с хэш-таблицы, которая достаточно велика немедленно. Вы не можете сделать тот же трюк с последовательностью кортежей, потому что вы не можете знать заранее, если не будет дубликатов ключей в последовательность.

дополнительные сведения см. В исходном коде dict типа, в частности dict_update_common реализация (который вызывается из dict_init()); это требует либо PyDict_MergeFromSeq2() для случая последовательности кортежей или вызовов PyDict_Merge() при передаче аргументов ключевых слов.

на PyDict_MergeFromSeq2() функции перебирает последовательность, проверяет каждый результат, чтобы убедиться, что есть два элемента, а затем по существу вызывает .__setitem__(key, value) на словарь. В какой-то момент может потребоваться изменить размер словаря!

на


короткий ответ (TL;DR)

это потому, что в первом тесте реализация CPython dict создаст новый дикт из списка, но второй только копирует словарь. Копирование занимает меньше времени, чем разбор списка.

дополнительная информация

рассмотрим этот код:

import dis
dis.dis("dict([('foo', 1), ('bar', 'bar'), ('baz', 100)])", depth=10)
print("------------")
dis.dis("dict({'foo': 1, 'bar': 'bar', 'baz': 100})", depth=10)

здесь

модуль dis поддерживает анализ байт-кода CPython разборка он.

что позволяет нам видеть выполняемые операции байт-кода. Вывод показывает

  1           0 LOAD_NAME                0 (dict)
              2 LOAD_CONST               0 (('foo', 1))
              4 LOAD_CONST               1 (('bar', 'bar'))
              6 LOAD_CONST               2 (('baz', 100))
              8 BUILD_LIST               3
             10 CALL_FUNCTION            1
             12 RETURN_VALUE
------------
  1           0 LOAD_NAME                0 (dict)
              2 LOAD_CONST               0 (1)
              4 LOAD_CONST               1 ('bar')
              6 LOAD_CONST               2 (100)
              8 LOAD_CONST               3 (('foo', 'bar', 'baz'))
             10 BUILD_CONST_KEY_MAP      3
             12 CALL_FUNCTION            1
             14 RETURN_VALUE

на выходе вы можете увидеть:

  1. оба вызова должны загрузить dict имя, которое будет называться.
  2. после этого первый метод загружает список в память (BUILD_LIST) в то время как второй строит словарь (BUILD_CONST_KEY_MAP) (см. здесь)
  3. по этой причине, когда функция dict вызывается (CALL_FUNCTION шаг (см. здесь)), это занимает гораздо меньше во втором случае, потому что словарь уже был создан, поэтому он просто делает копию вместо того, чтобы перебирать список для создания хэш-таблицы.

Примечание: С байт-кодом вы не можете окончательно решить, что CALL_FUNCTION делает это, так как его реализация написана на C и только прочитав его, вы можете на самом деле знать это (см. ответ Martijn Pieters для точного объяснения того, как эта часть работает). Однако это помогает увидеть, как объект словаря уже создан за пределами dict() (пошагово, а не синтаксически в Примере), в то время как для списка это не так.

редактировать

чтобы быть ясным, когда вы говорите

есть несколько способов построить словарь на python

это правда, что делать:

dkwargs = {'foo': 1, 'bar': 'bar', 'baz': 100}

вы создаете словарь, в смысл в том, что интерпретатор преобразует выражение в объект словаря, хранящийся в памяти, и делает переменную dkwargs указать на него. Однако, сделав:dict(**kwargs) или, если вы предпочитаете dict(kwargs), ты не совсем создать словарь, а просто копирование уже существующий объект (и это важно подчеркнуть копирование):

>>> dict(dkwargs) is dkwargs
False

dict(kwargs) сил Python, чтобы создать новый объект, однако это не означает, что он имеет к восстановить объект. Фактически, эта операция бесполезна, потому что на практике они являются равными объектами (хотя и не одним и тем же объектом).

>>> id(dkwargs)
2787648914560
>>> new_dict = dict(dkwargs)
>>> id(new_dict)
2787652299584
>>> new_dict == dkwargs
True
>>> id(dkwargs) is id(new_dict)
False

где id:

возвращает "идентификатор" объекта. Это целое число, которое гарантированно будет уникальным и постоянным для этого объекта в течение его жизни [...]

CPython деталь реализации: это адрес объекта в память.

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


dkwargs уже является словарем, поэтому вы в основном делаете его копию. Вот почему он намного быстрее.