MicroPython:Основы/Язык MicroPython и его реализация/Максимизация скорости работы MicroPython

Материал из Онлайн справочника
Перейти к навигацииПерейти к поиску

Перевод: Максим Кузьмин
Проверка/Оформление/Редактирование: Мякишев Е.А.


Максимизация скорости работы MicroPython[1]

Эта статья – о том, как улучшить производительность кода MicroPython. Советы по оптимизации других языков (в частности, модулей, написанных на C, и ассемблерных вставок MicroPython) в эту статью не входят. Разработка высокопроизводительного кода включает следующие этапы (их нужно выполнять в указанном ниже порядке):

  • Проектирование кода (с прицелом на скорость)
  • Создание кода и отладка

Оптимизационные этапы:

  • Определение самых медленных разделов кода
  • Повышение эффективности Python-кода
  • Использование эмиттера нативного кода
  • Использование эмиттера Viper-кода
  • Использование аппаратной оптимизации

Проектирование кода (с прицелом на скорость)

О производительности нужно думать с самого начала. В частности, вам нужно определить разделы кода, которые наиболее критичны с точки зрения производительности, и уделить их проектированию особое внимание. Оптимизация выполняется после тестирования кода, и если на стадии проектирования все было сделано правильно, оптимизация будет простой или даже вовсе окажется не нужна.

Алгоритмы

Самый важный аспект создания любой оптимизационной схемы – убедиться, что у вас используется наилучший алгоритм. Это тема, достойная учебников, а не руководства по MicroPython, но иногда потрясающей производительности можно достичь, если воспользоваться алгоритмом, который уже известен в программистских кругах своей эффективностью.

Выделение RAM-памяти

Если вы хотите создать эффективный MicroPython-код, нужно понимать, как интерпретатор осуществляет выделение памяти. При создании объекта или увеличении его размера (например, при добавлении элемента в список) происходит выделение участка RAM-памяти в блоке под названием «куча». На это требуется много времени, и периодически в куче запускается процесс сборки мусора, на который требуется несколько миллисекунд.

Следовательно, производительность функции/метода можно улучшить, если объект будет создан лишь раз и ему не будет разрешено увеличиваться в размере. Это также подразумевает, что во время использования объекта он должен продолжать существовать: как правило, его инстанцинируют при помощи конструктора класса и используют в различных методах.

Более подробно об этом рассказывается в Разделе «Управление сбором мусора» ниже.

Буферы

Буферы часто используются для коммуникации с устройствами. Как правило, драйвер создает буфер в конструкторе и периодически использует его в своих методах чтения/записи.

В библиотеках MicroPython, как правило, есть поддержка буферов с предварительно выделенной памятью. К примеру, у объектов с потоковым интерфейсом (вроде тех, что используются для работы с файлами или UART) есть метод read(), выделяющий память под новый буфер для считанных данных, а также метод readinto() для считывания данных в уже существующий буфер.

Числа с плавающей точкой

Некоторые MicroPython-порты выделяют память для чисел с плавающей точкой в куче. У некоторых устройств может не быть сопроцессора для чисел с плавающей точкой, поэтому в них арифметические операции с ними будут выполняться «программно» и на гораздо более низкой скорости, чем с целыми числами. В местах, где важна производительность, используйте операции с целыми числами, а операции с числами с плавающей точкой используйте только в тех местах, где производительность не первостепенна. Примером может послужить ситуация, когда вы быстро считываете АЦП-данные в массив целых чисел и только затем конвертируете их в числа с плавающей точкой, чтобы обработать полученные сигналы.

Массивы

Вместо списков советуем использовать разные типы массивов. Модуль array поддерживает разные типы 8-битных данных, которые также поддерживаются встроенными Python-классами bytes и bytearray. Во всех этих структурах элементы хранятся в смежных участках памяти. Опять же, чтобы избежать выделения памяти в критических разделах кода, память для этих типов данных нужно выделить заблаговременно и передавать их либо в виде аргументов, либо в виде связанных объектов.

Передавая срез объекта вроде экземпляра bytearray, Python создает копию, что сопровождается выделением памяти, чей размер пропорционален размеру среза. Вы можете упростить эту операцию при помощи объекта memoryview. Память для memoryview выделяется в куче, но это маленький объект фиксированного размера, чей размер не зависит от размера среза, на который он указывает.

ba = bytearray(10000)  # большой массив
func(ba[30:2000])      # передаем копию, выделяем новые 2 Kб 
mv = memoryview(ba)    # выделение памяти под маленький объект
func(mv[30:2000])      # передаем указатель на память

Объект memoryview можно применить только к объектам, поддерживающим буферный протокол – к ним относятся массивы, но не списки. Но есть маленький нюанс: пока существует объект memoryview, существует и его исходный буферный объект. Поэтому memoryview – это не панацея. Взять, к примеру, код выше: если вы закончили работать с буфером на 10 Кб, и вам нужен из него лишь срез «30:2000», то, возможно, лучше будет просто отрезать этот кусочек и попрощаться с 10-килобайтным буфером (будьте готовы запустить сборщик мусора) вместо того, чтобы создавать долгоживущий memoryview и не давать сборщику мусора очистить эти 10 Кб.

В общем, если вы хотите научиться делать предварительное выделение памяти под буферы более эффективно, то без memoryview не обойтись. Метод readinto(), о котором говорилось выше, помещает данные в начало буфера, а потом заполняет весь оставшийся буфер. Но что если вам надо поместить данные в середину существующего буфера? Просто создайте memoryview в нужном разделе буфера и передайте его в readinto().

Определение самых медленных участков кода

Этот процесс называется «профайлингом», и о нем лучше почитать в учебниках. Что касается стандартного Python, то профайлинг для него поддерживается во многих инструментах для разработки ПО. Если речь о маленьких встраиваемых приложениях, которые обычно запускаются на MicroPython, то в них самые медленные функции/методы, как правило, можно определить при помощи разумного использования функций группы ticks модуля utime. Время выполнения кода может быть измерено в миллисекундах, микросекундах или машинных циклах.

Фрагмент кода ниже позволяет измерить время выполнения любой функции/метода при помощи декоратора @timed_function:

def timed_function(f, *args, **kwargs):
    myname = str(f).split(' ')[1]
    def new_func(*args, **kwargs):
        t = utime.ticks_us()
        result = f(*args, **kwargs)
        delta = utime.ticks_diff(utime.ticks_us(), t)
        print('Функция {} Время = {:6.3f}ms'.format(myname, delta/1000))
        return result
    return new_func

Улучшение эффективности Python-кода

Объявление const()

В MicroPython есть функция const(), которая похожа на #define из Си в том, что когда код компилируется в байт-код, компилятор заменяет заданный идентификатор числовым значением. Это позволяет избежать поиска в словарях во время выполнения кода. Аргументом в 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
        # повторяющийся код, использующий два этих объекта

Это позволяет избежать необходимости постоянно искать self.ba и obj_display.framebuffer в теле метода bar().

Управление сбором мусора

Когда требуется выделение памяти, MicroPython пытается найти подходящий по размеру участок в куче. Это может завершиться неудачей, и обычно это происходит из-за того, что куча уже завалена объектами, к которым код больше не обращается. В этом случае запускается процесс сборки мусора, который очищает память, используемую этими бесполезными объектами, после чего происходит новая попытка выделения памяти, что может занять несколько миллисекунд.

Эту проблему можно упредить, периодически запуская gc.collect(). Во-первых, выполнить сбор мусора до того, как в нем возникнет острая нужда – это быстро. Как правило, если делать это часто, это занимает примерно одну миллисекунду. Во-вторых, вы можете сами выделить в своей программе место, где будет расходоваться эта миллисекунда – вместо того, чтобы иметь дело с более долгими задержками в случайных участках кода (возможно, даже в критических участках). Наконец, регулярная сборка мусора может снизить фрагментацию кучи. Сильная фрагментация может привести к неисправимым сбоям выделения памяти.

Эмиттер нативного кода

Этот эмиттер заставляет компилятор MicroPython генерировать не байт-код, а нативные коды операций для процессора. Это охватывает немалую часть MicroPython-функционала, благодаря чему большинству функций адаптация будет не нужна (но см. ниже). Использование этого эмиттера осуществляется при помощи декоратора функций @micropython.native:

@micropython.native
def foo(self, arg):
    buf = self.linebuf # кэшированный объект
    # код

В текущей реализации эмиттера нативного кода есть некоторые ограничения:

  • Менеджеры контекста не поддерживаются (оператор with)
  • Генераторы не поддерживаются
  • При использовании raise необходимо также указать аргумент

Впрочем, за улучшение производительности при использовании эмиттера нативного кода (скорость вырастает примерно в два раза по сравнению с байт-кодом) приходится расплачиваться увеличением размера кода в скомпилированном состоянии.

Эмиттер Viper-кода

Оптимизационные методы, о которых говорилось выше, касаются кода, совместимого со стандартами Python. Viper-код им соответствует не полностью. В целях улучшения производительности в нем используются специальные нативные Viper-типы данных. К примеру, не совместима обработка целых чисел, так как в ней используются машинные слова: в 32-битных устройствах арифметические вычисления выполняются по модулю 232.

Как и эмиттер нативного кода, эмиттер Viper-кода генерирует машинные инструкции, но выполняет и дополнительную оптимизацию, значительно повышая производительность – особенно в арифметических вычислениях с целыми числами и операциях с битами. Viper-код генерируется с помощью декоратора @micropython.viper:

@micropython.viper
def foo(self, arg: int) -> int:
    # код

Как показывает фрагмент кода выше, при использовании Viper-оптимизатора полезно использовать Python’овские аннотации типов. Они информируют о том, к какому типу данных принадлежат аргументы и возвращаемые значения. Это стандартная функция языка Python, официальное описание которой можно найти тут. В Viper-коде есть собственные типы данных, а именно – int, uint (беззнаковое целое число), ptr, ptr8, ptr16 и ptr32. Последние четыре типа мы обсудим чуть ниже. Сейчас тип uint служит всего одной цели – в качестве аннотации для значения, возвращаемого функцией. Если эта функция вернет «0xffffffff», Python интерпретирует результат как «232 -1», а не «-1».

Вдобавок к ограничениям эмиттера нативного кода на Viper-код накладываются следующие ограничения:

  • В функциях может быть не более 4 аргументов
  • Аргументам нельзя задавать значения по умолчанию
  • Числа с плавающей точкой можно использовать, но эта функция не оптимизирована

В целях оптимизации Viper-коде также поддерживаются ссылочные типы данных (типы-указатели):

  • ptr – указатель на объект
  • ptr8 – указатель на байт
  • ptr16 – указатель на 16-битное полуслово
  • ptr32 – указатель на 32-битное машинное слово

Python-программистам идея указателя может быть незнакома. Он похож на Python-объект memoryview тем, что предоставляет прямой доступ к данным, хранящимся в памяти. Доступ к объектам осуществляется при помощи индексной записи, но срезы не поддерживаются; указатель может вернуть только один объект. Цель указателя в том, чтобы предоставить быстрый произвольный доступ к данным, хранящимся в смежных участках памяти. Речь, например, о данных, хранящихся в объектах, которые поддерживают буферный протокол, и периферийных регистрах микроконтроллера с отображением в памяти. Тут стоит отметить, что программирование с использованием указателей опасно: граничная проверка не выполняется, а компилятор ничего даже не пытается предотвратить ошибки переполнения буфера.

Типичное использование указателей – это кэширование переменных:

@micropython.viper
def foo(self, arg: int) -> int:
    buf = ptr8(self.linebuf) # «self.linebuf» - это 
                             # объект типа «bytearray» или «bytes»
    for x in range(20, 30):
        bar = buf[x] # получаем доступ 
                     # к объекту данных через указатель 
        # код опущен

В примере выше компилятор «знает», что buf – это адрес массива байтов, и может сгенерировать код для быстрого вычисления адреса buf[x] прямо во время выполнения. При использовании приведения типов для конвертации объектов в нативные Viper-типы данных эту операцию нужно выполнять в начале функции, а не в критических циклах, т.к. операция приведения типов может занять несколько микросекунд.

Соблюдайте следующие правила приведения типов:

  • Операторы приведения типов, имеющиеся в данный момент: int, bool, uint, ptr, ptr8, ptr16 и ptr32.
  • Результатом приведения типов будет нативная Viper-переменная.
  • Аргументом в приведении типов может быть Python-объект или нативная Viper-переменная.
  • Если аргумент – это нативная Viper-переменная, то приведение типов будет холостой командой (т.е. в фазе выполнения она не будет «стоить» ничего), просто меняющей тип данных (например, с uint на ptr8), чтобы вы потом могли сохранять/считывать эти данные при помощи этого указателя.
  • Если аргумент – это Python-объект, и вы осуществляете приведение типов из int в uint, то этот Python-объект должен быть целочисленным типом данных, а возвращаемым значением будет значение этого целочисленного объекта.
  • При приведении в булево значение в аргументе должен быть задан целочисленный тип данных (булево значение или целое число); если использовать для возврата данных Viper-функцию, она вернет объект True или False.
  • Если аргумент – это Python-объект, и вы осуществляете приведение в ptr, ptr8, ptr16 или ptr32, то либо у этого Python-объекта должен быть буферный протокол (и в этом случае возвращаемым объектом будет указатель на начало буфера), либо это должен быть целочисленный тип данных (и в этом случае будет возвращено значение этого целочисленного объекта).

Запись в указатель, указывающий на объект только для чтения, приведет к непредсказуемым последствиям.

Пример ниже иллюстрируется приведение с помощью ptr16 для переключения контакта X1 n раз:

BIT0 = const(1)
@micropython.viper
def toggle_n(n: int):
    odr = ptr16(stm.GPIOA + stm.GPIO_ODR)
    for _ in range(n):
        odr[0] ^= BIT0

Подробное техническое описание этих трех эмиттеров кода можно найти на Kickstarter в примечании 1 и примечании 2.

Прямой доступ к устройству

Примечание: Примеры кода в этом разделе даны для Pyboard, но описанные здесь техники применимы и к другим MicroPython-портам.

Описываемые тут вещи подпадают под категорию более продвинутого программирования и подразумевают, что вы имеете более-менее хорошее представление о своем микроконтроллере. Во фрагменте кода ниже показано переключение выходного контакта на Pyboard. Стандартным подходом было бы написать:

mypin.value(mypin.value() ^ 1) # инстанцинируем «mypin» 
                               # как выходной контакт

Здесь мы расточительно два раза вызываем метод value() экземпляра Pin. Но этот код можно оптимизировать, если выполнить чтение/запись в релевантный бит ODR-регистра (от англ. «output data register», т.е. «регистр выходных данных») GPIO-порта чипа. Также можно воспользоваться модулем stm, в котором есть набор констант с адресами релевантных регистров. Быстрое переключение контакта P4 (контакта A14 на процессоре), к которому подключен зеленый светодиод, можно выполнить следующим образом:

import machine
import stm

BIT14 = const(1 << 14)
machine.mem16[stm.GPIOA + stm.GPIO_ODR] ^= BIT14

См.также

Внешние ссылки