Основные методики оптимизации кода
Оптимизация программного кода — это модификация программ, выполняемая оптимизирующим компилятором или интерпретатором с целью улучшения их характеристик, таких как производительности или компактности, — без изменения функциональности.
Последние три слова в этом определении очень важны: как бы не улучшала оптимизация характеристики программы, она обязательно должна сохранять изначальный смысл программы при любых условиях.
Оптимизация
В оптимизации есть несколько важных моментов:
Оптимизация должна быть естественной. Оптимизированный фрагмент кода должен легко вливаться в программу, не нарушая логики ее работы. Он должен легко вводится в программу, изменятся или удаляться из нее.
Оптимизация должна приносить существенный прирост производительности. Оптимизированная программа должна работать минимум на 20%-30% эффективней, чем ее неоптимизированный аналог, иначе она теряет смысл. Разработка (и отладка) критических областей не должна увеличивать время разработки программы более чем на 10%-15%.
Так же, перед тем как писать оптимизированный вариант, полезно иметь его неоптимизированный аналог. Обычно, оптимизированный код очень тяжел для восприятия, и если после его внедрения в программе появятся ошибки, то, подставив вместо него его менее эффективного собрата, мы можем определить, кто виноват в ошибках.
Основные постулаты оптимизации:
- Начинать оптимизацию нужно с самых «узких» мест программы. Если мы будем оптимизировать те места, где и без нашего вмешательства все быстро работает, то прирост производительности будет минимален. Это основной закон оптимизации, от него мы и будем отталкиваться, разбирая остальные.
- Оптимизировать лучше те места, которые регулярно повторяются в ходе работы. Этот закон относится к циклам и подпрограммам. Если можно хотя бы немного оптимизировать цикл, то делайте это не задумываясь. Если в одной итерации мы добьемся прироста в 2%, то после 1000 повторений это уже будет достаточно большое значение.
- Старайтесь не слишком злоупотреблять оптимизацией единичных операций. Этот закон – своеобразное продолжение предыдущего. Оптимизируя фрагмент, который будет вызван лишь один раз, мы вряд ли добьемся ощутимого прироста (но если эффект будет ощутим (>10%, что бывает крайне редко), то оптимизация лишней не будет).
- Используйте ассемблер только там, где скорость работы очень важна. Как уже писалось ранее, сейчас ассемблер не дает огромного прироста скорости. Поэтому использовать его стоит лишь в самых «узких» местах программы.
- Задумывайтесь над оптимизацией. Неправильная оптимизация может даже навредить программе, увеличить время ее разработки, практически не уменьшив скорость ее работы.
Вырезание условий и свитчей
Компилятор вырежет целую ветку условий или свитчей, если заранее будет уверен в результате сравнения или выбора. Как его в этом убедить? Правильно, константой! Рассмотрим элементарный пример: условие или свитч (неважно) с тремя вариантами:
Если объявить num
как обычную переменную – в скомпилированный код попадёт вся конструкция целиком, три условия или весь свитч. Если num
сделать константой const или дефайном #define
– компилятор вырежет весь блок условий или свитч и оставит только содержимое, которое получается при заданном num
. В этом очень легко убедиться, скомпилировав код и посмотрев на объём занимаемой памяти в логе компилятора. При помощи данного трюка можно ускорить выполнение некоторых функций и уменьшить занимаемое ими место в памяти.
Использовать переменные соответствующих типов
Тип переменной/константы не только влияет на занимаемый ей объём памяти, но и на скорость вычислений! В реальном коде время может быть меньше. Примечание: время приведено для кварца 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 |
float | 8.125 | 10 | 31.5 |
Как вы можете заметить, время вычислений отличается в разы даже для целочисленных типов данных, так что всегда нужно прикидывать, какая максимальная величина будет храниться в переменной, и выбирать соответствующий тип данных. Стараться не использовать 32-битные числа там, где они не нужны, а также по возможности не использовать float
. В то же время, умножить long
на float
будет выгоднее, чем делить long на целое число. Такие моменты можно считать заранее как 1/число и умножать вместо деления в критических ко времени выполнения моментах кода.
Отказаться от float
Из таблицы выше вы также можете узнать, что на действия с числами с плавающей точкой микроконтроллер тратит в несколько раз больше времени по сравнению с целочисленными типами. Дело в том, что у большинства микроконтроллеров AVR (что стоят на Ардуинах) нет “хардверной” поддержки вычислений float-чисел, и эти вычисления производятся не очень оптимальными программными методами. На взрослых микроконтроллерах ARM такая поддержка, к слову, имеется. Что же делать? Просто избегайте использования float
там, где задачу можно решить целочисленными типами. Если нужно перемножить-переделить кучу float‘ов, то можно перевести их в целочисленный тип, умножив на 10-100-1000, смотря какая нужна точность, вычислить, а затем результат снова перевести в float
. В большинстве случаев это получается быстрее, чем вычислять float напрямую:
Заменить Ардуино-функции их быстрыми аналогами
Если в проекте очень часто используется периферия микроконтроллера (АЦП, цифровые входы/выходы, генерация ШИМ…), то нужно знать одну вещь: Ардуино (на самом деле Wiring) функции написаны так, чтобы защитить пользователя от возможных ошибок, внутри этих функций находится куча различных проверок и защит “от дурака”, поэтому они выполняются гораздо дольше, чем могли бы. Также некоторая периферия микроконтроллера настроена так, что работает очень медленно. Пример: digitalWrite()
и digitalRead()
выполняются около 3.5 мкс, когда прямая работа с портом микроконтроллера занимает 0.5 мкс, что почти на порядок быстрее. analogRead() выполняется 112 мкс, хотя если его настроить чуть по-другому, он будет выполняться почти в 10 раз быстрее, не особо потеряв в точности.
Использовать switch вместо else if
В ветвящихся конструкциях со множественным выбором по значению целочисленной переменной стоит отдавать предпочтение конструкции switch-case
, она работает быстрее else if
(изучали в уроках про условия и выбор). Но помните, что switch
работает только с целочисленными значениями!
// тест SWITCH
// keka равна 10
// тест SWITCH
// время выполнения: 0.3 мкс (5 тактов)
num
numswitch (keka) {
num
case 10: break; // выбираем это
num
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;
num}
num
// keka равна 100
// тест SWITCH
// время выполнения: 0.3 мкс (5 тактов)
num
numswitch (keka) {
num
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; // выбираем это
num
num}
num
// тест ELSE IF
// тест SWITCH
// keka равна 10
// тест SWITCH
// время выполнения: 0.50 мкс (8 тактов)
num
numif (keka == 10) { // выбираем это
num
num} else if (keka == 20) {
num
num} else if (keka == 30) {
num
num} else if (keka == 40) {
num
num} else if (keka == 50) {
num
num} else if (keka == 60) {
num
num} else if (keka == 70) {
num
num} else if (keka == 80) {
num
num} else if (keka == 90) {
num
num} else if (keka == 100) {
num
num}
num
// keka равна 100
// тест SWITCH
// время выполнения: 2.56 мкс (41 такт)
num
numif (keka == 10) {
num
num} else if (keka == 20) {
num
num} else if (keka == 30) {
num
num} else if (keka == 40) {
num
num} else if (keka == 50) {
num
num} else if (keka == 60) {
num
num} else if (keka == 70) {
num
num} else if (keka == 80) {
num
num} else if (keka == 90) {
num
num} else if (keka == 100) { // выбираем это
num
num}
num
Использовать константы
Константы (const
или #define
) “работают” гораздо быстрее переменных при передаче их в качестве аргументов в функции. Делайте константами всё, что не будет меняться в процессе работы программы! Пример:
byte pin = 3; // частота будет 128 кГц (GyverCore)
// тест SWITCH
//const byte pin = 3; // частота будет 994 кГц (GyverCore)
num
numvoid setup() {
num
pinMode(pin, OUTPUT);
num}
num
numvoid loop() {
num
for (;;) {
num
digitalWrite(pin, 1);
digitalWrite(pin, 0);
}
num
num}
Почему это происходит? Компилятор оптимизирует код, и с константными аргументами он может выбросить из функции почти весь лишний код (если там есть, например, блоки if-else
), и она будет работать быстрее.