- С чем компилятор справится сам
- Как измерить скорость
- Оптимизация
- Типы переменных
- Отказ от float
- Заменить умножение на 2^n битовым сдвигом
- Заменить деление на 2^n битовым сдвигом
- Заменить остаток от деления на 2^n битовой маской
- Заменить деление и остаток на Xdiv
- Заменить деление умножением
- Заменить деление умножением на float
- Заменить возведение в степень умножением
- Заменить сдвиг указателем
- Предварительно вычислять то, что можно вычислить
- Заменить Ардуино-функции их быстрыми аналогами
- Использовать switch вместо else if
- Помнить про порядок условий
- Проверка флага
- Использовать битовые операции
- Использовать указатели и ссылки
- Использовать константы
- Миновать loop
С ростом навыков и созданием более крупных проектов вы столкнётесь с тем, что "Ардуина" перестанет справляться с тем объёмом работы, который вы хотите от неё получить. Может банально не хватать быстродействия в расчётах, обновлении информации на дисплеях, отправки данных и прочих ресурсозатратных действий! Рассмотрим способы оптимизации скорости работы кода, в прошлом уроке речь шла об оптимизации памяти.
С чем компилятор справится сам #
Компилятор оптимизирует некоторые вычисления:
- Заменяет типы данных на более оптимальные там, где это возможно и не повлияет на результат. Например
val /= 2.8345выполняется в 4 раза дольше, чемval /= 2.0, потому что2.0была заменена на2 - Заменяет операции целочисленного умножения на степени двойки (
2^n) битовым сдвигом. Например,val * 16выполняется в два раза быстрее, чемval * 12, потому что будет заменена наval << 4- Примечание: для операций целочисленного деления такая оптимизация не проводится и её можно сделать вручную (см. ниже)
- Заменяет операции взятия остатка от деления
%на степени двойки битовой маской (см. ниже)- Примечание: такая оптимизация производится не всегда (по моим тестам), поэтому лучше всё таки заменять взятие остатка от деления на битовую маску
- Предварительно вычисляет всё, что можно вычислить (константы). Например
val /= 7.8125выполняется столько же, сколькоval /= (2.5*10.0/3.2+12.28*3.2), потому что компилятор заранее посчитал и подставил результат всех действий с константами
Как измерить скорость #
Для большинства случаев достаточно стандартной конструкции с millis() / micros() - запомнить текущее время, выполнить действие, вычесть запомненное время из текущего. Точность измерения тем выше, чем меньше в "измеряемом" коде используется запрет прерываний. Минимальная единица измерения (для AVR Arduino) - 4 микросекунды, это разрешение функции micros():
void setup() {
Serial.begin(115200);
}
void loop() {
uint32_t us = micros();
// измеряемый код
delay(123);
// измеряемый код
us = micros() - us;
Serial.println(us);
delay(1000);
}
Для "взрослой" оптимизации кода (вычисления, IO) этой точности будет мало. Я написал библиотеку для измерения времени выполнения кода, она работает на:
- AVR (библиотека использует Timer 1)
- ESP8266
- ESP32
На AVR (Arduino Nano, UNO...) даёт точность до 1 такта процессора:
#include <Benchmark.h>
void setup() {
Serial.begin(115200);
benchBegin();
delay(1);
benchEnd(Serial);
}
void loop() {}
Оптимизация #
Типы переменных #
Тип переменной/константы не только влияет на занимаемый ей объём памяти, но и на скорость вычислений! Привожу таблицу для простейших не оптимизированных компилятором вычислений. В реальном коде время может быть меньше. Примечание: время приведено для AVR на частоте 16 МГц.
| Тип данных | Время выполнения, мкс | ||
|---|---|---|---|
| Сложение и вычитание | Умножение | Деление, остаток | |
int8_t |
0.44 | 0.625 | 14.25 |
uint8_t |
0.44 | 0.625 | 5.38 |
int16_t |
0.89 | 1.375 | 14.25 |
uint16_t |
0.89 | 1.375 | 13.12 |
int32_t |
1.75 | 6.06 | 38.3 |
uint32_t |
1.75 | 6.06 | 37.5 |
int64_t |
4 | 22.4 | 49.5 |
uint64_t |
4 | 22.4 | 49.1 |
float |
8.125 | 10 | 31.5 |
Как вы можете заметить, время вычислений отличается в разы даже для целочисленных типов данных, так что всегда нужно прикидывать, какая максимальная величина будет храниться в переменной и выбирать соответствующий тип данных. Стараться не использовать 32-битные числа там, где они не нужны, а также по возможности не использовать float. В то же время, умножить long на float будет выгоднее, чем делить long на целое число. Такие моменты можно считать заранее как 1/число и умножать вместо деления в критических ко времени выполнения моментах кода. Также читай об этом чуть ниже.
Отказ от float #
Из таблицы выше можно увидеть, что на действия с числами с плавающей точкой микроконтроллер тратит в несколько раз больше времени по сравнению с целочисленными типами. Дело в том, что у большинства микроконтроллеров AVR (что стоят на Ардуинах) нет аппаратной поддержки вычислений float чисел и эти вычисления производятся программными средствами.
Просто избегайте использования float там, где задачу можно решить целочисленными типами. Если нужно перемножить-переделить кучу float'ов, то можно перевести их в целочисленный тип, умножив на 10/100/1000, смотря какая нужна точность, вычислить, а затем результат снова перевести в float. В большинстве случаев это получается быстрее, чем вычислять float напрямую:
// допустим, нам нужно хитро обработать значение float с датчика
// или хранить массив таких значений, не тратя лишнюю память.
// пусть sensorRead() возвращает float температуру с точностью до 1 знака.
// Превратим её в целочисленное, умножив на 10:
int val = sensorRead() * 10;
// теперь с целочисленным val можно работать без потери точности измерения и
// можно хранить его в двух байтах вместо 4-х.
// Чтобы превратить его обратно во float - просто делим на 10
float val_f = val / 10.0;
Также можно использовать числа с фиксированной точкой, например мою библиотеку fixed.
Заменить умножение на 2^n битовым сдвигом #
В операциях целочисленного умножения, где второй множитель является константой или числом (val * 10), можно ускорить вычисление в том случае, когда число состоит из степени двойки (2 4 8 16 32 64 128...). Для этого нужно заменить умножение на 2^n сдвигом влево на n:
val * 2 == val << 1val * 8 == val << 3val * 32 == val << 5- И так далее
Примечание: компилятор сам оптимизирует такие вычисления, так что нужно стараться писать свои алгоритмы так, чтобы в математических операциях были числа из степени двойки (например размеры буферов, размеры матрицы и т.д.)
Заменить деление на 2^n битовым сдвигом #
В операциях целочисленного деления, где делитель является константой или числом (val / 10), можно ускорить вычисление в том случае, когда делитель состоит из степени двойки (2 4 8 16 32 64 128...). Для этого нужно заменить деление на 2^n сдвигом вправо на n:
val / 2 == val >> 1val / 8 == val >> 3val / 32 == val >> 5- И так далее
Заменяя сдвигом деление на степень двойки помним, что это работает правильно только для положительных чисел:
64 / 8 // == 8
64 >> 3 // == 8
63 / 8 // == 7
63 >> 3 // == 7
-64 / 8 // == -8
-64 >> 3 // == -8
-63 / 8 // == -7
-63 >> 3 // == -8 (!)
Как быть: если число меньше нуля, и оно не делится на делитель нацело, надо добавить к результату единицу:
// делим знаковое val на 8 (2^3):
if ((val < 0) && (val & ((1 << 3) - 1))) result = (val >> 3) + 1;
else result = val >> 3;
Заменить остаток от деления на 2^n битовой маской #
В операциях взятия целочисленного остатка от деления, где делитель является константой или числом (val % 10), можно ускорить вычисление в том случае, когда делитель состоит из степени двойки (2 4 8 16 32 64 128...). Для этого нужно заменить взятие остатка от 2^n на битовую маску (2^n - 1):
val % 2 == val & 1val % 8 == val & 7val % 32 == val & 31- И так далее
Вычисление выполняется в ~17 раз быстрее (AVR).
Примечание: компилятор сам оптимизирует такие вычисления, но не всегда, поэтому в критических ко времени вычислениях рекомендуется проводить такую оптимизацию самостоятельно
Заменить деление и остаток на Xdiv #
Часто бывает нужно разделить на число и получить остаток от деления на него, например:
123 / 20; // 6
123 % 20; // 3
Можно избавиться от остатка, заменив его быстрым умножением:
int quot = 123 / 20; // 6
int rem = 123 - quot * 20; // 3
Также в стандартной библиотеке (справочник) есть инструмент для этого действия - функции div, ldiv и lldiv:
div_t res;
res = div(123, 20);
res.quot; // 6
res.rem; // 3
Заменить деление умножением #
В операциях целочисленного деления, где делитель является константой или числом (val / 10), можно заменить деление умножением на обратное число. Идея состоит в следующем:
a / b == a * (1 / b) == (a * x) >> n, где:
x = (2^n) / b + 1a * xне должно превышать ячейку 32 бита (или 16 бит для более быстрого вычисления)n- "масштаб". Чем большеn, тем выше точность. Нужно подобрать так, чтобы было максимальным для 32 или 16 бит, при известном максимальном значенииa
Алгоритм:
- Определить
n. Выразим из формулы для x:2^n < (MAX / a_max - 1) * b, гдеMAX- размер ячейки для операции умножения, имеет смысл 32 бит (4294967296) или 16 бит (65356) - Посчитать
xпо формуле выше - Заменить деление на
(a * x) >> nдля 16 бит или((uint32_t)a * x) >> nдля 32 бит - Бонус - поиск остатка от деления. Достаточно вычесть из делимого произведение результата от деления на делитель:
a % b == a - q * b, гдеq = a / b, посчитанное способом выше
Вычисление выполняется в ~2 раза быстрее (AVR).
Пример расчёта:
- Хочу оптимизировать деление на
6 - Делить буду переменную, которая у меня в программе принимает значения от
0до130 - Вычисления ограничим в ячейке 16 бит (макс.
65536) для бОльшей скорости - Ищем
n:(65536 / 130 - 1) * 6 = 3018, то есть2^nне должно превышать3018, ближайшееn==11 - Пересчитываем
x:x = (2^11) / 6 + 1 = 342 - Получим выражение для вычисления
a / 6:(a * 342) >> 11
Заменить деление умножением на float #
Опять же по таблице выше можно увидеть, что деление для всех типов данных выполняется гораздо дольше умножения, поэтому иногда бывает выгоднее заменить деление на целое число умножением на float. И да, пытаться усидеть на двух стульях, стараясь не использовать float и использовать его вместо деления:
val / 10; // выполняется 14.54 мкс
val * 0.1; // выполняется 10.58 мкс
Заменить возведение в степень умножением #
Для возведения в степень у нас есть удобная функция pow(a, b), но в целочисленных расчётах лучше ей не пользоваться: она выполняется гораздо дольше ручного перемножения, потому работает с float, даже если скормить ей целое:
val = pow(val, 5); // выполняется 20.33 us
val = (long)val * val * val * val * val; // выполняется 4.47 us
Заменить сдвиг указателем #
Очень часто при работе с данными бывает нужно "склеить" 16-32 бит переменную из отдельных байтов или наоборот разобрать её на байты. Обычно в таких случаях используются сдвиги:
// получить старший байт из 16 бит переменной a
b = a >> 8;
// склеить два байта в 16 бит int
v = a | (b << 8);
// разбить 24 бит цвет на каналы
r = ((uint32_t)color >> 16) & 0xFF;
g = ((uint32_t)color >> 8) & 0xFF;
b = (uint32_t)color & 0xFF;
// склеить 24 бит цвет из трёх каналов
color = ((uint32_t)r << 16) | (g << 8) | b;
Сдвиги можно заменить на доступ к байтам переменной по указателю, и уже оттуда их читать и писать (равноценный пример):
// получить старший (второй) байт из 16 бит переменной a
b = ((uint8_t*)&a)[1];
// склеить два байта в 16 бит int
((uint8_t*)&v)[0] = a;
((uint8_t*)&v)[1] = b;
// разбить 24 бит цвет на каналы
r = ((uint8_t*)&color)[2];
g = ((uint8_t*)&color)[1];
b = ((uint8_t*)&color)[0];
// склеить 24 бит цвет из трёх каналов
((uint8_t*)&color)[2] = r;
((uint8_t*)&color)[1] = g;
((uint8_t*)&color)[0] = b;
Как это работает (на первом примере) - мы берём адрес переменной a - &a, затем преобразуем его к указателю на тип данных uint8_t - (uint8_t*)&a. Чтобы получить доступ к нужному байту в переменной, достаточно обратиться к полученной конструкции как к массиву - ((uint8_t*)&a)[номер байта] для записи и чтения.
Такая конструкция работает в 2 раза быстрее сдвигов, но всё же это крохоборство: в примере со сборкой 3 байт доступ по указателю даёт экономию в 1.5 микросекунды (на AVR). В подавляющем большинстве случаев это настолько незначительно, что лучше не трогать привычные сдвиги. Но если таких преобразований нужно делать много, и важен каждый такт микроконтроллера, то можно и ускорить.
Предварительно вычислять то, что можно вычислить #
Некоторые сложные вычисления требуют выполнения одних и тех же действий несколько раз. Гораздо быстрее будет создать локальную переменную, в неё "посчитать" и использовать в дальнейших расчётах.
Примечание: большинство расчётов компилятор оптимизирует сам, например действия с константами и конкретными цифрами
Ещё хороший пример: расчёт величин, которые ведут себя предсказуемо, например гармонические функции sin() и cos(). На их вычисление уходит довольно-таки много времени - 119.46 мкс! На практике синусы/косинусы практически никогда не вычисляют средствами микроконтроллера, их вычисляют заранее и сохраняют в виде массива. Да, опять два стула: тратить время на вычисление или занимать память уже посчитанными данными. Также не забываем, что компилятор сам оптимизирует вычисления и делает это весьма неплохо.
Заменить Ардуино-функции их быстрыми аналогами #
Читайте урок про разгон аппаратуры.
Использовать switch вместо else if #
В ветвящихся конструкциях со множественным выбором по значению целочисленной переменной стоит отдавать предпочтение конструкции switch-case, она работает быстрее else if. Но помните, что:
switchработает только с целочисленными даннымиcaseдолжны быть константами
Причём чем дальше находится верное условие (через несколько сравнений), тем больше времени выполняется код. При использовании switch время каждый раз одинаковое!
Сравнение
// тест SWITCH
// keka равна 10
// время выполнения: 0.3 мкс (5 тактов)
switch (keka) {
case 10: break; // выбираем это
case 20: break;
case 30: break;
case 40: break;
case 50: break;
case 60: break;
case 70: break;
case 80: break;
case 90: break;
case 100: break;
}
// keka равна 100
// время выполнения: 0.3 мкс (5 тактов)
switch (keka) {
case 10: break;
case 20: break;
case 30: break;
case 40: break;
case 50: break;
case 60: break;
case 70: break;
case 80: break;
case 90: break;
case 100: break; // выбираем это
}
// тест ELSE IF
// keka равна 10
// время выполнения: 0.50 мкс (8 тактов)
if (keka == 10) { // выбираем это
} else if (keka == 20) {
} else if (keka == 30) {
} else if (keka == 40) {
} else if (keka == 50) {
} else if (keka == 60) {
} else if (keka == 70) {
} else if (keka == 80) {
} else if (keka == 90) {
} else if (keka == 100) {
}
// keka равна 100
// время выполнения: 2.56 мкс (41 такт)
if (keka == 10) {
} else if (keka == 20) {
} else if (keka == 30) {
} else if (keka == 40) {
} else if (keka == 50) {
} else if (keka == 60) {
} else if (keka == 70) {
} else if (keka == 80) {
} else if (keka == 90) {
} else if (keka == 100) { // выбираем это
}
Конструкции со сравнениями и диапазонами также можно заменить на switch-case:
// условия
if (a > 0 && a < 10) {код}
else if (a >= 11 && a < 20) {код}
else if (a >= 21 && a < 30) {код}
// switch с диапазонами
switch (a) {
case 0 ... 10: код; break;
case 11 ... 20: код; break;
case 21 ... 30: код; break;
}
Такой код не только выглядит более читаемо, но и выполняется сильно быстрее, особенно при попадании в дальние диапазоны.
Помнить про порядок условий #
Если проверяется одновременно несколько логических выражений, то при наступлении первого результата, при котором всё условие однозначно получит известное значение, остальные выражения даже не проверяются. Например:
if ( flag && getSensorState() ) {
// какой-то код
}
Если flag имеет значение false, функция getSensorState() даже не будет вызвана! if будет сразу пропущен (или выполнен else, если он есть). Этим нужно пользоваться, расставляя условия в порядке возрастания процессорного времени, которое требуется для их вызова/выполнения, если это функции. Например, если наша getSensorState() тратит какое-то время для выполнения, то мы ставим её после флага, который является просто переменной. Это позволит сэкономить процессорное время в те моменты, когда флаг имеет значение false.
Проверка флага #
Казалось бы логично, что запись в переменную занимает дольше времени, чем проверка её значения. Например, нужно сбросить флаг, если он поднят:
if (f) f = false;
Но это не так - запись выполняется быстрее, то есть выгоднее каждый раз сбрасывать флаг без проверки:
f = false;
Использовать битовые операции #
Используйте битовые трюки и вообще битовые операции, часто они помогают ускорить код.
Использовать указатели и ссылки #
Вместо передачи "объекта" в качестве аргумента функции по значению, передавать его по ссылке или по указателю: процессор не будет выделять память под копию аргумента и создавать эту копию в качестве формальной переменной - это сэкономит время!
Использовать константы #
Константы (const или #define) "работают" гораздо быстрее переменных при передаче их в качестве аргументов в функции. Делайте константами всё, что не будет меняться в процессе работы программы! Пример:
uint8_t pin = 3; // частота будет 128 кГц (GyverCore)
//const uint8_t pin = 3; // частота будет 994 кГц (GyverCore)
void setup() {
pinMode(pin, OUTPUT);
}
void loop() {
for (;;) {
digitalWrite(pin, 1);
digitalWrite(pin, 0);
}
}
Почему это происходит? Компилятор оптимизирует код, и с константными аргументами он может выбросить из функции почти весь лишний код (если там есть, например, блоки if-else) и она будет работать быстрее.
Миновать loop #
Функция loop() является вложенной во внешний цикл с некоторыми дополнительными проверками, поэтому если вам очень важно минимальное время между итерациями loop() - просто работайте в своём цикле for(;;), например вот так:
void loop() {
for (;;) {
// ваш код
}
}
Полезные страницы #
- Набор GyverKIT – наш большой стартовый набор Arduino, продаётся в России
- Каталог ссылок на дешёвые Ардуины, датчики, модули и прочие железки с AliExpress
- Обратная связь – сообщить об ошибке в уроке или предложить дополнение по тексту ([email protected])
- Поддержать автора за работу над уроками