Есть ли альтернатива zip (*итерируемый), когда итерируемый состоит из миллионов элементов?

#python #python-3.x #optimization #iterable-unpacking

#python #python-3.x #оптимизация #iterable-распаковка

Вопрос:

Я наткнулся на код, подобный этому:

 from random import randint

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

points = [Point(randint(1, 10), randint(1, 10)) for _ in range(10)]
xs = [point.x for point in points]
ys = [point.y for point in points]
  

И я думаю, что этот код не Pythonic, потому что он повторяется. Если в Point класс добавляется другое измерение, необходимо записать целый новый цикл, подобный:

 zs = [point.z for point in points]
  

Итак, я попытался сделать его более питоническим, написав что-то вроде этого:

 xs, ys = zip(*[(point.x, point.y) for point in p])
  

Если добавлено новое измерение, проблем нет:

 xs, ys, zs = zip(*[(point.x, point.y, point.z) for point in p])
  

Но это почти в 10 раз медленнее, чем другое решение, когда есть миллионы точек, хотя оно имеет только один цикл. Я думаю, это потому, что * оператору нужно распаковать миллионы аргументов в zip функцию, которая ужасна. Итак, мой вопрос:

Есть ли способ изменить приведенный выше код, чтобы он был таким же быстрым, как раньше, и Pythonic (без использования сторонних библиотек)?

Комментарии:

1. Во-первых, вы могли бы использовать генератор вместо построения полного списка: zip(*((point.x, point.y, point.z) for point in p)) . Насколько это поможет в отличие от использования полностью другого подхода, я не могу сказать сразу.

2. @deceze Я не знаю почему, но это еще медленнее.

3. @deceze: Это вообще не поможет. Распаковка аргумента всегда преобразует его в tuple из того, чем он оказался, так что для заполнения tuple просто используется более дорогое выражение генератора, а не более дешевый listcomp с последующим быстрым мелким копированием.

4. @ShadowRanger Я вижу, это объясняет это, спасибо.

5. @Tryph Конечно, это будет быстрее, но я думаю, что это было бы мошенничеством 🙂 Я мог бы написать этот код на C, и это будет в 5 раз быстрее. Я пытаюсь понять, почему это медленно и как я мог бы это улучшить.

Ответ №1:

Я только что протестировал несколько способов архивирования Point координат и проверил их производительность при увеличении количества точек.

Ниже приведены функции, которые я использовал для тестирования:

 def hardcode(points):
    # a hand crafted comprehension for each coordinate
    return [point.x for point in points], [point.y for point in points]


def using_zip(points):
    # using the "problematic" qip function
    return zip(*((point.x, point.y) for point in points))


def loop_and_comprehension(points):
    # making comprehension from a list of coordinate names
    zipped = []
    for coordinate in ('x', 'y'):
        zipped.append([getattr(point, coordinate) for point in points])
    return zipped


def nested_comprehension(points):
    # making comprehension from a list of coordinate names using nested
    # comprehensions
    return [
        [getattr(point, coordinate) for point in points]
        for coordinate in ('x', 'y')
    ]
  

Используя timeit, я рассчитал время выполнения каждой функции с разным количеством точек, и вот результаты:

 comparing processing times using 10 points and 10000000 iterations
hardcode................. 14.12024447 [ 0%]
using_zip................ 16.84289724 [ 19%]
loop_and_comprehension... 30.83631476 [ 118%]
nested_comprehension..... 30.45758349 [ 116%]

comparing processing times using 100 points and 1000000 iterations
hardcode................. 9.30594717 [ 0%]
using_zip................ 13.74953714 [ 48%]
loop_and_comprehension... 19.46766583 [ 109%]
nested_comprehension..... 19.27818860 [ 107%]

comparing processing times using 1000 points and 100000 iterations
hardcode................. 7.90372457 [ 0%]
using_zip................ 12.51523594 [ 58%]
loop_and_comprehension... 18.25679913 [ 131%]
nested_comprehension..... 18.64352790 [ 136%]

comparing processing times using 10000 points and 10000 iterations
hardcode................. 8.27348382 [ 0%]
using_zip................ 18.23079485 [ 120%]
loop_and_comprehension... 18.00183383 [ 118%]
nested_comprehension..... 17.96230063 [ 117%]

comparing processing times using 100000 points and 1000 iterations
hardcode................. 9.15848662 [ 0%]
using_zip................ 22.70730675 [ 148%]
loop_and_comprehension... 17.81126971 [ 94%]
nested_comprehension..... 17.86892597 [ 95%]

comparing processing times using 1000000 points and 100 iterations
hardcode................. 9.75002857 [ 0%]
using_zip................ 23.13891725 [ 137%]
loop_and_comprehension... 18.08724660 [ 86%]
nested_comprehension..... 18.01269820 [ 85%]

comparing processing times using 10000000 points and 10 iterations
hardcode................. 9.96045920 [ 0%]
using_zip................ 23.11653558 [ 132%]
loop_and_comprehension... 17.98296033 [ 81%]
nested_comprehension..... 18.17317708 [ 82%]

comparing processing times using 100000000 points and 1 iterations
hardcode................. 64.58698246 [ 0%]
using_zip................ 92.53437881 [ 43%]
loop_and_comprehension... 73.62493845 [ 14%]
nested_comprehension..... 62.99444739 [-2%]
  

Мы можем видеть, что разрыв между «закодированным» решением и решениями с пониманием, созданными с gettattr , кажется, постоянно сокращается по мере роста количества точек.

Итак, для очень большого количества точек может быть хорошей идеей использовать сгенерированные представления из списка координат:

 [[getattr(point, coordinate) for point in points]
 for coordinate in ('x', 'y')]
  

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


Для информации, вот код, который я использовал для запуска этого теста:

 import timeit


...


def compare(nb_points, nb_iterations):
    reference = None
    points = [Point(randint(1, 100), randint(1, 100))
              for _ in range(nb_points)]
    print("comparing processing times using {} points and {} iterations"
          .format(nb_points, nb_iterations))

    for func in (hardcode, using_zip, loop_and_comprehension, nested_comprehension):
        duration = timeit.timeit(lambda: func(points), number=nb_iterations)

        print('{:.<25} {:0=2.8f} [{:0> .0%}]'
              .format(func.__name__, duration,
                      0 if reference is None else (duration / reference - 1)))

        if reference is None:
            reference = duration

    print("-" * 80)



compare(10, 10000000)
compare(100, 1000000)
compare(1000, 100000)
compare(10000, 10000)
compare(100000, 1000)
compare(1000000, 100)
compare(10000000, 10)
compare(100000000, 1)
  

Ответ №2:

Проблема с zip(*iter) заключается в том, что он будет выполнять итерацию по всему итерируемому и передавать результирующую последовательность в качестве аргументов в zip.

Таким образом, они функционально одинаковы:

Использование *: xs, ys = zip(*[(p.x, p.y) for p in ((0,1),(0,2),(0,3))])

Использование позиционных значений: xz, ys = zip((0,1),(0,2),(0,3)) .

Очевидно, что при наличии миллионов позиционных аргументов это будет медленно.

Подход итератора — это единственный обходной путь.

Я выполнил поиск в Интернете по python itertools unzip . К сожалению, самый близкий itertools вариант — это tee . В ссылке на суть выше, кортеж итераторов из itertools.tee возвращается из этой реализации iunzip : https://gist.github.com/andrix/106334 .

Мне пришлось преобразовать его в python3:

 from random import randint
import itertools
import time
from operator import itemgetter

def iunzip(iterable):
    """Iunzip is the same as zip(*iter) but returns iterators, instead of 
    expand the iterator. Mostly used for large sequence"""

    _tmp, iterable = itertools.tee(iterable, 2)
    iters = itertools.tee(iterable, len(next(_tmp)))
    return (map(itemgetter(i), it) for i, it in enumerate(iters))

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

points = [Point(randint(1, 10), randint(1, 10)) for _ in range(1000000)]
itime = time.time()
xs = [point.x for point in points]
ys = [point.y for point in points]
otime = time.time() - itime
itime  = otime
print(f"original: {otime}")
xs, ys = zip(*[(p.x, p.y) for p in points])
otime = time.time() - itime
itime  = otime
print(f"unpacking into zip: {otime}")
xs, ys = iunzip(((p.x, p.y) for p in points))
for _ in zip(xs, ys): pass
otime = time.time() - itime
itime  = otime
print(f"iunzip: {otime}")
  

Вывод:

 original: 0.1282501220703125
unpacking into zip: 1.286362886428833
iunzip: 0.3046858310699463
  

Таким образом, итератор определенно лучше, чем распаковка в позиционные аргументы. Не говоря уже о том, что мои 4 ГБ памяти были съедены, когда я дошел до 10 миллионов точек… Однако я не уверен, что iunzip приведенный выше итератор настолько оптимален, насколько это могло бы быть, если бы это был встроенный python, учитывая, что повторение дважды для выполнения распаковки, как в «оригинальном» методе, по-прежнему является самым быстрым (~ в 4 раза быстрее при попытке с различной длиной точек).

Кажется, что iunzip должно быть что-то. Я удивлен, что это не встроенный python или часть itertools…