Почему этот код нарезки быстрее, чем более процедурный код?

У меня есть функция Python, которая берет список и возвращает генератор, дающий 2-кортежи каждой смежной пары, например

>>> list(pairs([1, 2, 3, 4]))
[(1, 2), (2, 3), (3, 4)]

Я рассмотрел реализацию с использованием 2 срезов:

def pairs(xs):
    for p in zip(xs[:-1], xs[1:]): 
        yield p

и один написан в более процедурном стиле:

def pairs(xs):
    last = object()
    dummy = last
    for x in xs:
        if last is not dummy:
            yield last,x
        last = x

Тестирование с использованием range(2 ** 15) в качестве ввода дает следующие моменты (вы можете найти мой тестовый код и вывести здесь):

2 slices: 100 loops, best of 3: 4.23 msec per loop
0 slices: 100 loops, best of 3: 5.68 msec per loop

Частью хита производительности для sliceless-реализации является сравнение в цикле (if last is not dummy). Удаление этого (неверный вывод) улучшает его производительность, но оно все же медленнее, чем реализация zip-a-pair-of-slices:

2 slices: 100 loops, best of 3: 4.48 msec per loop
0 slices: 100 loops, best of 3: 5.2 msec per loop

Итак, я в тупике. Зачем сжимать два среза, эффективно повторяя их по списку дважды параллельно, быстрее, чем повторять один раз, обновляя last и x, когда вы идете?

ИЗМЕНИТЬ

Дан Ленски предложил третью реализацию:

def pairs(xs):
    for ii in range(1,len(xs)):
        yield xs[ii-1], xs[ii]

Здесь его сравнение с другими реализациями:

2 slices: 100 loops, best of 3: 4.37 msec per loop
0 slices: 100 loops, best of 3: 5.61 msec per loop
Lenski's: 100 loops, best of 3: 6.43 msec per loop

Это еще медленнее! Что меня озадачивает.

ИЗМЕНИТЬ 2:

ssm предложил, используя itertools.izip вместо zip, и он даже быстрее, чем zip:

2 slices, izip: 100 loops, best of 3: 3.68 msec per loop

Итак, izip - победитель! Но по-прежнему трудно проверить причины.

Ответ 1

Много интересной дискуссии в другом месте этой темы. В принципе, мы начали сравнивать две версии этой функции, которые я буду описывать со следующими немыми именами:

  • Версия zip -py:

    def pairs(xs):
        for p in zip(xs[:-1], xs[1:]): 
            yield p
    
  • Версия "loopy":

    def pairs(xs):
        last = object()
        dummy = last
        for x in xs:
            if last is not dummy:
                yield last,x
            last = x
    

Так почему же петлевая версия оказывается медленнее? В принципе, я думаю, что это сводится к нескольким вещам:

  • Строчная версия явно выполняет дополнительную работу: она сравнивает идентификаторы двух объектов (if last is not dummy: ...) на каждой инициализирующей пару итерации внутреннего цикла.

    • @mambocab edit показывает, что не делать этого сравнения делает петлевую версию
      немного быстрее, но не полностью закрывает пробел.

  • В zippy-версии больше всего содержится в компилированном C-коде, который имеет петлевая версия в коде Python:

    • Объединение двух объектов в tuple. Завершающая версия имеет yield last,x, тогда как в zippy-версии кортеж p поступает прямо от zip, поэтому он просто делает yield p.

    • Связывание имен переменных с объектом: циклическая версия делает это дважды в каждом цикле, назначая x в цикле for и last=x. Версия zippy делает это только один раз, в цикле for.

  • Интересно, что существует один способ, с помощью которого zippy-версия действительно выполняет больше: она использует два listiterator s, iter(xs[:-1]) и iter(xs[1:]), которые передаются в zip. В циклической версии используется один listiterator (for x in xs).

    • Создание объекта listiterator (вывод iter([])), вероятно, очень оптимизирован, поскольку программисты Python используют его так часто.
    • Итерация над списками списков xs[:-1] и xs[1:] - очень легкая операция, которая практически не увеличивает накладные расходы по сравнению с итерированием по всему списку. По сути, это просто означает перемещение начальной или конечной точки итератора, но не изменение того, что происходит на каждой итерации.

Ответ 2

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

In [11]: %timeit pairsLoop([1,2,3,4,5])
1000000 loops, best of 3: 651 ns per loop

In [12]: %timeit pairsZip([1,2,3,4,5])
1000000 loops, best of 3: 637 ns per loop

In [13]: %timeit pairsIzip([1,2,3,4,5])
1000000 loops, best of 3: 655 ns per loop

Ниже приведена версия кода ниже:

from itertools import izip


def pairsIzip(xs):
    for p in izip(xs[:-1], xs[1:]): 
        yield p

def pairsZip(xs):
    for p in zip(xs[:-1], xs[1:]): 
        yield p

def pairsLoop(xs):
    last = object()
    dummy = last
    for x in xs:
        if last is not dummy:
            yield last,x
        last = x

Ответ 3

Я подозреваю, что основная причина, по которой вторая версия медленнее, заключается в том, что она выполняет операцию сравнения для каждой пары, которая yield s:

# pair-generating loop
for x in xs:
    if last is not dummy:
       yield last,x
    last = x

Первая версия не делает ничего, кроме плюсов. Переименованные переменные цикла эквивалентны этому:

# pair-generating loop
for last,x in zip(xs[:-1], xs[1:]):
    yield last,x 

Это не особенно красиво или Pythonic, но вы можете написать процедурный вариант без сравнения во внутреннем цикле. Как быстро это выполняется?

def pairs(xs):
    for ii in range(1,len(xs)):
        yield xs[ii-1], xs[ii]