Если вы пишите на Python и знакомы с различными включениями, вы наверняка слышали о том, что создание коллекции с помощью включения обычно работает быстрее, чем создание той же коллекции с помощью обычного цикла for. Я пишу на Python несколько лет, и разумеется я тоже слышал о производительности включений. Все время для меня этот факт был своего рода аксиомой, истиной, которая не требует проверки. Однако это неправильный подход к изучению точных наук и технологий, поэтому я сел разбираться.

Быстродействие

Первое, с чего я решил начать — это понять, действительно ли при создании коллекций различного рода включения работают быстрее создания коллекций с помощью циклов и прямого использования метода вставки. Для решения этой задачи необходимо было собрать данные о времени работы обоих подходов, по возможности построить некоторые зависимости и осуществить визуальное сравнение полученных зависимостей. Разумеется, я начал со сбора данных.

Для сбора времен работы подходов была использована следующая методика:

  • Были рассмотрены следующие коллекции: list, dict, set;

  • Измерялись времена создания коллекций размером от 10 элементов до 10^7элементов с шагом в 1000 элементов. Такие значения были выбраны, чтобы достичь компромисса между числом элементов, общим временем сбора данных и требуемым количеством памяти;

  • Для каждого размера коллекции из указанного диапазона осуществлялось измерение времени создания коллекции с данным числом элементов с помощью цикла и нативного метода вставки, а также с помощью соответствующего включения;

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

Итого, если считать, что стоимость каждой операции — это 1 условная единица, то стоимость одной итерации при добавлении элементов в список с помощью цикла for и метода append составляет 8 условных единиц.

Теперь посмотрим на списковое включение:

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. Однако, во время сбора данных я не наблюдал такого поведения, но наблюдал, что на относительно небольших объемах (порядка 10^4) списковые включения и циклы 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 мы уходим на новую итерацию.

Итак, если принять стоимость каждой команды за 1 условную единицу, то одна итерация спискового включения обходится в 4 условные единицы. т. е. итерация спискового включения стоит в два раза дешевле аналогичной итерации при создании списка с помощью for и append.

На самом деле я сильно лукавлю, когда говорю, что все операции стоят 1 условную единицу. По факту, что можно видеть на графиках (или проверить в коде), коэффициенты наклона соотносятся друг с другом не с коэффициентом 2, а с коэффициентом 1.5, т.е. incline_{loop} = 1.5 \times incline_{comp}. Однако, факт остается фактом: включения работают быстрее, и это связано с тем, что для реализации одной итерации включениям требуется меньшее число операций.

P. S. Я ни в коем случае не истина в последней инстанции и мог допустить ошибки или неточности. В этом случае буду рад вашим дополнениям.

P. P. S. Также приглашаю вас в свой канал, где я пишу небольшие заметки про Python и разработку.

Комментарии (24)


  1. AndyLem
    24.09.2024 23:34
    +34

    Самое интересное в этой статье, это понять что такое включения. Подозреваю что имеются ввиду comprehensions.


    1. AskePit
      24.09.2024 23:34
      +9

      Прокрутил до середины статьи, чтобы это выяснить и начать читать сначала


      1. aamonster
        24.09.2024 23:34

        Мне кажется, этого достаточно, чтобы дальше не читать. Во-первых, это действительно интереснее остальной статьи. Во-вторых, раз использован никому не понятный термин для общеизвестной вещи – статья плохая.


    1. MishaPogrommist Автор
      24.09.2024 23:34

      Да, все верно. Тут я сплоховал, что не конкретизировал. Называю это включениями, потому что рассматривал включения в целом (listcomp, dictcomp, setcomp). Спасибо за замечание.


      1. domix32
        24.09.2024 23:34

        Тогда почему оно включения а не сопоставления? кто кого куда включает?


        1. MishaPogrommist Автор
          24.09.2024 23:34

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


          1. AndyLem
            24.09.2024 23:34
            +1

            Я не претендую на истину, но как мне кажется, из всех вариантов перевода слова comprehension, охват будет гораздо лучше соответствовать сути, чем включение. С другой стороны, я никогда у себя в голове не переводил это слово на русский язык вообще.


  1. GospodinKolhoznik
    24.09.2024 23:34
    +2

    Если это давно известный факт, то почему компилятор в байт-код не делает автоматическое преобразование цикла во включение?


    1. saege5b
      24.09.2024 23:34
      +1

      Это в примерах можно в чистом/дистилированном виде показывать, а в жизни всякое случается.


    1. fnail
      24.09.2024 23:34

      Наверно, потому, что при наличии логики составления коллекции, разница между способами нивелируется операциями этой самой логики. А если логики нет, то можно использовать list(range10).


    1. domix32
      24.09.2024 23:34

      1. Он не слишком умный

      2. Мутабельные данные мешают той самой оптимизации


  1. zzzzzzerg
    24.09.2024 23:34
    +1

    На эту же тему было ранее - Кто быстрее создаёт списки в Python, list() или [] / Хабр (habr.com)

    PS. man time.perf_counter


    1. MishaPogrommist Автор
      24.09.2024 23:34

      Спасибо! Не натыкался на эту стать статью, когда искал похожие материалы.


  1. QtRoS
    24.09.2024 23:34

    Отсюда следует интересный результат: поскольку listcomp — это функция, у нас возникают постоянные расходы на ее вызов. т. е. на небольших объемах данных списковые включения должны уступать в производительности циклу for

    Функция вроде один раз вызывается, и за один вызов полностью строит список. Причем добавление элементов в список так же происходит в цикле, но делается не через вызов функции append, а специальной командой LIST_APPEND.

    Я внутрянку Python не особо внимательно изучал, могу ошибаться, но листинг байткода читается так.


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


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

      Похоже - да. То же самое - нет. В вашем примере ассерт проходит потому что вы из генератора явно сконструировали список.


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

        Темы обширны, самых лучших ссылок под рукой нет, к сожалению, а первые попавшиеся добавлять не хочется, ведь наверняка будет источник ещё лучше.


        1. 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, правда, про словари, но сути не меняет.


        1. MishaPogrommist Автор
          24.09.2024 23:34

          И вот еще одна интересная ссылка на StackOverlow, на этот раз уже прямо по теме нашей дискуссии, про списковые включения.


        1. Deosis
          24.09.2024 23:34

          Надо протестировать такой вариант с заранее выделенным местом:

          result = [None]*10000
          for i in range(10000):
              result[i] = i

          Может оказаться, что на работу с памятью уходит много времени.


  1. Derfirm
    24.09.2024 23:34

    Не заметил сравнения, если забиндить append и использовать в цикле, будет ли большая разница с обычными append'ami ?

    Я про конструкции вида

    values = []
    append_list = values.append
    for i in range(1000):
      append_list(i)


    1. MishaPogrommist Автор
      24.09.2024 23:34

      Спасибо! Интересное замечание. Нужно будет обязательно проверить.


  1. ValeryIvanov
    24.09.2024 23:34

    Стоит ещё учитывать, что байткод питона не имеет какой-то спецификации и не обязан иметь обратную совместимость между версиями. Например, в версии питона 3.12 для спискового включения не создаётся отдельный кодовый объект, а включение инлайнится напрямую и в таком случае включение должно быть ещё на долю процента быстрее цикла


  1. AlexDvoinikov
    24.09.2024 23:34

    Вы все законы науки проверяете экспериментом?