6.19. Продуктивність

Ті самі архітектурні рішення, що роблять numpy швидким на камері – виклики бібліотечних функцій для цілих масивів, упаковані буфери з типізацією, представлення, що спільно використовують дані з джерелом – також формують набір звичок, про які варто знати. Сторінка Форма та кроки вже охопила правило розміщення по останній осі; ця сторінка каталогізує звички виділення пам’яті та dtype, що найбільше важать у потоковому циклі.

6.19.1. Обирайте розумний dtype

Типовий dtype кожного конструктора – float. Для даних, що природньо є 8-бітними або 16-бітними – семплів ADC, пікселів зображень, показань датчиків – передавайте dtype= явно, вказуючи один із цілочисельних типів:

adc = np.array(adc_samples, dtype=np.uint16)

Економія RAM становить 2x для uint16 та 4x для uint8 порівняно з 4-байтовим типом float за замовчуванням. Математика також виконується швидше, оскільки цілочисельні шляхи коду всередині numpy є більш жорсткими, ніж узагальнені шляхи для float. Правило переповнення цілих чисел, розглянуте на сторінці Типи даних (dtype), застосовується – перед арифметикою, яка може переповнитися, виконуйте приведення до ширшого типу.

6.19.2. Надавайте перевагу ndarray над ітерованим об’єктом

Більшість функцій редукції та універсальних функцій приймають як ітерований об’єкт, так і ndarray

np.sum([1, 2, 3, 4, 5])               # works, but slow
np.sum(np.array([1, 2, 3, 4, 5]))     # ~3x faster

Форма з ітерованим об’єктом змушує numpy проходити вхідні дані по одному Python-об’єкту за раз, перетворюючи кожен у число перш ніж він може його використати. З ndarray перетворення вже виконано, і виклик проходить прямо через упакований буфер.

Коли одні й ті самі дані використовуються більше одного разу, створіть ndarray один раз і передавайте його. Коли дані існують лише як список Python і споживаються один раз, вартість перетворення може перевищити прискорення – конструктор array() сам має обходити список та виділяти пам’ять.

6.19.3. Надавайте перевагу представленням над копіями

Зрізи, індексування за однією віссю масиву вищого рангу, reshape(), transpose() та frombuffer() – усі повертають представлення, що спільно використовують дані з джерелом. Вони практично безкоштовні.

copy(), flatten(), булеве індексування (a[mask]) та будь-який арифметичний вираз виділяють копію. Вдавайтеся до них лише тоді, коли незалежний буфер дійсно необхідний.

Якщо є сумніви, ndinfo() виводить розташування базового буфера; два масиви, що повідомляють однакову адресу, спільно використовують дані. Повна таблиця представлення та копії знаходиться на Представлення та копії.

6.19.4. Виділяйте пам’ять один раз, потім записуйте

Найбільший вузьке місце продуктивності на камері – виділення нових масивів усередині циклу, який виконується багато разів на секунду. Кожен новий ndarray запитує у камери RAM, а часті нові виділення витрачають її.

Більшість універсальних функцій приймають out=, щоб результат можна було записати у вже існуючий масив:

x = np.linspace(0, 2 * np.pi, num=512)
y = np.zeros(512)        # allocate once

while True:
    np.sin(x, out=y)
    # use y ...

image.Image.to_ndarray() приймає buffer= з тієї самої причини; spectrogram() та конвертери у стилі from_int32_buffer() приймають і out=, і scratchpad=. Виділяйте все один раз і повторно використовуйте.

6.19.5. Використовуйте оператори «на місці»

b = b + 1 виділяє тимчасовий масив розміром b, копіює та перепризначає. b += 1 модифікує b безпосередньо:

# makes a temporary
b = b + 1

# no temporary
b += 1

Та сама ідея застосовується до складених виразів. a + b * c виділяє тимчасовий масив для b * c. Розбивання виразу на прості підвирази, що записуються у попередньо виділений буфер, усуває тимчасові масиви:

# one temporary for (a + b), another for the ``* 2``
out = (a + b) * 2

# zero temporaries
out[:]  = a
out    += b
out    *= 2

6.19.6. Будуйте результат, не додавайте до нього по частинах

ndarray не має append – навмисно. Розширення масиву означало б виділення нового, більшого буфера та копіювання старого вмісту до нього. На мікроконтролері заздалегідь виділяйте кінцевий розмір і заповнюйте його:

out = np.zeros(N, dtype=np.float)
for i in range(N):
    out[i] = some_calculation(i)

Коли N справді не відоме заздалегідь, записуйте у Python list і виконуйте одне перетворення наприкінці за допомогою array().

6.19.7. Присвоєння через зріз замість нових масивів

Багато патернів «побудувати новий масив із частин інших» можна виразити як присвоєння через зріз у попередньо виділений буфер замість нового виділення при кожному виклику.

Ковзне вікно над потоком семплів – основа фільтра ковзного середнього – є канонічним прикладом. Буфер зберігає останні N семплів; кожна ітерація відкидає найстаріший і додає найновіший. Очевидна форма перебудовує буфер при кожній ітерації:

while True:
    sample = read_sample()
    buf = np.concatenate((buf[1:],              # new buffer every loop
                          np.array([sample])))
    avg = np.mean(buf)

Це нове виділення – і копія N - 1 елементів – на кожен семпл. Форма з присвоєнням через зріз зсуває на місці:

N   = 16
buf = np.zeros(N, dtype=np.float)               # allocate once

while True:
    sample   = read_sample()
    buf[:-1] = buf[1:]                          # shift left by one
    buf[-1]  = sample                           # append at the end
    avg      = np.mean(buf)

buf[:-1] = buf[1:] – це цікавий рядок: два перекриваючихся представлення того самого буфера, де правий зріз читається з одного кінця і записується в інший. numpy обходить базову пам’ять у порядку, що робить зсув на місці безпечним. Усередині циклу жодного нового масиву ніколи не виділяється.

6.19.8. Остерігайтеся булевих масок у потокових циклах

Булеве індексування та where() створюють новий масив при кожному виклику – розмір результату залежить від даних, тому жоден попередньо виділений буфер не може поглинути виділення. Повторне побудування маски у потоковому циклі заповнює RAM одноразовими масивами. Виклик gc.collect() з певною частотою відновлює простір:

import gc

for i in range(1000):
    mask = a < threshold
    _    = a[mask]
    if i % 100 == 0:
        gc.collect()

Те саме застереження стосується складених булевих виразів, як-от (a > lo) & (a < hi) – кожен оператор виділяє новий булевий масив. Коли маска повторно використовується, будуйте її один раз і зберігайте:

mask = a < threshold
foo[mask] = 0
bar[mask] = 1