Максимальное повышение скорости MicroPython¶
В этом руководстве описываются способы повышения производительности кода на MicroPython. Оптимизации, связанные с другими языками, рассматриваются в других местах, а именно использование модулей, написанных на C, и встроенного ассемблера MicroPython.
Процесс разработки высокопроизводительного кода состоит из следующих этапов, которые следует выполнять в указанном порядке.
Проектирование с учётом скорости.
Написание кода и отладка.
Этапы оптимизации:
Определите самый медленный участок кода.
Повысьте эффективность кода на Python.
Используйте генератор нативного кода (native code emitter).
Используйте генератор кода viper (viper code emitter).
Используйте аппаратно-зависимые оптимизации.
Проектирование с учётом скорости¶
Вопросы производительности следует учитывать с самого начала. Это предполагает выявление наиболее критичных к производительности участков кода и уделение особого внимания их проектированию. Процесс оптимизации начинается после того, как код протестирован: если проект изначально корректен, оптимизация будет простой и может оказаться вовсе ненужной.
Алгоритмы¶
Самый важный аспект проектирования любой процедуры с учётом производительности — это применение наилучшего алгоритма. Это тема для учебников, а не для руководства по MicroPython, но иногда можно добиться впечатляющего прироста производительности, применяя алгоритмы, известные своей эффективностью.
Выделение RAM¶
Чтобы проектировать эффективный код на MicroPython, необходимо понимать, как интерпретатор выделяет RAM. Когда объект создаётся или увеличивается в размере (например, когда элемент добавляется в список), необходимая RAM выделяется из блока, известного как куча (heap). Это занимает значительное время; кроме того, иногда это запускает процесс, известный как сборка мусора, который может занимать несколько миллисекунд.
Следовательно, производительность функции или метода можно повысить, если объект создаётся только один раз и не увеличивается в размере. Это подразумевает, что объект сохраняется на протяжении всего времени его использования: как правило, он создаётся в конструкторе класса и используется в различных методах.
Это подробнее рассматривается в разделе Управление сборкой мусора ниже.
Буферы¶
Примером вышесказанного является распространённый случай, когда требуется буфер, например, используемый для связи с устройством. Типичный драйвер создаёт буфер в конструкторе и использует его в методах ввода/вывода, которые вызываются многократно.
Библиотеки MicroPython обычно обеспечивают поддержку предварительно выделенных буферов. Например, объекты, поддерживающие потоковый интерфейс (например, файл или UART), предоставляют метод read(), который выделяет новый буфер для прочитанных данных, но также и метод readinto() для чтения данных в существующий буфер.
Несколько полезных классов для создания повторно используемых объектов-буферов:
Числа с плавающей точкой¶
Некоторые порты MicroPython выделяют числа с плавающей точкой в куче. В некоторых других портах может отсутствовать выделенный сопроцессор для операций с плавающей точкой, и арифметические операции над ними выполняются «программно» со значительно меньшей скоростью, чем над целыми числами. Там, где производительность важна, используйте целочисленные операции и ограничьте использование чисел с плавающей точкой теми участками кода, где производительность не имеет первостепенного значения. Например, захватите показания ADC в виде целочисленных значений в массив за один быстрый проход и только затем преобразуйте их в числа с плавающей точкой для обработки сигнала.
Массивы¶
Рассмотрите использование различных типов классов массивов как альтернативу спискам. Модуль array поддерживает различные типы элементов, причём 8-битные элементы поддерживаются встроенными в Python классами bytes и bytearray. Все эти структуры данных хранят элементы в смежных областях памяти. И снова, чтобы избежать выделения памяти в критическом коде, их следует выделять заранее и передавать в качестве аргументов или связанных объектов.
Объекты memoryview¶
При передаче срезов объектов, таких как экземпляры bytearray, Python создаёт копию, что предполагает выделение памяти, пропорциональное размеру среза. Этого можно избежать с помощью объекта memoryview. Сам объект memoryview выделяется в куче, но представляет собой небольшой объект фиксированного размера, независимо от размера среза, на который он указывает. Срез memoryview создаёт новый memoryview, поэтому это нельзя делать в обработчике прерывания. Кроме того, синтаксис среза a:b вызывает дополнительное выделение памяти за счёт создания объекта slice(a, b).
ba = bytearray(10000) # big array
func(ba[30:2000]) # a copy is passed, ~2K new allocation
mv = memoryview(ba) # small object is allocated
func(mv[30:2000]) # a pointer to memory is passed
memoryview может быть применён только к объектам, поддерживающим протокол буфера — это включает массивы, но не списки. Небольшой нюанс заключается в том, что пока объект memoryview существует, он также сохраняет в живом состоянии исходный объект-буфер. Таким образом, memoryview не является универсальной панацеей. Например, в приведённом выше примере, если вы закончили работу с буфером в 10K и вам нужны только байты 30:2000 из него, может быть лучше сделать срез и позволить буферу в 10K освободиться (стать доступным для сборки мусора), вместо того чтобы создавать долгоживущий memoryview и держать 10K заблокированными для GC.
Тем не менее memoryview незаменим для продвинутого управления предварительно выделенными буферами. Рассмотренный выше метод readinto() помещает данные в начало буфера и заполняет весь буфер. Что делать, если нужно поместить данные в середину существующего буфера? Просто создайте memoryview на нужный участок буфера и передайте его в readinto().
Строки против байтов¶
MicroPython использует интернирование строк для экономии места при наличии нескольких одинаковых строк. Каждый раз, когда новая строка выделяется во время выполнения (например, при конкатенации двух других строк), MicroPython проверяет, можно ли интернировать новую строку для экономии RAM.
Если у вас есть код, выполняющий критичные к производительности операции со строками, рассмотрите использование объектов bytes и литералов (то есть b"abc"). Это пропускает проверку интернирования и может быть в несколько раз быстрее, чем выполнение тех же операций с объектами-строками.
Примечание
Наивысшая производительность всегда достигается за счёт полного отказа от создания новых объектов, например, с помощью повторно используемого буфера, как описано выше.
Определение самого медленного участка кода¶
Это процесс, известный как профилирование; он описывается в учебниках и (для стандартного Python) поддерживается различными программными инструментами. Для небольших встраиваемых приложений, которые обычно работают на платформах MicroPython, самую медленную функцию или метод обычно можно установить путём грамотного использования группы функций измерения времени ticks, документированных в time. Время выполнения кода можно измерять в мс, мкс или тактах CPU.
Следующее позволяет измерять время выполнения любой функции или метода путём добавления декоратора @timed_function:
def timed_function(f, *args, **kwargs):
myname = str(f).split(' ')[1]
def new_func(*args, **kwargs):
t = time.ticks_us()
result = f(*args, **kwargs)
delta = time.ticks_diff(time.ticks_us(), t)
print('Function {} Time = {:6.3f}ms'.format(myname, delta/1000))
return result
return new_func
Улучшения кода на MicroPython¶
Объявление const()¶
MicroPython предоставляет объявление const(). Оно работает аналогично #define в C в том смысле, что при компиляции кода в байт-код компилятор подставляет числовое значение вместо идентификатора. Это позволяет избежать поиска в словаре во время выполнения. Аргументом для const() может быть всё, что во время компиляции вычисляется в целое число, например 0x100 или 1 << 8.
Кэширование ссылок на объекты¶
Там, где функция или метод многократно обращается к объектам, производительность повышается за счёт кэширования объекта в локальной переменной:
class foo(object):
def __init__(self):
self.ba = bytearray(100)
def bar(self, obj_display):
ba_ref = self.ba
fb = obj_display.framebuffer
# iterative code using these two objects
Это избавляет от необходимости многократно искать self.ba и obj_display.framebuffer в теле метода bar().
Управление сборкой мусора¶
Когда требуется выделение памяти, MicroPython пытается найти в куче блок подходящего размера. Это может не удаться, обычно потому, что куча захламлена объектами, на которые код больше не ссылается. В случае неудачи процесс, известный как сборка мусора, освобождает память, используемую этими ненужными объектами, после чего выделение памяти повторяется снова — процесс, который может занимать несколько миллисекунд.
Может быть полезно опережать это, периодически вызывая gc.collect(). Во-первых, выполнение сборки до того, как она фактически потребуется, происходит быстрее — обычно порядка 1 мс при частом выполнении. Во-вторых, вы можете определить точку в коде, где расходуется это время, вместо того чтобы более длительная задержка возникала в случайные моменты, возможно, в критичном к скорости участке. Наконец, регулярное выполнение сборки может уменьшить фрагментацию кучи. Серьёзная фрагментация может привести к неустранимым сбоям выделения памяти.
Генератор нативного кода (Native code emitter)¶
Это заставляет компилятор MicroPython генерировать нативные опкоды CPU вместо байт-кода. Он охватывает основную часть функциональности MicroPython, поэтому большинство функций не потребует адаптации (но см. ниже). Он вызывается с помощью декоратора функции:
@micropython.native
def foo(self, arg):
buf = self.linebuf # Cached object
# code
В текущей реализации генератора нативного кода есть определённые ограничения.
Если используется
raise, должен быть передан аргумент.Фоновый планировщик (см.
micropython.schedule) не запускается во время выполнения нативного кода.На целевых платформах с потоками и GIL, GIL не освобождается во время выполнения нативного кода.
Чтобы смягчить последние два момента, длительно работающие нативные функции должны периодически вызывать time.sleep(0), что запустит планировщик и освободит GIL.
Платой за повышенную производительность (примерно вдвое быстрее байт-кода) является увеличение размера скомпилированного кода.
Генератор кода Viper (Viper code emitter)¶
Рассмотренные выше оптимизации затрагивают код на Python, соответствующий стандартам. Генератор кода Viper не полностью соответствует стандартам. Он поддерживает специальные нативные типы данных Viper в погоне за производительностью. Обработка целых чисел не соответствует стандартам, поскольку используются машинные слова: арифметика на 32-битном оборудовании выполняется по модулю 2**32.
Как и генератор нативного кода, Viper производит машинные инструкции, но выполняются дополнительные оптимизации, существенно повышающие производительность, особенно для целочисленной арифметики и манипуляций с битами. Он вызывается с помощью декоратора:
@micropython.viper
def foo(self, arg: int) -> int:
# code
Как показывает приведённый выше фрагмент, полезно использовать подсказки типов Python для помощи оптимизатору Viper. Подсказки типов предоставляют информацию о типах данных аргументов и возвращаемого значения; это стандартная возможность языка Python, формально определённая здесь PEP0484. Viper поддерживает собственный набор типов, а именно int, uint (беззнаковое целое), ptr, ptr8, ptr16 и ptr32. Типы ptrX рассматриваются ниже. В настоящее время тип uint служит единственной цели: как подсказка типа для возвращаемого значения функции. Если такая функция возвращает 0xffffffff, Python интерпретирует результат как 2**32 -1, а не как -1.
Помимо ограничений, налагаемых нативным генератором, применяются следующие ограничения:
Значения аргументов по умолчанию не разрешены.
Числа с плавающей точкой могут использоваться, но не оптимизируются.
Viper предоставляет типы указателей для помощи оптимизатору. К ним относятся
ptrУказатель на объект.ptr8Указывает на байт.ptr16Указывает на 16-битное полуслово.ptr32Указывает на 32-битное машинное слово.
Концепция указателя может быть незнакома программистам на Python. Она имеет сходство с объектом memoryview в Python в том, что предоставляет прямой доступ к данным, хранящимся в памяти. Доступ к элементам осуществляется с помощью индексной нотации, но срезы не поддерживаются: указатель может вернуть только один элемент. Его назначение — обеспечить быстрый произвольный доступ к данным, хранящимся в смежных областях памяти, таким как данные, хранящиеся в объектах, поддерживающих протокол буфера, и отображаемые в память регистры периферийных устройств в микроконтроллере. Следует отметить, что программирование с использованием указателей опасно: проверка границ не выполняется, и компилятор ничего не делает для предотвращения ошибок переполнения буфера.
Типичное использование — кэширование переменных:
@micropython.viper
def foo(self, arg: int) -> int:
buf = ptr8(self.linebuf) # self.linebuf is a bytearray or bytes object
for x in range(20, 30):
bar = buf[x] # Access a data item through the pointer
# code omitted
В этом случае компилятор «знает», что buf — это адрес массива байтов; он может сгенерировать код для быстрого вычисления адреса buf[x] во время выполнения. Там, где приведения типов используются для преобразования объектов в нативные типы Viper, их следует выполнять в начале функции, а не в критичных по времени циклах, поскольку операция приведения может занимать несколько микросекунд. Правила приведения следующие:
Операторы приведения в настоящее время:
int,bool,uint,ptr,ptr8,ptr16иptr32.Результатом приведения будет нативная переменная Viper.
Аргументами приведения могут быть объект Python или нативная переменная Viper.
Если аргумент является нативной переменной Viper, то приведение является холостой операцией (то есть ничего не стоит во время выполнения), которая просто изменяет тип (например, с
uintнаptr8), чтобы затем можно было сохранять/загружать с помощью этого указателя.Если аргумент является объектом Python и приведение — это
intилиuint, то объект Python должен быть целочисленного типа, и возвращается значение этого целочисленного объекта.Аргумент для приведения bool должен быть целочисленного типа (булевого или целого); при использовании в качестве типа возвращаемого значения функция viper возвращает объекты True или False.
Если аргумент является объектом Python и приведение — это
ptr,ptr8,ptr16илиptr32, то объект Python должен либо поддерживать протокол буфера (в этом случае возвращается указатель на начало буфера), либо быть целочисленного типа (в этом случае возвращается значение этого целочисленного объекта).
Запись в указатель, указывающий на объект только для чтения, приведёт к неопределённому поведению.
Примечание
Приведённые ниже примеры кода даны для камер OpenMV Cam на базе STM32, которые предоставляют модуль stm. Описанные методы применимы в целом.
Модуль stm раскрывает адреса памяти регистров периферийных устройств MCU. Каждый порт GPIO имеет регистр выходных данных (ODR), биты которого взаимно однозначно отображаются на выводы этого порта: запись в регистр напрямую управляет этими выводами, без накладных расходов на вызов метода machine.Pin, а операция XOR над битом переключает его вывод. На оригинальной OpenMV Cam синий светодиод подключён к выводу 2 GPIOC, поэтому в следующем примере используется приведение ptr16 для переключения синего светодиода n раз:
BIT2 = const(1 << 2)
@micropython.viper
def toggle_n(n: int):
odr = ptr16(stm.GPIOC + stm.GPIO_ODR)
for _ in range(n):
odr[0] ^= BIT2
Подробное техническое описание трёх генераторов кода можно найти на Kickstarter здесь Примечание 1 и здесь Примечание 2
Прямой доступ к оборудованию¶
Это относится к категории более продвинутого программирования и предполагает некоторое знание целевого MCU. Рассмотрим пример переключения выходного вывода на OpenMV Cam. Стандартный подход — написать
mypin.value(mypin.value() ^ 1) # mypin was instantiated as an output pin
Это влечёт за собой накладные расходы на два вызова метода value() экземпляра Pin. Эти накладные расходы можно устранить, выполнив чтение/запись в соответствующий бит регистра выходных данных порта GPIO микросхемы (ODR). Чтобы облегчить это, модуль stm предоставляет набор констант, дающих адреса соответствующих регистров (stm.GPIOC — это базовый адрес порта GPIOC, stm.GPIO_ODR — смещение его регистра выходных данных). Как и выше, синий светодиод на оригинальной OpenMV Cam — это вывод 2 GPIOC, поэтому его быстрое переключение можно выполнить следующим образом:
import machine
import stm
BIT2 = const(1 << 2)
machine.mem16[stm.GPIOC + stm.GPIO_ODR] ^= BIT2