Фильтрация понятий списка - "ловушка set()"

Достаточно общая операция заключается в фильтрации одного list на основе другого list. Люди быстро находят, что это:

[x for x in list_1 if x in list_2]

медленный для больших входов - это O (n * m). Тьфу. Как мы ускоряем это? Используйте set для поиска фильтров O (1):

s = set(list_2)
[x for x in list_1 if x in s]

Это дает хорошее общее поведение O (n). Я часто вижу, что даже ветеран-кодеры попадают в The Trap ™:

[x for x in list_1 if x in set(list_2)]

Ack! Это снова O (n * m), поскольку python создает set(list_2) каждый раз, а не только один раз.


Я думал, что это конец истории - python не может оптимизировать его, чтобы только создать set один раз. Просто знай о ловушке. Должен жить с этим. Хм.

#python 3.3.2+
list_2 = list(range(20)) #small for demonstration purposes
s = set(list_2)
list_1 = list(range(100000))
def f():
    return [x for x in list_1 if x in s]
def g():
    return [x for x in list_1 if x in set(list_2)]
def h():
    return [x for x in list_1 if x in {0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19}]

%timeit f()
100 loops, best of 3: 7.31 ms per loop

%timeit g()
10 loops, best of 3: 77.4 ms per loop

%timeit h()
100 loops, best of 3: 6.66 ms per loop

Да, python (3.3) может оптимизировать заданный литерал. Он даже быстрее, чем f() в этом случае, по-видимому, потому, что он заменяет a LOAD_GLOBAL на LOAD_FAST.

#python 2.7.5+
%timeit h()
10 loops, best of 3: 72.5 ms per loop

Python 2, в частности, не делает эту оптимизацию. Я попытался еще раз изучить, что делает python3, но, к сожалению, dis.dis не может исследовать внутренности выражений понимания. В основном все интересное превращается в MAKE_FUNCTION.

Итак, теперь мне интересно - почему python 3.x оптимизирует заданный литерал только для сборки один раз, но не set(list_2)?

Ответ 1

Чтобы оптимизировать set(list_2), интерпретатор должен доказать, что list_2 (и все его элементы) не изменяется между итерациями. Это сложная проблема в общем случае, и меня не удивит, если интерпретатор даже не попытается ее решить.

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

Ответ 2

От Что нового в Python 3.2:

Оптимизатор Pythons peephole теперь распознает шаблоны, такие как x in {1, 2, 3} как тест для членства в наборе констант. Оптимизатор переустанавливает набор как фризонсет и сохраняет заранее созданную константу.

Ответ 3

Итак, теперь мне интересно - почему python 3.x может оптимизировать настройку literal только для сборки один раз, но не установлен (list_2)?

Никто еще не упоминал эту проблему: как вы знаете, что set([1,2,3]) и {1, 2, 3} - это одно и то же?

>>> import random
>>> def set(arg):
...     return [random.choice(range(5))]
... 
>>> list1 = list(range(5))
>>> [x for x in list1 if x in set(list1)]
[0, 4]
>>> [x for x in list1 if x in set(list1)]
[0]

Вы не можете затенять литерал; вы можете затенять set. Поэтому, прежде чем вы сможете рассмотреть подъем, вам нужно знать не только то, что list1 не влияет, вы должны быть уверены, что set - это то, что вы думаете. Иногда вы можете это сделать, либо в ограниченных условиях во время компиляции, либо более удобно во время выполнения, но это определенно нетривиально.

Это смешно: часто, когда появляется предложение сделать такие оптимизации, один pushback - это так же хорошо, как и они, это затрудняет рассуждение о том, как будет выглядеть производительность Python, даже алгоритмически. Ваш вопрос дает некоторые доказательства для этого возражения.

Ответ 4

Слишком долго для комментария

Это не говорит о деталях оптимизации или различиях v2 vs. v3. Но когда я сталкиваюсь с этим в некоторых ситуациях, я нахожу, что создание менеджера контекста из объекта данных полезно:

class context_set(set):
    def __enter__(self):
        return self
    def __exit__(self, *args):
        pass

def context_version():
    with context_set(list_2) as s:
        return [x for x in list_1 if x in s]

Используя это, я вижу:

In [180]: %timeit context_version()
100 loops, best of 3: 17.8 ms per loop

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

Более общую версию можно сделать с помощью contextlib.contextmanager. Вот быстрая и грязная версия того, что я имею в виду.

def context(some_type):
    from contextlib import contextmanager
    generator_apply_type = lambda x: (some_type(y) for y in (x,))
    return contextmanager(generator_apply_type)

Тогда можно сделать:

with context(set)(list_2) as s:
    # ...

или так же легко

with context(tuple)(list_2) as t:
    # ...

Ответ 5

Основная причина заключается в том, что литерал действительно не может измениться, тогда как если это выражение типа set(list_2), возможно, что оценка целевого выражения или итерабельности понимания может изменить значение set(list_2). Например, если у вас есть

[f(x) for x in list_1 if x in set(list_2)]

Возможно, что f изменяет list_2.

Даже для простого выражения [x for x in blah ...] теоретически возможно, что метод __iter__ blah может изменить list_2.

Я бы предположил, что есть некоторые возможности для оптимизации, но текущее поведение упрощает работу. Если вы начнете добавлять оптимизацию для таких вещей, как "она оценивается только один раз, если целевое выражение является единственным голосом, а итерируемый - встроенным списком или dict...", вы значительно усложняете, чтобы выяснить, что произойдет в любом данной ситуации.