ESP32-C3: оптимизация скорости работы Печать
Добавил(а) microsin   

Оптимизация скорости выполнения кода - ключевой элемент эффективности программного обеспечения. Код, который выполняется быстрее, дает другие положительные эффекты, наподобие снижение общего энергопотребления. Однако повышение скорости работы может быть сделано ценой других аспектов производительности, таких как минимизация объема кода [2] и расход оперативной памяти [3].

[Выбор: что нужно оптимизировать]

Если функция в firmware приложения выполняется 1 раз в неделю, то скорее всего не имеет особого значения, сколько времени занимает её выполнение, 10 или 100 мс. Если же функция запускается постоянно с частотой 10 Гц, то становится очень важным, сколько времени она работает.

Большинство кода firmware имеет очень небольшой набор функций, которые требуют оптимальной производительности. Возможно, эти функции выполняются очень частот, или должны удовлетворить некоторым особым требованиям приложения для минимизации латентности или повышение полосы пропускания. Действия по оптимизации должны быть нацелены именно на такие определенные функции.

[Измерение производительности]

Чтобы что-то улучшить в плане повышения скорости работы, необходимо какое-то средство, чтобы оценить текущую ситуацию.

Если измерение производительности связано с взаимодействием с внешним миром, то вы можете измерить скорость работы напрямую. Например, можно применить код wifi/iperf и ethernet/iperf для измерения общей производительности сети, или можно использовать осциллограф или логический анализатор, чтобы измерить интервалы времени циклов работы периферийного устройства.

В противном случае одним из способов измерения производительности является увеличение кода для выполнения измерений синхронизации:

#include "esp_timer.h"
 
void measure_important_function(void)
{
   const unsigned MEASUREMENTS = 5000;
   uint64_t start = esp_timer_get_time();
 
   for (int retries = 0; retries < MEASUREMENTS; retries++)
   {
      important_function(); // Эта функция, скорость выполнения которой мы определяем
   }
 
   uint64_t end = esp_timer_get_time();
 
   printf("%u iterations took %llu milliseconds (%llu microseconds per invocation)\n",
          MEASUREMENTS, (end - start)/1000, (end - start)/MEASUREMENTS);
}

Если для оценки времени выполнения не требуется очень большая точность, то для этого можно использовать вывод сообщений в лог.

Макросы для вывода в лог, такие как ESP_LOGI [4], выводят в круглых скобках, в начале строки лога, значение абсолютного счетчика тиков RTOS (обычно длительность тика составляет 1 мс). Таким образом, можно напрямую оценить время выполнения участков кода с точностью до одного тика:

I (579) cpu_start: Pro cpu start user code
I (579) cpu_start: cpu freq: 160000000
I (579) cpu_start: Application information:
I (579) cpu_start: Project name:     spotter
I (579) cpu_start: App version:      71ba7e4-dirty
I (580) cpu_start: Compile time:     Mar 22 2023 07:04:20
I (581) cpu_start: ELF file SHA256:  11272b759af54713...
I (581) cpu_start: ESP-IDF:          v4.4.1-dirty
I (582) heap_init: Initializing. RAM available for dynamic allocation:
I (583) heap_init: At 3FCACF50 len 000130B0 (76 KiB): DRAM
I (584) heap_init: At 3FCC0000 len 0001F060 (124 KiB): STACK/DRAM
I (586) spi_flash: detected chip: gd
I (587) spi_flash: flash io: dio

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

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

Также можно использовать стандартные Unix-функции gettimeofday() и utime(), однако накладные расходы на их выполнение несколько больше.

Иначе подключите hal/cpu_hal.h, и вызывайте HAL-функцию cpu_hal_get_cycle_count(), которая вернет текущее количество циклов CPU. У этой функции меньше накладные расходы по сравнению с другими функциями. Это хорошее средство для измерения коротких интервалов времени с высокой точностью.

При измерении очень малых интервалов времени (которые много меньше 1 мс) производительность работы кэша flash иногда может дать большой разброс в результатах измерения времени, в зависимости от конкретного кода. Это происходит потому, что различные последовательности выполнения кода могут вызывать различные ситуации попадания или промахов кэша. Если тестируемый код большой, то эффект от накладных расходов доступа к flash обычно усредняется. Выполнение маленькой функции много раз может помочь снизить влияние промахов кэша. Чтобы полностью устранить накладные расходы на обращение к flash, поместите функцию в IRAM (см. далее "Целевые оптимизации").

Внешняя трассировка. Использование библиотеки трассировки и отладчика [5] позволяет проводить самые точные измерения с минимальным влиянием на тестируемый код.

Производительность задач. Если разрешена опция CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS, то можно использовать API-функцию vTaskGetRunTimeStats(), чтобы получить runtime-информацию об относительном потреблении процессорного времени каждой задачей FreeRTOS.

Утилита SEGGER SystemView является отличным инструментом для визуализации выполнения задачи и выявления проблем производительности для всей системы целиком.

[Общее повышение скорости работы]

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

· Установите опцию CONFIG_ESPTOOLPY_FLASHMODE в режим QIO или QOUT (Quad I/O). Оба этих режима почти в 2 раза повышают скорость, с который код загружается или выполняется из flash, в сравнении с режимом по умолчанию DIO. QIO немного быстрее QOUT, если оба этих режима поддерживаются. Обратите внимание, что для поддержки QIO или QOUT необходима как соответствующий тип микросхемы flash, так и электрические соединения между ESP32-C3 и микросхемой flash, иначе режимы quad I/O или SoC не будут корректно работать.
· Установите CONFIG_COMPILER_OPTIMIZATION в "Optimize for performance (-O2)". Это может немного увеличить размер кода по сравнению с настройкой по умолчанию, однако почти наверняка повысит скорость работы какого-нибудь кода. Обратите внимание, что если ваш код содержит участки с неопределенной последовательностью выполнения (C/C++ Undefined Behaviour), то изменение уровня оптимизации может выявить ошибки кода, которые в других условиях не проявляются.
· Избегайте использования арифметики с плавающей точкой (float). ESP32-C3 выполняет такие вычисления программно, что происходит очень медленно. При возможности используйте арифметику с фиксированной запятой, другой метод целочисленного представления дробных чисел, или преобразуйте часть вычислений в целочисленные операции перед тем, как переключиться на плавающую точку.
· Избегайте арифметики плавающей точки двойной точности (double). Эти вычисления также эмулируются программно, и происходят очень медленно. Если возможно, замените doble на представление на основе целых чисел, или в крайнем случае перейдите на плавающую точку одинарной точности.

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

· Уменьшить объем вывода путем понижения значения опции CONFIG_LOG_DEFAULT_LEVEL (эквивалентно настройке загрузчика CONFIG_BOOTLOADER_LOG_LEVEL). Это также уменьшит размер двоичного кода, и уменьшит расход процессорного времени CPU, затраченного на обработку форматирования строк вывода.
· Повышение скорости вывода в лог путем увеличения CONFIG_ESP_CONSOLE_UART_BAUDRATE.

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

· Установите CONFIG_COMPILER_OPTIMIZATION_ASSERTION_LEVEL в состояние "запрещено" (Compiler options → Assertion level → Disabled (sets -DNDEBUG)). Это также немного уменьшит размер двоичного кода firmware. Однако такая настройка может увеличить серьезность ошибок в firmware, включая баги, связанные с безопасностью. Если такая настройка необходима для оптимизации определенной функции, то рассмотрите вместо этого добавление #define NDEBUG в начало только одного файла исходного кода.

[Целевые оптимизации]

Следующие изменения повысят скорость работы выбранной части firmware приложения:

· Поместите наиболее часто выполняемые участки кода во внутреннюю быструю оперативную память (IRAM). По умолчанию весь код приложения выполняется из кэша flash. Это означает, что могут быть ситуации "промаха кэша" для CPU, когда при загрузке следующей выполняемой инструкции он ждет синхронизации содержимого flash и кэша. Функции, которые копируются во время загрузки в IRAM, всегда будут выполняться на полной скорости без подобных задержек.

IRAM это ограниченный ресурс, и при увеличении использования IRAM для кода это может уменьшить доступное количество DRAM, поэтому перемещение кода в IRAM требует стратегического планирования. Подробнее про IRAM (Instruction RAM) см. [6].

· Оптимизации таблицы переходов (jump table) могут быть заново разрешены для отдельных исходных файлов, которые не должны быть помещены в IRAM. Для часто используемых ветвей больших операторов switch/case это улучшит производительность. Как добавить опции компиляции -fjump-tables и -ftree-switch-conversion для отдельных исходных файлов, см. секцию "Управление компиляцией компонента" статьи [7].

[Уменьшение времени запуска]

В дополнение к описанным выше общим улучшениям производительнсти следующие опции можно подстроить для значительного уменьшения времени запуска приложения (Startup Time):

· Минимизация CONFIG_LOG_DEFAULT_LEVEL и CONFIG_BOOTLOADER_LOG_LEVEL значительно влияет на startup time. Чтобы вернуть более подробный лог после запуска приложения, установите CONFIG_LOG_MAXIMUM_LEVEL на минимальный уровень, и затем вызовите esp_log_level_set() для восстановления необходимого, повышенного уровня подробности вывода в лог. Функция main примера system/startup_time демонстрирует использование этого метода.
· Если используется режим deep sleep, то установка CONFIG_BOOTLOADER_SKIP_VALIDATE_IN_DEEP_SLEEP позволит ускорить процесс выхода приложения из сна. Обратите внимание, что если используется Secure Boot, то это представляет определенный компромисс в контексте безопасности, поскольку проверка Secure Boot при пробуждении не будет выполняться.
· Установка CONFIG_BOOTLOADER_SKIP_VALIDATE_ON_POWER_ON пропустит проверку двоичного образа при каждой загрузке, когда включается питание и/или выполняется сброс (power-on reset). Насколько сократится время загрузки, зависит от размера двоичного образа и настроек flash. Обратите внимание, что эта опция добавляет риск неожиданной порчи flash. Прочитайте текст подсказки этой опции в menuconfig для объяснения её поведения и рекомендаций по использованию.
· Также можно немного уменьшить время загрузки, если запретить калибровку медленных тактов (RTC slow clock). Для этого установите в 0 опцию CONFIG_RTC_CLK_CAL_CYCLES. В результате любая часть firmware, которая использует RTC slow clock в качестве эталона интервалов времени, будет работать менее точно.

Проект примера system/startup_time предварительно сконфигурирован для оптимизации startup time. Файл system/startup_time/sdkconfig.defaults содержит все эти настройки. Вы можете добавить их в конец файла sdkconfig своего проекта, однако сначала прочитайте документацию по каждой из настроек.

[Приоритеты задач]

Приложения ESP-IDF разрабатываются поверх FreeRTOS, и в приложении могут быть потоки задач, которым нужна повышенная пропускная способность по обработке и уменьшение задержек. Для таких задач нужно повысить приоритет, чтобы они запускались немедленно в нужные моменты времени. Приоритет задачи устанавливается при их создании, т. е. при вызове xTaskCreate() или xTaskCreatePinnedToCore(), и также можно поменять приоритет во время выполнения путем вызова vTaskPrioritySet().

Также необходимо обеспечить, чтобы высокоприоритетные задачи уступали процессорное время CPU остальным задачам (вызовом vTaskDelay(), sleep(), или путем блокировке на семафоре, очереди, оповещении задачи и т. п.), чтобы задачи с пониженным приоритетам также могли выполняться, и это не создавало проблем для всей системы. Task Watchdog Timer (TWDT) обеспечивает механизм автоматического определения "голода" задач по процессорному времени [8]. Однако имейте в виду, что таймаут Task WDT не всегда показывает проблему, иногда корректная работа firmware зависит от некоторых долго выполняющихся вычислений. В таких (редких) случаях может потребоваться подстройка времени таймаута Task WDT или даже его полный запрет.

Приоритеты встроенных задач. ESP-IDF запускает несколько системных задач с фиксированными уровнями приоритета. Некоторые из них запускаются автоматически в процессе загрузки, другие запускаются только если firmware инициализирует некоторый определенный функционал. Для оптимизации производительности структурируйте приоритеты задач приложения таким образом, чтобы это не задерживало выполнение системных задач и не влияло на другие функции системы.

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

Заголовочный файл components/esp_system/include/esp_task.h содержит макросы для уровней прерывания, используемых для встроенных задач ESP-IDF. См. раздел Background Tasks документации [9] для дополнительной информации по системным задачам (system tasks).

Для задач используются следующие общие приоритеты:

· Главная задача системы (main task), которая запускает функцию app_main, имеет минимальный приоритет (1).
· Системная задача High Resolution Timer (ESP Timer), обрабатывающая события таймера и выполняющая их callback-функции, имеет повышенный приоритет (22, ESP_TASK_TIMER_PRIO).
· FreeRTOS Timer Task для обработки callback-вызовов таймера FreeRTOS создается, когда инициализируется планировщик, и у неё минимальный приоритет (1, что конфигурируется).
· Системная задача Event Loop Library для обработки цикла событий системы по умолчанию (default system event loop) и выполнения callback-функций, имеет высокий приоритет (20, ESP_TASK_EVENT_PRIO). Эта конфигурация используется только для случая, когда приложение вызывает esp_event_loop_create_default(), вместо этого можно вызывать esp_event_loop_create() с пользовательской конфигурацией задачи.
· Задача lwIP TCP/IP с повышенным приоритетом (18, ESP_TASK_TCPIP_PRIO).
· Задача Wi-Fi Driver с высоким приоритетом (23).
· Компонент Wi-Fi wpa_supplicant может создавать выделенные задачи во время использования функций Wi-Fi Protected Setup (WPS), WPA2 EAP-TLS, Device Provisioning Protocol (DPP) или BSS Transition Management (BTM). У этих задач низкий приоритет (2).
· Задача Bluetooth Controller имеет высокий приоритет (23, ESP_TASK_BT_CONTROLLER_PRIO). Контроллеру Bluetooth необходимо отвечать на запросы с малой задержкой, поэтому для него всегда необходимо использовать самый высокий приоритет задачи в системе.
· Задача хоста NimBLE Bluetooth Host работает с высоким приоритетом (21).
· Драйвер Ethernet создает задачу для MAC, чтобы принимать фреймы Ethernet. Если используется конфигурация по умолчанию ETH_MAC_DEFAULT_CONFIG, то у этой задачи средний приоритет (15). Эту настройку можно поменять передачей пользовательской структуры eth_mac_config_t, когда инициализируется Ethernet MAC.
· Если используется компонент MQTT [10], то он создает задачу с приоритетом по умолчанию 5 (это конфигурируется опцией CONFIG_MQTT_USE_CUSTOM_CONFIG (и также можно сконфигурировать runtime полем task_prio в esp_mqtt_client_config_t).
· У службы mDNS приоритет по умолчанию 1 устанавливается опцией CONFIG_MDNS_TASK_PRIORITY, подробнее см. [11].

Выбор приоритетов для задач приложения. В общем случае не рекомендуется устанавливать приоритет задачи выше, чем приоритет встроенных операций Wi-Fi/BT, поскольку отъем у них верени CPU может сделать систему нестабильной. Для очень коротких, критичных по времени выполнения операций, которые не используют сеть, используйте ISR или очень ограниченную задачу (очень короткими всплесками только runtime) с самым высоким приоритетом (24). Выбор приоритета 19 позволит низкоуровневому коду Wi-Fi/BT работать без задержек, вытесняя при этом код стека lwIP TCP/IP stack и другие не очень критичные к времени выполнения внутренние системные части кода - это самый лучший вариант выбора для критичных ко времени выполнения задач, которые не выполняют сетевых операций. Любая задача, которая выполняет сетевые операции TCP/IP, должна работать с приоритетом ниже, чем приоритет lwIP TCP/IP task (18), чтобы избежать проблем инверсии приоритета.

Примечание: выполнение задач полностью приостанавливается, когда выполняется запись встроенного чипа SPI flash. Продолжат выполнение только обработчики прерываний, находящиеся во внутреннем ОЗУ (IRAM-Safe Interrupt Handlers).

[Улучшение производительности прерываний]

ESP-IDF поддерживает динамическое выделение прерываний с возможностью вытеснения. У каждого прерывания в системе есть приоритет, и прерывания с более высоким приоритетом вытесняют прерывания с более низким приоритетом.

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

Чтобы получить лучше быстродействие для определенного ISR:

· Назначьте более важным прерываниями повышенный приоритет, используя при вызове esp_intr_alloc() такой флаг, как ESP_INTR_FLAG_LEVEL2 или ESP_INTR_FLAG_LEVEL3.
· Если вы уверены, что весь обработчик прерывания может работать из IRAM (см. "IRAM-Safe обработчики прерывания" статьи [12]), то установите флаг ESP_INTR_FLAG_IRAM, когда вызываете esp_intr_alloc() для назначения прерывания. Это предотвратит ситуацию, когда прерывание будет временно запрещено, когда firmware приложения записывает внутреннюю память SPI flash.
· Даже если ISR не является IRAM safe, то если он вызывается часто, то рассмотрите какой-нибудь способ перемещения его функции в IRAM. Это минимизирует шанс промаха кэша при вызове ISR (cм. выше "Целевые оптимизации"). Это можно сделать без добавления флага ESP_INTR_FLAG_IRAM для пометки прерывания как IRAM-safe, если только части обработчика гарантируется размещение в IRAM.

[Повышение скорости работы сети]

· Для Wi-Fi см. разделы "How to Improve Wi-Fi Performance" и "Wi-Fi Buffer Usage" статьи [13].
· Для lwIP TCP/IP (Wi-Fi и Ethernet), см. "Performance Optimization" документации [14].
· Пример wifi/iperf содержит конфигурацию, которая жестко оптимизирована для повышенной пропускной способности Wi-Fi TCP/IP. Добавьте содержимое файлов wifi/iperf/sdkconfig.defaults, wifi/iperf/sdkconfig.defaults.esp32c3 и wifi/iperf/sdkconfig.ci.99 в свой файл sdkconfig проекта, чтобы добавить все эти опции. Обратите внимание, что некоторые из этих опций создают компромисс в контексте усложнения отладки, увеличения размера firmware, повышения расхода IRAM или снижения производительности другого функционала. Для получения наилучшего результата внимательно ознакомьтесь с документацией выше и ссылками, чтобы выяснить, какие из опций лучше всего подходят для вашего конкретного приложения.

[Улучшение производительности ввода/вывода]

Использование стандартных библиотечных функций C наподобие fread и fwrite вместо специфичных для платформы не буферизированных системных вызовов (unbuffered syscalls), таких как read и write, может быть медленным. Эти функции разработаны, чтобы быть портируемыми, так что они не обязательно оптимизированы для скорости, и могут вносить дополнительные накладные расходы на обработку и буферизацию.

Специальная информация и советы для использования FatFS:

· Максимальный размер запроса R/W == размеру кластера FatFS (единица выделяемой памяти).
· Используйте read и write вместо fread и fwrite.
· Для увеличения скорости буферизированных функций чтения наподобие fread и fgets вы можете увеличить размер файлового буфера (у Newlib по умолчанию размер буфера 128 байт) на 4096, 8192 или даже 16384. Это можно сделать локально вызовом функции setvbuf на определенном файловом указателе, или глобально, применимо ко всем файлам, путем модификации CONFIG_FATFS_VFS_FSTAT_BLKSIZE.

Примечание: установка повышенного размера буфера также увеличит использование кучи.

[Ссылки]

1. ESP-IDF Maximizing Execution Speed site:espressif.com.
2. ESP32-C3: минимизация размера двоичного кода.
3. ESP-IDF Minimizing RAM Usage site:espressif.com.
4. Библиотека ESP-IDF для вывода в лог.
5. ESP-IDF Application Level Tracing library site:espressif.com.
6. ESP32: типы памяти.
7. ESP-IDF Build System.
8. ESP-IDF Watchdogs site:espressif.com.
9. ESP-IDF FreeRTOS (Overview) site:espressif.com.
10. Библиотека ESP-MQTT.
11. ESP-IDF mDNS.
12. ESP32-C3: выделение прерывания.
13. ESP32-C3 Wi-Fi Driver site:espressif.com.
14. ESP-IDF lwIP site:espressif.com.