Чому масиви Python повільні?


153

Я очікував, що 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

Що може бути причиною такої різниці?


4
інструменти numpy можуть ефективно використовувати свій масив:% timeit np.sum (A): 100 циклів, найкраще 3: 8,87 мс на цикл
BM

6
Я ніколи не стикався з ситуацією, коли мені потрібно було використовувати arrayпакет. Якщо ви хочете зайнятись великою кількістю математики, Numpy працює зі швидкістю світла (тобто С), і, як правило, краще, ніж наївні реалізації подібних речей sum()).
Нік Т

40
Близькі виборці: Чому саме на цій думці? Здається, ОП задає конкретне, технічне запитання щодо вимірюваного та повторюваного явища.
Кевін

5
@NickT Прочитайте анекдот з оптимізації . Виходить arrayдосить швидко в перетворенні рядка цілих чисел (що представляють ASCII байти) в strоб'єкт. Сам Гвідо придумав це лише після багатьох інших рішень і був дуже здивований виступом. У всякому разі, це єдине місце, де я пам’ятаю, як бачив, що це корисно. numpyнабагато краще для роботи з масивами, але це стороння залежність.
Бакуріу

Відповіді:


220

Зберігання є «розпакований», але кожен раз , коли ви отримуєте доступ до елементу Python повинен «поле» він (вставити його в звичайний об'єкт Python) для того , щоб зробити що - небудь з ним. Наприклад, ваші sum(A)ітерації над масивом та заповнюють кожне ціле число, по одному, у звичайному intоб’єкті Python . Це коштує часу. У вашому 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'-вий об’єкт у списку (і всі об’єкти Python в CPython є вказівниками на структуру, початковий сегмент якої відповідає макеті a 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цілих чисел платформи ; iC longчитається до; а потім PyLong_FromLong()викликається загортати ("коробку") нативну C longв об'єкт Python long(що в Python 3, що виключає відмінність Python 2 між intі long, насправді відображається як тип int).

Цей бокс повинен виділити нову пам'ять для intоб'єкта Python та розпорошити в нього натільні C longбіти. У контексті оригінального прикладу, термін служби цього об’єкта дуже короткий (достатньо довгий, sum()щоб додати вміст до поточного загального), а потім потрібно більше часу для розміщення нового intоб’єкта.

Ось звідки походить різниця швидкостей, завжди походить і завжди виходитиме з реалізації CPython.


87

Щоб додати до відмінної відповіді Тіма Пітерса, масиви реалізують буферний протокол , тоді як списки - ні. Це означає, що якщо ви пишете розширення C (або моральний еквівалент, такий як написання модуля Cython ), то ви можете отримати доступ до роботи з елементами масиву набагато швидше, ніж все, що може зробити Python. Це дасть вам значне покращення швидкості, можливо, набагато більше на порядок. Однак він має ряд недоліків:

  1. Зараз ви займаєтеся написанням C замість Python. Cython - це один із способів поліпшити це, але він не усуває багатьох принципових відмінностей між мовами; вам потрібно ознайомитись із семантикою С і зрозуміти, що вона робить.
  2. API API PyPy певною мірою працює , але не дуже швидко. Якщо ви орієнтуєтесь на PyPy, вам, мабуть, слід просто написати простий код із звичайними списками, а потім дозволити JITter оптимізувати його для вас.
  3. Розширення C важче розподілити, ніж чистий код Python, оскільки їх потрібно компілювати. Компіляція, як правило, залежить від архітектури та операційної системи, тому вам потрібно буде забезпечити компіляцію для вашої цільової платформи.

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


10

Тім Пітерс відповів, чому це повільно, але давайте подивимось, як це покращити .

Дотримуйтесь вашого прикладу 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 c:

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

Отже, від рішення списку до нумерованої версії це коефіцієнт 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-бітові вставки, достатньо, щоб ми могли зберегти деяку пам'ять.

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-бітових дюймів швидше, ніж 32-бітові дюйми на моїй машині, тому цього варто лише в тому випадку, якщо ви обмежені пам’яттю / пропускною здатністю.


-1

Зверніть увагу , що 100000000прирівнює до 10^8НЕ 10^7, і мої результати як folowwing:

100000000 == 10**8

# my test results on a Linux virtual machine:
#<L = list(range(100000000))> Time: 0:00:03.263585
#<A = array.array('l', range(100000000))> Time: 0:00:16.728709
#<L = list(range(10**8))> Time: 0:00:03.119379
#<A = array.array('l', range(10**8))> Time: 0:00:18.042187
#<A = array.array('l', L)> Time: 0:00:07.524478
#<sum(L)> Time: 0:00:01.640671
#<np.sum(L)> Time: 0:00:20.762153
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.