Максимізація швидкості MicroPython

У цьому посібнику описано способи підвищення продуктивності коду MicroPython. Оптимізації за допомогою інших мов розглянуто в інших розділах — зокрема використання модулів, написаних на C, та вбудованого асемблера MicroPython.

Процес розробки високопродуктивного коду складається з таких етапів, які слід виконувати у вказаному порядку.

  • Проектування з урахуванням швидкодії.

  • Написання коду та налагодження.

Кроки оптимізації:

  • Визначення найповільнішої ділянки коду.

  • Підвищення ефективності Python-коду.

  • Використання емітера нативного коду.

  • Використання емітера коду Viper.

  • Використання апаратно-специфічних оптимізацій.

Проектування з урахуванням швидкодії

Питання продуктивності слід враховувати ще на початку розробки. Це передбачає визначення найбільш критичних до швидкодії ділянок коду та приділення особливої уваги їхньому проектуванню. Оптимізація починається після тестування коду: якщо початкове проектування є правильним, оптимізація буде простою і може виявитися зайвою.

Алгоритми

Найважливішим аспектом проектування будь-якої підпрограми для досягнення швидкодії є вибір найкращого алгоритму. Це тема для підручників, а не для посібника з 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 не є універсальним рішенням. Наприклад, у наведеному вище прикладі, якщо ви закінчили роботу з буфером розміром 10 КБ і вам потрібні лише байти 30:2000 з нього, краще зробити зріз і звільнити буфер 10 КБ (підготувати до збирання сміття), ніж створювати довгоживучий memoryview і утримувати 10 КБ заблокованими для GC.

Тим не менш, memoryview є незамінним для розширеного керування попередньо виділеними буферами. Метод readinto(), розглянутий вище, поміщає дані на початок буфера і заповнює весь буфер. Що робити, якщо вам потрібно помістити дані в середину наявного буфера? Просто створіть memoryview на потрібну ділянку буфера і передайте його в readinto().

Рядки проти байтів

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

Якщо у вас є код, що виконує критичні до продуктивності операції з рядками, розгляньте використання об’єктів bytes та літералів (тобто b"abc"). Це пропускає перевірку інтернінгу і може бути в кілька разів швидшим, ніж виконання тих самих операцій з рядковими об’єктами.

Примітка

Найвищу продуктивність завжди можна досягти, повністю уникаючи створення нових об’єктів, наприклад за допомогою буфера багаторазового використання, описаного вище.

Визначення найповільнішої ділянки коду

Цей процес відомий як профілювання і висвітлюється в підручниках, а також (для стандартного Python) підтримується різними програмними інструментами. Для невеликих вбудованих програм, що зазвичай виконуються на платформах MicroPython, найповільнішу функцію або метод, як правило, можна визначити за допомогою виваженого використання групи функцій ticks, задокументованих у time. Час виконання коду можна вимірювати в мс, мкс або тактах процесора.

Наступне дозволяє вимірювати час будь-якої функції або методу шляхом додавання декоратора @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 намагається знайти блок відповідного розміру в купі. Це може не вдатися, зазвичай через те, що купа захаращена об’єктами, на які більше немає посилань у коді. Якщо це відбувається, процес збирання сміття звільняє пам’ять, зайняту цими надлишковими об’єктами, і виділення пам’яті повторюється — процес, який може зайняти кілька мілісекунд.

Можливо, є сенс випереджати це, periodically видаючи gc.collect(). По-перше, виконання збирання до того, як воно реально потрібне, є швидшим — як правило, близько 1 мс при частому виконанні. По-друге, ви можете визначити точку в коді, де витрачається цей час, а не мати довшу затримку в довільних місцях, можливо в критичній до швидкодії ділянці. Нарешті, регулярне виконання збирання може зменшити фрагментацію купи. Сильна фрагментація може призвести до невідновлюваних збоїв виділення пам’яті.

Емітер нативного коду

Він змушує компілятор MicroPython випромінювати нативні опкоди CPU замість байткоду. Він охоплює більшу частину функціональності MicroPython, тому більшість функцій не потребуватимуть адаптації (але дивіться нижче). Він викликається за допомогою декоратора функції:

@micropython.native
def foo(self, arg):
    buf = self.linebuf # Cached object
    # code

У поточній реалізації емітера нативного коду є певні обмеження.

  • Якщо використовується raise, необхідно вказати аргумент.

  • Фоновий планувальник (дивіться micropython.schedule) не запускається під час виконання нативного коду.

  • На цільових платформах з потоками та GIL, GIL не звільняється під час виконання нативного коду.

Щоб пом’якшити останні два пункти, довго виконувані нативні функції повинні periodically викликати time.sleep(0), що запустить планувальник і звільнить GIL.

Компроміс за покращену продуктивність (приблизно вдвічі швидше, ніж байткод) — це збільшення розміру скомпільованого коду.

Емітер коду Viper

Оптимізації, розглянуті вище, стосуються Python-коду, сумісного зі стандартами. Емітер коду Viper не є повністю сумісним. Він підтримує спеціальні нативні типи даних Viper для досягнення продуктивності. Обробка цілих чисел є несумісною, оскільки використовує машинні слова: арифметика на 32-бітному апаратному забезпеченні виконується за модулем 2**32.

Як і емітер Native, 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. Вона має схожість з об’єктом Python memoryview в тому сенсі, що надає прямий доступ до даних, збережених у пам’яті. Доступ до елементів здійснюється за допомогою індексного запису, але зрізи не підтримуються: вказівник може повертати лише один елемент. Його призначення — забезпечити швидкий довільний доступ до даних, збережених у суміжних комірках пам’яті — таких як дані в об’єктах, що підтримують буферний протокол, та регістри периферійних пристроїв мікроконтролера, відображені в пам’ять. Слід зазначити, що програмування з використанням вказівників є небезпечним: перевірка меж не виконується, і компілятор не робить нічого для запобігання помилкам переповнення буфера.

Типове використання — кешування змінних:

@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 надає доступ до адрес пам’яті регістрів периферійних пристроїв мікроконтролера. Кожний порт 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 тут Note 1 та тут Note 2

Прямий доступ до апаратного забезпечення

Це відноситься до категорії більш просунутого програмування і потребує певних знань цільового мікроконтролера. Розглянемо приклад перемикання вихідного виводу на 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