Почему Python массивы замедляются?

Я ожидал, что array.array будет быстрее, чем списки, поскольку массивы вроде бы распаковываются.

Однако я получаю следующий результат:

In [1]: import array

In [2]: L = list(range(100000000))

In [3]: A = array.array('l', range(100000000))

In [4]: %timeit sum(L)
1 loop, best of 3: 667 ms per loop

In [5]: %timeit sum(A)
1 loop, best of 3: 1.41 s per loop

In [6]: %timeit sum(L)
1 loop, best of 3: 627 ms per loop

In [7]: %timeit sum(A)
1 loop, best of 3: 1.39 s per loop

Что может быть причиной такой разницы?

Ответ 1

Хранилище "распаковано", но каждый раз, когда вы обращаетесь к элементу, Python должен "вставить" его (встроить его в обычный объект Python), чтобы что-то с ним делать. Например, ваш sum(A) выполняет итерацию по массиву и помещает каждое целое число по одному в обычный объект Python int. Это стоит времени. В вашем sum(L) весь бокс был выполнен во время создания списка.

Итак, в конце концов, массив, как правило, медленнее, но требует существенно меньше памяти.


Вот соответствующий код из недавней версии Python 3, но те же основные идеи относятся ко всем реализациям CPython, поскольку Python был впервые выпущен.

Вот код для доступа к элементу списка:

PyObject *
PyList_GetItem(PyObject *op, Py_ssize_t i)
{
    /* error checking omitted */
    return ((PyListObject *)op) -> ob_item[i];
}

Там очень мало: somelist[i] просто возвращает i'th объект в списке (и все объекты Python в CPython являются указателями на структуру, начальный сегмент которой соответствует макету struct PyObject).

И вот реализация __getitem__ для array с кодом типа l:

static PyObject *
l_getitem(arrayobject *ap, Py_ssize_t i)
{
    return PyLong_FromLong(((long *)ap->ob_item)[i]);
}

Необработанная память рассматривается как вектор основанных на платформе целых чисел C long; считывается i 'th C long; и затем PyLong_FromLong() вызывается для обертывания ( "box" ) родного C long в объекте Python long (который в Python 3, который исключает различие Python 2 между int и long, фактически показан как тип int).

Этот бокс должен выделять новую память для объекта Python int и сбрасывать в нее собственные бит C long. В контексте исходного примера это время жизни объекта очень кратковременно (достаточно просто для sum(), чтобы добавить содержимое в текущее количество), а затем требуется больше времени для освобождения нового объекта int.

Здесь происходит различие скорости, всегда приходило и всегда будет реализовано в реализации CPython.

Ответ 2

Чтобы добавить к превосходному ответу Тима Петерса, массивы реализуют буферный протокол, а списки - нет. Это означает, что, если вы пишете расширение C (или моральный эквивалент, например, написание модуля Cython), вы можете получить доступ и работать с элементами массива намного быстрее, чем все, что может сделать Python. Это даст вам значительные улучшения в скорости, возможно, на порядок. Однако он имеет ряд недостатков:

  • Теперь вы занимаетесь написанием C вместо Python. Китон - один из способов улучшить это, но он не устраняет многие фундаментальные различия между языками; вы должны быть знакомы с семантикой C и понимать, что она делает.
  • PyPy C API работает в некоторой степени, но не очень быстро. Если вы настроите PyPy, вы должны просто написать простой код с регулярными списками, а затем позволить JITter оптимизировать его для вас.
  • Расширения C сложнее распространять, чем чистый код Python, потому что их необходимо скомпилировать. Компиляция имеет тенденцию быть зависимой от архитектуры и операционной системы, поэтому вам нужно будет обеспечить компиляцию для вашей целевой платформы.

Прямо на C-удлинители можно использовать кувалду, чтобы размахивать мухой, в зависимости от вашего варианта использования. Сначала вы должны исследовать NumPy и посмотреть, достаточно ли он достаточно, чтобы делать какую-либо математику, которую вы пытаетесь сделать. Он также будет намного быстрее, чем собственный Python, если он будет использован правильно.

Ответ 3

Тим Питерс ответил , почему это медленно, но пусть видит , как улучшить.

Придерживаясь вашего примера sum(range(...)) (коэффициент 10 меньше вашего примера, чтобы вписаться в память здесь):

import numpy
import array
L = list(range(10**7))
A = array.array('l', L)
N = numpy.array(L)

%timeit sum(L)
10 loops, best of 3: 101 ms per loop

%timeit sum(A)
1 loop, best of 3: 237 ms per loop

%timeit sum(N)
1 loop, best of 3: 743 ms per loop

В этом случае numpy необходимо также использовать box/unbox, у которого есть дополнительные накладные расходы. Чтобы сделать это быстро, нужно оставаться внутри кода numpy c:

%timeit N.sum()
100 loops, best of 3: 6.27 ms per loop

Итак, из решения списка в версию numpy это фактор 16 во время выполнения.

Пусть также проверяет, как долго создаются эти структуры данных.

%timeit list(range(10**7))
1 loop, best of 3: 283 ms per loop

%timeit array.array('l', range(10**7))
1 loop, best of 3: 884 ms per loop

%timeit numpy.array(range(10**7))
1 loop, best of 3: 1.49 s per loop

%timeit numpy.arange(10**7)
10 loops, best of 3: 21.7 ms per loop

Очистить победителя: Нравится

Также обратите внимание, что создание структуры данных занимает примерно столько же времени, сколько и суммирование, если не больше. Выделение памяти происходит медленно.

Использование памяти:

sys.getsizeof(L)
90000112
sys.getsizeof(A)
81940352
sys.getsizeof(N)
80000096

Таким образом, они занимают 8 байтов на номер с различными накладными расходами. Для диапазона, которым мы используем 32-битные int, достаточно, поэтому мы можем сохранить некоторую память.

N=numpy.arange(10**7, dtype=numpy.int32)

sys.getsizeof(N)
40000096

%timeit N.sum()
100 loops, best of 3: 8.35 ms per loop

Но оказывается, что добавление 64-битных ints быстрее, чем 32bit ints на моей машине, поэтому это стоит того, только если вы ограничены памятью/пропускной способностью.