#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…