6.19. Производительность

Те же проектные решения, которые делают numpy быстрым на камере – вызовы библиотеки над массивом целиком, упакованные типизированные буферы, представления, разделяющие данные со своим источником, – также порождают набор привычек, о которых стоит знать. На странице Форма и шаги (strides) уже было рассмотрено правило размещения по последней оси; на этой странице каталогизированы привычки, связанные с выделением памяти и dtype, которые важнее всего в потоковом цикле.

6.19.1. Выбирайте разумный dtype

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

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

Экономия RAM составляет 2 раза для uint16 и 4 раза для uint8 по сравнению с 4-байтовым значением по умолчанию float. Вычисления также выполняются быстрее, потому что целочисленные пути кода внутри numpy компактнее обобщённых для чисел с плавающей точкой. Применяется правило целочисленного переполнения, рассмотренное на Dtypes, – приводите к более широкому типу перед арифметикой, которая может переполниться.

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