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