Search
Write a publication
Pull to refresh

Comments 26

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Почему включения быстрее циклов?

Потому что не изменяет размер существующих объектов, в отличие от.

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

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

Чтобы в этом убедиться, достаточно дизассемблировать код генераторного выражения:

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

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

у нас тут поднято уже два разных вопроса:

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

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

Я понимаю, что вы хотите сказать. И я с вами не согласен. Вот как выглядит байткод создания списка с помощью генератора:

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

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

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

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

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

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

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

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

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

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

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

это в питоне такая культура перформанс тестирования? ;) смотрим на байт код? ужос.

что, нет никаких фреймворков для тестирования как например в java - jmh? который 1) сделает адекватный прогрев 2) посчитает стат ошибку 3) прицепит профайлеры: gc, а главное как в этом случаи perf профайлер, который покажет 1) branches/branch-misses, IPC и еще покажет в процентном соотношении время выполнения для каждой ассемблерной инструкции (примерно конечно), процессор же их выполняет а не байт код. Ну и еще с общего можно глянуть что компилируется/не компилируется, какой код куда инлайнится или не инлайнится.

Собственно на основе всего этого и делаются адекватные выводы насчет того, что чего быстрее и почему. Когда речь идет о микро бенчмарках смотреть на код бесполезная затея в 90% случаев, потому что виртуальная машина перелопатит все по своему усмотрению.

Насколько я понял - вывод в том, что причина более быстрой работы - это разное число операций в байткоде (разница в 2 раза).

Мне кажется - это утверждение ошибочным (и несоответствие экспериментальным данным это подтверждают - 2 против 1.5).

И главная причина ошибки - это допущение:

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

Думаю этого делать не стоит никак. Думаю, что все операции помещения в стек адресов и переменных, джампы и т.д. - это все пыль и примерно одинаковы в обоих методах. А реальная потеря времени происходит в методах АППЕНД. Т.е. вам фактически сравнивать нужно:

C1 + N*CALL(APPEND)
vs
C2 + N*LIST_APPEND

Но данным способом вы эти методы сравнить не можете. Они тут для вас как черные ящики. Нужно смотреть внутрь их, что бы понять в чем суть.

Для начала можно провести испытания хотя бы просто с этих методов, без циклов. Попробовать для подтверждения. Подтвердить где теряется время. В вашем методе - вы не знаете где оно теряется и размазываете вину равномерно между всеми операциями, что не верно абсолютно (вы можете подтвердить это замерами через пустые циклы или АППЕНДы без циклов и т.д.).

Моя теория совпадает с комментариями выше: разница в том, что быстрый аппенд знает финальный размер и потому выделяет место под список только один раз, а медленный - постоянно пересоздает список с большим размером. Эти процессы примерно одинаковы везде.

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

В общем начало есть, но нужно мерять дальше и глубже. И скорее всего все крутится вокруг аппендов. Думаю круто было бы увидеть дальнейшее погружение в это сравнение от вас.

П.С. Еще бы проверил и сравнил те методы что названы ФАСТ в быстрой версии - STORE_FAST, LOAD_FAST - все равно думаю, что их вклад незначителен, но сравнить интересно какова разница.

Sign up to leave a comment.

Articles