- С чем компилятор справится сам
- Как измерить скорость
- Оптимизация
- Типы переменных
- Отказ от float
- Умножение на 2^n и битовый сдвиг
- Деление на 2^n и битовый сдвиг
- Остаток от деления на 2^n и битовая маска
- Заменить деление и остаток на Xdiv
- Заменить деление умножением
- Заменить деление умножением на float
- Заменить возведение в степень умножением
- Доступ к отдельным байтам
- Предварительно вычислять то, что можно вычислить
- Заменить Ардуино-функции их быстрыми аналогами
- switch и else if
- Помнить про порядок условий
- Проверка флага
- Использовать битовые операции
- Использовать указатели и ссылки
- Использовать константы
- Миновать loop
С ростом навыков и созданием более крупных проектов вы столкнётесь с тем, что "Ардуина" перестанет справляться с тем объёмом работы, который вы хотите от неё получить. Может банально не хватать быстродействия в расчётах, обновлении информации на дисплеях, отправке данных и прочих ресурсозатратных действиях! Рассмотрим способы оптимизации скорости работы кода, в прошлом уроке речь шла об оптимизации памяти.
С чем компилятор справится сам #
Компилятор оптимизирует некоторые вычисления:
- Упрощает выражения, если это не меняет их результат. Например, деление целого
valна2.0может превратиться в более короткую целочисленную операцию, если компилятор докажет эквивалентность - Заменяет операции целочисленного умножения и деления на степени двойки более быстрыми последовательностями сдвигов и поправок
- Заменяет операции взятия остатка от деления
%на степени двойки битовой маской (см. ниже)- Для беззнаковых типов и известного делителя современные компиляторы обычно делают это сами
- Предварительно вычисляет всё, что можно вычислить (константы). Например
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. Но не забывайте, что компилятор умеет оптимизировать операции с постоянным делителем, а замена целочисленной операции на float может изменить точность и результат. Подробнее об этом будет написано ниже.
Отказ от float #
Из таблицы выше можно увидеть, что на действия с числами с плавающей точкой микроконтроллер тратит в несколько раз больше времени по сравнению с целочисленными типами. Дело в том, что у большинства микроконтроллеров AVR (что стоят на Ардуинах) нет аппаратной поддержки вычислений float чисел и эти вычисления производятся программными средствами.
Просто избегайте использования float там, где задачу можно решить целочисленными типами. Если нужно перемножить-переделить кучу float'ов, то можно перевести их в целочисленный тип, умножив на 10/100/1000, смотря какая нужна точность, вычислить, а затем результат снова перевести в float. В большинстве случаев это получается быстрее, чем вычислять float напрямую:
// допустим, нам нужно хитро обработать значение float с датчика
// или хранить массив таких значений, не тратя лишнюю память.
// пусть sensorRead() возвращает float температуру с точностью до 1 знака.
// Превратим её в целочисленное, умножив на 10:
int16_t val = sensorRead() * 10;
// теперь с целочисленным val можно работать без потери точности измерения и
// можно хранить его в двух байтах вместо 4.
// Чтобы превратить его обратно во float - просто делим на 10
float val_f = val / 10.0;
Также можно использовать числа с фиксированной точкой, например мою библиотеку fixed.
Умножение на 2^n и битовый сдвиг #
В операциях с беззнаковыми целыми, где второй множитель является степенью двойки 2^n (2 4 8 16 32 64 128...), умножению соответствует сдвиг влево на n:
val * 2 == val << 1val * 8 == val << 3val * 32 == val << 5
Компилятор сам оптимизирует такие вычисления, поэтому ради скорости вручную заменять понятное умножение сдвигом обычно не нужно. Сдвиг знакового или слишком большого значения может привести к неопределённому поведению
Деление на 2^n и битовый сдвиг #
Для беззнаковых целых делению на степень двойки соответствует сдвиг вправо на n:
val / 2 == val >> 1val / 8 == val >> 3val / 32 == val >> 5- И так далее
Современный компилятор обычно выполняет эту замену сам и сохраняет правильное округление для знаковых типов, поэтому "грубая" замена деления на сдвиг может выполняться совсем незначительно быстрее
Для отрицательных чисел деление в C++ округляется к нулю, а результат правого сдвига отрицательного числа зависит от стандарта и компилятора. Поэтому эти выражения не всегда равнозначны:
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 и битовая маска #
Для беззнакового или неотрицательного значения остаток от деления на степень двойки соответствует битовой маске (2^n - 1):
val % 2 == val & 1val % 8 == val & 7val % 32 == val & 31- И так далее
Для отрицательных чисел % и & дают разные результаты. Деление и остаток с постоянной степенью двойки компилятор обычно оптимизирует сам
Заменить деление и остаток на 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
Заменить деление умножением #
В операциях беззнакового целочисленного деления деление иногда можно заменить умножением на заранее рассчитанное обратное число. Современный компилятор уже применяет подобные алгоритмы для постоянного делителя, поэтому ручной вариант имеет смысл только после измерения и проверки всего диапазона. Идея состоит в следующем:
a / b == a * (1 / b) == (a * x) >> n, где:
x = ceil(2^n / b)или в целых числахx = ((1u << n) + b - 1) / ba * xне должно превышать ячейку 32 бита (или 16 бит для более быстрого вычисления)n- "масштаб". Чем большеn, тем выше точность. Нужно подобрать так, чтобы было максимальным для 32 или 16 бит, при известном максимальном значенииa
Алгоритм:
- Определить
n. Выразим из формулы для x:2^n < (MAX / a_max - 1) * b, гдеMAX- количество значений ячейки для операции умножения, например4294967296для 32 бит или65536для 16 бит - Посчитать
xпо формуле выше - Заменить деление на
(a * x) >> nдля 16 бит или((uint32_t)a * x) >> nдля 32 бит - Бонус - поиск остатка от деления. Достаточно вычесть из делимого произведение результата от деления на делитель:
a % b == a - q * b, гдеq = a / b, посчитанное способом выше
В старом тесте для AVR вычисление выполнялось примерно в 2 раза быстрее. На актуальном компиляторе результат может совпасть с обычным делением.
Пример расчёта:
- Хочу оптимизировать деление на
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.1f; // в старом тесте выполнялось 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 = uint16_t(a) | (uint16_t(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) | (uint32_t(g) << 8) | b;
На little-endian платформах вроде AVR те же байты можно читать и писать через указатель:
// получить старший (второй) байт из 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)[номер байта] для записи и чтения.
Такой код зависит от порядка байтов процессора: на big-endian индексы будут другими. Кроме того, современный компилятор часто превращает сдвиги в такой же доступ к байтам. Поэтому для протоколов и переносимого кода привычные сдвиги обычно понятнее и безопаснее, а результат оптимизации нужно проверять по дизассемблеру или измерением.
Предварительно вычислять то, что можно вычислить #
Некоторые сложные вычисления требуют выполнения одних и тех же действий несколько раз. Гораздо быстрее будет создать локальную переменную, в неё "посчитать" и использовать в дальнейших расчётах.
Примечание: большинство расчётов компилятор оптимизирует сам, например действия с константами и конкретными цифрами
Ещё хороший пример: расчёт величин, которые ведут себя предсказуемо, например гармонические функции sin() и cos(). В тесте AVR на вычисление уходило 119.46 мкс. Если синус или косинус нужно вычислять часто для ограниченного набора аргументов, значения выгодно посчитать заранее и сохранить в массиве. Да, опять два стула: тратить время на вычисление или занимать память уже посчитанными данными. Также не забываем, что компилятор сам оптимизирует вычисления и делает это весьма неплохо.
Заменить Ардуино-функции их быстрыми аналогами #
Читайте урок про разгон аппаратуры.
switch и else if #
В ветвящихся конструкциях со множественным выбором по значению целочисленной переменной часто удобно использовать switch-case. Компилятор сам выбирает реализацию: цепочку сравнений, дерево переходов или таблицу. Поэтому switch может быть быстрее else if, особенно если вариантов мало или значения расположены редко. Но помните, что:
switchработает только с целочисленными даннымиcaseдолжны быть константами
Сравнение
// тест 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 с диапазонами (расширение GCC)
switch (a) {
case 1 ... 9: код; break;
case 11 ... 19: код; break;
case 21 ... 29: код; break;
}
Помнить про порядок условий #
Если проверяется одновременно несколько логических выражений, то при наступлении первого результата, при котором всё условие однозначно получит известное значение, остальные выражения даже не проверятся. Например:
if ( flag && getSensorState() ) {
// какой-то код
}
Если flag имеет значение false, функция getSensorState() не будет вызвана! if будет сразу пропущен (или выполнен else, если он есть). Этим нужно пользоваться, расставляя условия в порядке возрастания процессорного времени, которое требуется для их вызова/выполнения, если это функции. Например, если наша getSensorState() тратит какое-то время для выполнения, то мы ставим её после флага, который является просто переменной. Это позволит сэкономить процессорное время в те моменты, когда флаг имеет значение false.
Проверка флага #
Казалось бы логично, что запись в переменную занимает дольше времени, чем проверка её значения. Например, нужно сбросить флаг, если он поднят:
if (f) f = false;
Для обычной локальной переменной оптимизатор часто сам уберёт лишнюю проверку, но обычно будет быстрее сбрасывать флаг без проверки:
f = false;
Использовать битовые операции #
Используйте битовые трюки и вообще битовые операции, часто они помогают ускорить код.
Использовать указатели и ссылки #
Большие объекты вместо передачи по значению лучше передавать по const ссылке или указателю, если копия не нужна. Для маленьких типов вроде uint8_t передача по значению обычно проще и не медленнее.
Использовать константы #
Константы (const, constexpr или иногда #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() вызывается из внешнего цикла ядра, где на некоторых платформах выполняются дополнительные действия. Если вам действительно важно минимальное время между итерациями и вы понимаете последствия, можно работать в своём цикле for(;;):
void loop() {
for (;;) {
// ваш код
}
}
Такой цикл уже не вернёт управление Arduino-ядру. На некоторых платформах это помешает системным обработчикам, yield(), сетевому стеку или watchdog, поэтому данный трюк нельзя бездумно переносить между платами.
Полезные страницы #
- Набор GyverKIT – наш большой стартовый набор Arduino, продаётся в России
- Каталог ссылок на дешёвые Ардуины, датчики, модули и прочие железки с AliExpress
- Обратная связь – сообщить об ошибке в уроке или предложить дополнение по тексту (alex@alexgyver.ru)
- Поддержать автора за работу над уроками