Вложенный comprehension необходимо оптимизировать вручную?
Вопрос к "грандам".
Отвечая на вопрос, где есть такие данные:
dict_groups = [{'name': 'Новые Альбомы', 'gid': 4100014},
{'name': 'Synthpop [Futurepop, Retrowave, Electropop]', 'gid': 8564},
{'name': 'E:\\music\\leftfield', 'gid': 101522128},
{'name': 'Бренд одежды | MEDICINE', 'gid': 134709480},
{'name': 'Другая Музыка', 'gid': 35486626},
{'name': 'E:\\music\\trip-hop', 'gid': 27683540},
{'name': 'Depeche Mode', 'gid': 125927592}]
x = [{'gid': 35486626},{'gid': 134709480},{'gid': 27683540}]
Предложил такое решение:
x_val = tuple(d["gid"] for d in x)
rez = [dict_el for dict_el in dict_groups if dict_el["gid"] in x_val]
Оценим производительность:
%timeit x_val = tuple(d["gid"] for d in x)
1.55 µs ± 81.1 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)
%timeit [dict_el for dict_el in dict_groups if dict_el["gid"] in x_val]
1.99 µs ± 159 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)
Можно ли записать итоговый вариант в виде "однострочника"? Конечно можно:
%timeit [dict_el for dict_el in dict_groups if dict_el["gid"] in tuple(d["gid"] for d in x)]
11.9 µs ± 756 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)
Не знаю почему, но однострочник оказывается медленнее. Так что, рекомендации учебников везде использовать "компрехенсив" (comprehension) - надо проверять.
Складывается впечатление, что выражение (d["gid"] for d in x) вычисляется в компрехенсиве 7 раз, хотя не зависит от переменных внутри.
Ведь 7*1,55 + 1,99 = 12,84, что примерно совпадает с временем однострочника.
Ответы (2 шт):
По-моему, вы все правильно поняли - кортеж tuple(d["gid"] for d in x) создается и проверяется на каждой итерации цикла в однострочной версии.
import dis
In [71]: s = '''[dict_el
...: for dict_el in dict_groups
...: if dict_el["gid"] in
...: tuple(d["gid"] for d in x)]'''
In [72]: dis.dis(s)
1 0 LOAD_CONST 0 (<code object <listcomp> at 0x000002094FE22270, file "<dis>", line 1>)
2 LOAD_CONST 1 ('<listcomp>')
4 MAKE_FUNCTION 0
2 6 LOAD_NAME 0 (dict_groups)
8 GET_ITER
10 CALL_FUNCTION 1
12 RETURN_VALUE
Disassembly of <code object <listcomp> at 0x000002094FE22270, file "<dis>", line 1>:
1 0 BUILD_LIST 0
2 LOAD_FAST 0 (.0)
>> 4 FOR_ITER 34 (to 40)
2 6 STORE_FAST 1 (dict_el)
3 8 LOAD_FAST 1 (dict_el)
10 LOAD_CONST 0 ('gid')
12 BINARY_SUBSCR
4 14 LOAD_GLOBAL 0 (tuple)
16 LOAD_CONST 1 (<code object <genexpr> at 0x000002094FE22030, file "<dis>", line 4>)
18 LOAD_CONST 2 ('<listcomp>.<genexpr>')
20 MAKE_FUNCTION 0
22 LOAD_GLOBAL 1 (x)
24 GET_ITER
26 CALL_FUNCTION 1
28 CALL_FUNCTION 1
30 COMPARE_OP 6 (in)
32 POP_JUMP_IF_FALSE 4
34 LOAD_FAST 1 (dict_el)
36 LIST_APPEND 2
38 JUMP_ABSOLUTE 4
>> 40 RETURN_VALUE
Disassembly of <code object <genexpr> at 0x000002094FE22030, file "<dis>", line 4>:
4 0 LOAD_FAST 0 (.0)
>> 2 FOR_ITER 14 (to 18)
4 STORE_FAST 1 (d)
6 LOAD_FAST 1 (d)
8 LOAD_CONST 0 ('gid')
10 BINARY_SUBSCR
12 YIELD_VALUE
14 POP_TOP
16 JUMP_ABSOLUTE 2
>> 18 LOAD_CONST 1 (None)
20 RETURN_VALUE
PS как вы уже и сами заметили при использовании генератора - x_val = (d["gid"] for d in x) - x_val выдохнется после первой же проверки и это ускорит будущие проверки (результат всех последующих проверок будет неправильным). Но даже при использовании кортежа x_val = tuple(d["gid"] for d in x) - это работает заметно быстрее по сравнению с однострочной версией.
Замеры времени для списка, состоящего из 70.000 словарей:
In [55]: big = dict_groups * 10000
In [56]: len(big)
Out[56]: 70000
In [67]: %%timeit
...: x_val = tuple(d["gid"] for d in x)
...: rez = [dict_el for dict_el in big if dict_el["gid"] in x_val]
...:
...:
6 ms ± 566 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
In [68]: %%timeit
...: rez = [dict_el for dict_el in big if dict_el["gid"] in tuple(d["gid"] for d in x)]
...:
...:
61.5 ms ± 913 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
In [69]: %%timeit
...: rez = [dict_el for dict_el in big if dict_el["gid"] in set(d["gid"] for d in x)]
...:
...:
55.9 ms ± 4.55 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
Самый быстрый вариант - разница в скорости будет более заметна если в x будет намного больше элементов. Причина тому - скорость проверки in list(...) - O(n), когда для множества: in set(...) всего O(1):
In [70]: %%timeit
...: x_val = set(d["gid"] for d in x)
...: rez = [dict_el for dict_el in big if dict_el["gid"] in x_val]
...:
...:
5.1 ms ± 72.6 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
Вывод: - если важна скорость - надо пробовать и сравнивать результаты, а не слепо доверять шаблонам и рекомендациям. Не всегда однострочник лучше)
1. Разница между list comprehension и explicit loop (явный цикл).
list comprehensionизначально всего лишь более лаконичный способ создания объекта типаlist, а не средство оптимизации - PEP 202 -- List Comprehensions.list comprehensionработает быстрее за счёт специальной инструкции для виртуальной машины -LIST_APPEND, а не за счёт отдельной, свойственной только ему способности к оптимизации.LIST_APPENDизбавляет от поиска атрибута/методаappendу объекта и последующего вызова этого метода, как это происходит в обычном цикле (lst.append(new_item)).- Уж если бы такая оптимизация была в Python, то она бы работала и в обычном цикле.
Сравним два способа создания списка:
s1 = '''lst = []
for item in range(10):
lst.append(item)'''
dis.dis(s1)
и
s2 = "lst = [item for item in range(10)]"
dis.dis(s2)
Инструкции исполняемые виртуальной машиной для наполнения списка соответственно:
>> 14 FOR_ITER 14 (to 30)
16 STORE_NAME 2 (item)
3 18 LOAD_NAME 0 (lst)
20 LOAD_ATTR 3 (append)
22 LOAD_NAME 2 (item)
24 CALL_FUNCTION 1
26 POP_TOP
28 JUMP_ABSOLUTE 14
и
>> 4 FOR_ITER 8 (to 14)
6 STORE_FAST 1 (item)
8 LOAD_FAST 1 (item)
10 LIST_APPEND 2
12 JUMP_ABSOLUTE 4
Отсюда и прибавка в производительности.
Вариант с обычным циклом можно ускорить следующим образом:
lst = []
append = lst.append
for item in range(10):
append(item)
Уменьшаем цикл на одну инструкцию, убрав из него поиск атрибута. Встречал этот способ в участках кода, требующих быстрой работы, например во встроенных модулях. Почему интерпретатор не делает это сам? Думаю, по той же причине, что и оптимизацию в вопросе.
2. Рассуждения на тему: "Почему Python не оптимизирует циклы?"
Как вам написали в английском Stackoverflow - из-за возможных side effects.
Нет гарантии, что во время перебора значений my_lst в этом примере, не происходит что-либо ещё.
for item in my_lst:
print(item)
Дело в том, что самостоятельно цикл for в my_lst не копается, в отличии от языка С, например. Каждая последовательность в Python знает как себя перебирать. Она это делает посредством своего итератора. Цикл for только запрашивает у неё очередное значение, а уж как его найти, отдать и какие операции выполнить по ходу дела, она решает сама. Также my_lst может быть как встроенным типом, так и созданным пользователем.
Соответственно, работу цикла for можно представить так:
my_iterator = iter(my_lst) # запрос итератора у последовательности
while True:
try:
item = next(my_iterator) # перебор значений
print(item)
except StopIteration:
break
Вывод:
Интерпретатор не может зафиксировать значения tuple(d["gid"] for d in x), сохранив этот кортеж (как сделал автор вопроса вручную) и использовать его во всех последующих итерациях, так как он не знает логику итератора x. Может этот итератор формирует значения в зависимости от текущего времени и значения будут каждый раз разные?
Пример кастомизированного типа list
В данном случае, итератор пишет слово "hello" при каждом запросе значения, а может делать что угодно, например, считать запросы, писать в лог и т.д.
class My_list(list):
def __iter__(self):
self.list_iter = super().__iter__()
return self
def __next__(self):
print("hello")
return self.list_iter.__next__()
my_lst = My_list()
my_lst.append(1)
my_lst.append(2)
my_lst.append(3)
my_lst.append(4)
my_lst.append(5)
for item in my_lst:
print(item)
Output
hello
1
hello
2
hello
3
hello
4
hello
5
Также Python имеет методы:
__setitem____getitem____getattribute____getattr__- и т.д.
которые позволяют кастомизировать поведение объектов в соответствии с нуждами пользователя.
Поэтому он не может принимать оптимизационные решения типа append = lst.append
lst = []
append = lst.append
for item in range(10):
append(item)
так как получение атрибута может обрабатываться в методе __getattribute__ объекта lst. И сделав такую оптимизацию, он изменит логику работы программы.