Если вы пишите на Python и знакомы с различными включениями, вы наверняка слышали о том, что создание коллекции с помощью включения обычно работает быстрее, чем создание той же коллекции с помощью обычного цикла for
. Я пишу на Python несколько лет, и разумеется я тоже слышал о производительности включений. Все время для меня этот факт был своего рода аксиомой, истиной, которая не требует проверки. Однако это неправильный подход к изучению точных наук и технологий, поэтому я сел разбираться.
Быстродействие
Первое, с чего я решил начать — это понять, действительно ли при создании коллекций различного рода включения работают быстрее создания коллекций с помощью циклов и прямого использования метода вставки. Для решения этой задачи необходимо было собрать данные о времени работы обоих подходов, по возможности построить некоторые зависимости и осуществить визуальное сравнение полученных зависимостей. Разумеется, я начал со сбора данных.
Для сбора времен работы подходов была использована следующая методика:
Были рассмотрены следующие коллекции:
list
,dict
,set
;Измерялись времена создания коллекций размером от элементов до элементов с шагом в элементов. Такие значения были выбраны, чтобы достичь компромисса между числом элементов, общим временем сбора данных и требуемым количеством памяти;
Для каждого размера коллекции из указанного диапазона осуществлялось измерение времени создания коллекции с данным числом элементов с помощью цикла и нативного метода вставки, а также с помощью соответствующего включения;
Время создания измерялось для каждого способа отдельно с помощью встроенной в Python библиотеки
time
;Все измерения помещались в специальную структуру;
После рассмотрения всех значений размеров коллекций из обозначенного выше диапазона, полученные измерения сохранялись в файлы в формате json.
Для сбора времен я реализовал несколько вспомогательных функций, которые могут быть запущены одновременно в три процесса, чтобы ускорить аккумулирование данных. Весь код, который я писал для подготовки материалов, можно посмотреть здесь. Стоит оговориться, что сбор времени работы сопряжен с аппаратной зависимостью, а также зависимостью от используемой версии интерпретатора. Я был ограничен следующими аппаратными и программными зависимостями:
OS:
Windows 11
CPU:
AMD Ryzen 7 5000U
Оперативная память:
16 ГБ
Python:
3.11.1
На следующем этапе я визуализировал результаты и попытался оценить возможность построения некоторых зависимостей. В случае со списками удалось построить линейную зависимость времени создания от количества элементов по методу наименьших квадратов. Также удалось рассчитать коридор ошибок, в который поместилась значительная часть экспериментальных отсчетов.
В случае со словарями и множествами графики получились сильно разрывными, поэтому я не стал заморачиваться с построением тренда для них. Сами по себе графики для множеств и словарей структурно похожи между собой, что объясняется схожестью этих коллекций в плане реализации.
В результате действительно получилось, что создание коллекций с помощью включений работает быстрее создания коллекций с помощью циклов и нативных методов вставки.
Причина
Итак,когда на вопрос, какой способ быстрее, был получен однозначный ответ — способ с включениями, передо мной встал вопрос о генезисе процесса. Почему включения работают быстрее? Ведь на первый взгляд включения — это всего лишь синтаксический сахар.
Чтобы ответить на этот вопрос я воспользовался модулем стандартной библиотеки dis. С помощью этого модуля можно осуществить дизассемблирование байткода Python и проанализировать фактическую последовательность действий, которую осуществляет интепретатор для исполнения того или иного фрагмента кода. Сразу оговорюсь, что дизассемблирование открывает некоторые детали реализации, а потому вывод функций библиотеки для различных версий интерпретатора может отличаться. Сильно отличаться — вплоть до отсутствия/наличия определенных команд. Я использую версию языка Python 3.11.1, поэтому если ваша версия не совпадает с моей, вывод также может не совпадать. Однако концептуальных различий быть не должно.
Дальше я буду дизассемблировать только код создания списков. Дизассемблирование способов создания прочих коллекций приводит к похожим выводам, так что анализ байткода для словарей и множеств избыточен. Начнем с дизассемблирования процесса создания списка с помощью цикла for
и явного вызова метода append
:
Код дизассемблирования
import dis
loop = """\
lsp = []
for i in range(10):
lsp.append(i)
"""
dis.dis(loop)
Вывод
0 0 RESUME 0
1 2 BUILD_LIST 0
4 STORE_NAME 0 (lsp)
2 6 PUSH_NULL
8 LOAD_NAME 1 (range)
10 LOAD_CONST 0 (10)
12 PRECALL 1
16 CALL 1
26 GET_ITER
>> 28 FOR_ITER 23 (to 76)
30 STORE_NAME 2 (i)
3 32 LOAD_NAME 0 (lsp)
34 LOAD_METHOD 3 (append)
56 LOAD_NAME 2 (i)
58 PRECALL 1
62 CALL 1
72 POP_TOP
74 JUMP_BACKWARD 24 (to 28)
2 >> 76 LOAD_CONST 1 (None)
78 RETURN_VALUE
Если вы намерены досконально изучить происходящее, то вы можете прочитать значения всех приведенных выше команд в официальной документации. Я же хочу в подробностях разобрать только последовательность команд, которая используется на каждой итерации цикла. Это блок команд с адресами 30-74:
30 STORE_NAME 2 (i)
3 32 LOAD_NAME 0 (lsp)
34 LOAD_METHOD 3 (append)
56 LOAD_NAME 2 (i)
58 PRECALL 1
62 CALL 1
72 POP_TOP
74 JUMP_BACKWARD 24 (to 28)
Итак, на каждой итерации цикла у нас происходит связывания объекта, порожденного итератором, с переменной цикла i
. Затем интерпретатор загружает список lst
, после чего ищет метод append
в загруженном списке и помещает на верхушку стека сначала метод append
, а потом объект, с которым он связан. После чего на верхушку стека помещается переменная цикла. Такой порядок связан с особенностями вызова функций, точнее с особенностями расположения самого вызываемого объекта и его аргументов в стеке. 58–62 подготовка и вызов метода append
. Результат вызова помещается на верхушку стека, поэтому следом идет удаление значения с верхушки стека. Ну и 74 — это форма GOTO
, т. е. мы уходим на новую итерацию.
Итого, если считать, что стоимость каждой операции — это условная единица, то стоимость одной итерации при добавлении элементов в список с помощью цикла for
и метода append
составляет условных единиц.
Теперь посмотрим на списковое включение:
import dis
comp = "[i for i in range(10)]"
dis.dis(comp)
Вывод
0 0 RESUME 0
1 2 LOAD_CONST 0 (<code object <listcomp> at 0x0000021D9A765210, file "<dis>", line 1>)
4 MAKE_FUNCTION 0
6 PUSH_NULL
8 LOAD_NAME 0 (range)
10 LOAD_CONST 1 (10)
12 PRECALL 1
16 CALL 1
26 GET_ITER
28 PRECALL 0
32 CALL 0
42 RETURN_VALUE
Disassembly of <code object <listcomp> at 0x0000021D9A765210, file "<dis>", line 1>:
1 0 RESUME 0
2 BUILD_LIST 0
4 LOAD_FAST 0 (.0)
>> 6 FOR_ITER 4 (to 16)
8 STORE_FAST 1 (i)
10 LOAD_FAST 1 (i)
12 LIST_APPEND 2
14 JUMP_BACKWARD 5 (to 6)
>> 16 RETURN_VALUE
Здесь код немного запутаннее, т.к. перед нами результат дизассемблирования двух объектов: непосредственно нашего кода и функции listcomp
. Что такое listcomp
? А это и есть то самое списковое включение. т. е. при использовании синтаксиса спискового включения мы на самом деле вызываем специальную функцию. Отсюда следует интересный результат: поскольку listcomp
— это функция, у нас возникают постоянные расходы на ее вызов. т. е. на небольших объемах данных списковые включения должны уступать в производительности циклу for
. Однако, во время сбора данных я не наблюдал такого поведения, но наблюдал, что на относительно небольших объемах (порядка ) списковые включения и циклы for
работают примерно с одинаковым уровнем быстродействия. Это можно проверить путем анализа собранных данных.
Сам же код, ответственный за создание и наполнение списка, находится тут:
1 0 RESUME 0
2 BUILD_LIST 0
4 LOAD_FAST 0 (.0)
>> 6 FOR_ITER 4 (to 16)
8 STORE_FAST 1 (i)
10 LOAD_FAST 1 (i)
12 LIST_APPEND 2
14 JUMP_BACKWARD 5 (to 6)
>> 16 RETURN_VALUE
Здесьмы тоже только рассмотрим код итерации, т. е. команды с адресами 8–14. В начале мы связываем объект, порожденный итератором, с переменной цикла i
, после чего помещаем переменную цикла на верхушку стека. Затем вызываем LIST_APPEND
— специальную форму append
для реализации списковых включений. В команде по адресу 14 мы уходим на новую итерацию.
Итак, если принять стоимость каждой команды за условную единицу, то одна итерация спискового включения обходится в условные единицы. т. е. итерация спискового включения стоит в два раза дешевле аналогичной итерации при создании списка с помощью for
и append
.
На самом деле я сильно лукавлю, когда говорю, что все операции стоят условную единицу. По факту, что можно видеть на графиках (или проверить в коде), коэффициенты наклона соотносятся друг с другом не с коэффициентом , а с коэффициентом , т.е. . Однако, факт остается фактом: включения работают быстрее, и это связано с тем, что для реализации одной итерации включениям требуется меньшее число операций.
P. S. Я ни в коем случае не истина в последней инстанции и мог допустить ошибки или неточности. В этом случае буду рад вашим дополнениям.
P. P. S. Также приглашаю вас в свой канал, где я пишу небольшие заметки про Python и разработку.
Комментарии (24)
GospodinKolhoznik
24.09.2024 23:34+2Если это давно известный факт, то почему компилятор в байт-код не делает автоматическое преобразование цикла во включение?
saege5b
24.09.2024 23:34+1Это в примерах можно в чистом/дистилированном виде показывать, а в жизни всякое случается.
fnail
24.09.2024 23:34Наверно, потому, что при наличии логики составления коллекции, разница между способами нивелируется операциями этой самой логики. А если логики нет, то можно использовать list(range10).
zzzzzzerg
24.09.2024 23:34+1На эту же тему было ранее - Кто быстрее создаёт списки в Python, list() или [] / Хабр (habr.com)
PS. man time.perf_counter
MishaPogrommist Автор
24.09.2024 23:34Спасибо! Не натыкался на эту стать статью, когда искал похожие материалы.
QtRoS
24.09.2024 23:34Отсюда следует интересный результат: поскольку listcomp — это функция, у нас возникают постоянные расходы на ее вызов. т. е. на небольших объемах данных списковые включения должны уступать в производительности циклу for
Функция вроде один раз вызывается, и за один вызов полностью строит список. Причем добавление элементов в список так же происходит в цикле, но делается не через вызов функции append, а специальной командой LIST_APPEND.
Я внутрянку Python не особо внимательно изучал, могу ошибаться, но листинг байткода читается так.
MnogoNog
24.09.2024 23:34Почему включения быстрее циклов?
Потому что не изменяет размер существующих объектов, в отличие от.
comprehansion - это синтаксичеcкий сахар вокруг генератора:
comp_1 = [i for i in range(10)] comp_2_gen = (i for i in range(10)) comp_2 = list(comp_2_gen) assert comp_1 == comp_2
MishaPogrommist Автор
24.09.2024 23:34Не соглашусь. Все же с точки зрения байткода списковое включение и генераторное выражение - это два разных объекта. Они работают по одной и той же схеме, но используют немного разные операции.
Чтобы в этом убедиться, достаточно дизассемблировать код генераторного выражения:
Disassembly of <code object <genexpr> at 0x000001B8E67AF590, file "<dis>", line 1>: 1 0 RETURN_GENERATOR 2 POP_TOP 4 RESUME 0 6 LOAD_FAST 0 (.0) >> 8 FOR_ITER 6 (to 22) 10 STORE_FAST 1 (i) 12 LOAD_FAST 1 (i) 14 YIELD_VALUE 16 RESUME 1 18 POP_TOP 20 JUMP_BACKWARD 7 (to 8) >> 22 LOAD_CONST 0 (None) 24 RETURN_VALUE
Похоже - да. То же самое - нет. В вашем примере ассерт проходит потому что вы из генератора явно сконструировали список.
MnogoNog
24.09.2024 23:34у нас тут поднято уже два разных вопроса:
comprehansion как синтаксический сахар
Генератор отдельно для наглядности сахара вынесен, конечно же. Это тоже валидный код:
comp_1 = [i for i in range(10)] comp_2 = list(i for i in range(10)) assert comp_1 == comp_2
Почему включения быстрее циклов?
И то что лежит на поверхности (т.е. для этого даже не нужно смотреть байткод) - это изменение объектов (в данном случае изменяем экземпляр списка) и их особенности по работе с памятью:
from sys import getsizeof result = [] size_ = getsizeof(result) for i in range(10000): result.append(i) if (new_size := getsizeof(result)) > size_: size_ = new_size print(i, size_)
где вывод будет:
0 88 4 120 8 184 16 248 24 312 32 376 40 472 52 568 64 664 76 792 92 920 108 1080 128 1240 148 1432 172 1656 200 1912 232 2200 ...
Итого, чем больше мы добавляем объектов в коллекцию, тем больше питон тратит времени на работу с памятью. Направления для изучения:
Как вообще питон обращается с памятью.
Различия по работе с памятью по встроенных типах коллекций
Вопрос в заголовке на просторах stackoverflow
Темы обширны, самых лучших ссылок под рукой нет, к сожалению, а первые попавшиеся добавлять не хочется, ведь наверняка будет источник ещё лучше.
MishaPogrommist Автор
24.09.2024 23:34Я понимаю, что вы хотите сказать. И я с вами не согласен. Вот как выглядит байткод создания списка с помощью генератора:
>>> import dis >>> comand = "list(i for i in range(10))" >>> dis.dis(comand) 0 0 RESUME 0 1 2 PUSH_NULL 4 LOAD_NAME 0 (list) 6 LOAD_CONST 0 (<code object <genexpr> at 0x00000207B510F590, file "<dis>", line 1>) 8 MAKE_FUNCTION 0 10 PUSH_NULL 12 LOAD_NAME 1 (range) 14 LOAD_CONST 1 (10) 16 PRECALL 1 20 CALL 1 30 GET_ITER 32 PRECALL 0 36 CALL 0 46 PRECALL 1 50 CALL 1 60 RETURN_VALUE
Здесь для создания списка мы используем вызов
list
, а в качестве аргумента передаем генератор, созданный с помощью генераторного выражения, которое реализовано функциейgenexpr
. На всякий случай приведу байт кодgenexpr
еще раз:Disassembly of <code object <genexpr> at 0x00000207B510F590, file "<dis>", line 1>: 1 0 RETURN_GENERATOR 2 POP_TOP 4 RESUME 0 6 LOAD_FAST 0 (.0) >> 8 FOR_ITER 6 (to 22) 10 STORE_FAST 1 (i) 12 LOAD_FAST 1 (i) 14 YIELD_VALUE 16 RESUME 1 18 POP_TOP 20 JUMP_BACKWARD 7 (to 8) >> 22 LOAD_CONST 0 (None) 24 RETURN_VALUE
Здесь мы создаем генератор, а при каждом вызове мы будем делать YIELD_VALUE.
Теперь еще раз посмотрим на код создания с помощью спискового включения:
>>> comand = "[i for i in range(10)]" >>> dis.dis(comand) 0 0 RESUME 0 1 2 LOAD_CONST 0 (<code object <listcomp> at 0x00000207B4F95210, file "<dis>", line 1>) 4 MAKE_FUNCTION 0 6 PUSH_NULL 8 LOAD_NAME 0 (range) 10 LOAD_CONST 1 (10) 12 PRECALL 1 16 CALL 1 26 GET_ITER 28 PRECALL 0 32 CALL 0 42 RETURN_VALUE
Здесь и вызовов меньше, и
list
не используется, и нет никакогоgenexpr
. Зато вместо него появляетсяlistcomp
. Как выглядитlistcomp
? На всякий случай дублирую:Disassembly of <code object <listcomp> at 0x00000207B4F95210, file "<dis>", line 1>: 1 0 RESUME 0 2 BUILD_LIST 0 4 LOAD_FAST 0 (.0) >> 6 FOR_ITER 4 (to 16) 8 STORE_FAST 1 (i) 10 LOAD_FAST 1 (i) 12 LIST_APPEND 2 14 JUMP_BACKWARD 5 (to 6) >> 16 RETURN_VALUE
Здесь мы сразу создаем список (инструкция по адресу 2
BUILD_LIST
), а потом на каждой итерации выполняем командуLIST_APPEND
. Т.е. мы так же добавляем элементы в конец списка, как и при использовании цикла и явном вызовеappend
. Вот ссылка на документацию к этой команду для версии 3.11. В итоге, мы в обоих вариантах (цикл и включение) сначала создаем список, а потом изменяем его, закидывая туда элементы. Разница лишь в том, что включениям для этого требуется меньше операций.По поводу ссылок. Вот неплохой ответ на StackOverflow, правда, про словари, но сути не меняет.
MishaPogrommist Автор
24.09.2024 23:34И вот еще одна интересная ссылка на StackOverlow, на этот раз уже прямо по теме нашей дискуссии, про списковые включения.
Deosis
24.09.2024 23:34Надо протестировать такой вариант с заранее выделенным местом:
result = [None]*10000 for i in range(10000): result[i] = i
Может оказаться, что на работу с памятью уходит много времени.
Derfirm
24.09.2024 23:34Не заметил сравнения, если забиндить append и использовать в цикле, будет ли большая разница с обычными append'ami ?
Я про конструкции вида
values = [] append_list = values.append for i in range(1000): append_list(i)
MishaPogrommist Автор
24.09.2024 23:34Спасибо! Интересное замечание. Нужно будет обязательно проверить.
ValeryIvanov
24.09.2024 23:34Стоит ещё учитывать, что байткод питона не имеет какой-то спецификации и не обязан иметь обратную совместимость между версиями. Например, в версии питона 3.12 для спискового включения не создаётся отдельный кодовый объект, а включение инлайнится напрямую и в таком случае включение должно быть ещё на долю процента быстрее цикла
AndyLem
Самое интересное в этой статье, это понять что такое включения. Подозреваю что имеются ввиду comprehensions.
AskePit
Прокрутил до середины статьи, чтобы это выяснить и начать читать сначала
aamonster
Мне кажется, этого достаточно, чтобы дальше не читать. Во-первых, это действительно интереснее остальной статьи. Во-вторых, раз использован никому не понятный термин для общеизвестной вещи – статья плохая.
MishaPogrommist Автор
Да, все верно. Тут я сплоховал, что не конкретизировал. Называю это включениями, потому что рассматривал включения в целом (listcomp, dictcomp, setcomp). Спасибо за замечание.
domix32
Тогда почему оно включения а не сопоставления? кто кого куда включает?
MishaPogrommist Автор
В русскоязычной литературе натыкался только на вариант списковые, словарные, множественные включения. Это не мой прямой перевод.
AndyLem
Я не претендую на истину, но как мне кажется, из всех вариантов перевода слова comprehension, охват будет гораздо лучше соответствовать сути, чем включение. С другой стороны, я никогда у себя в голове не переводил это слово на русский язык вообще.