Основные методики оптимизации кода

Материал из MIK32 микроконтроллер
(перенаправлено с «Методики оптимизации кода»)

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

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

Оптимизация

В оптимизации есть несколько важных моментов:

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

Оптимизация должна приносить существенный прирост производительности. Оптимизированная программа должна работать минимум на 20%-30% эффективней, чем ее неоптимизированный аналог, иначе она теряет смысл. Разработка (и отладка) критических областей не должна увеличивать время разработки программы более чем на 10%-15%.

Так же, перед тем как писать оптимизированный вариант, полезно иметь его неоптимизированный аналог. Обычно, оптимизированный код очень тяжел для восприятия, и если после его внедрения в программе появятся ошибки, то, подставив вместо него его менее эффективного собрата, мы можем определить, кто виноват в ошибках.

Основные постулаты оптимизации:

  1. Начинать оптимизацию нужно с самых «узких» мест программы. Если мы будем оптимизировать те места, где и без нашего вмешательства все быстро работает, то прирост производительности будет минимален. Это основной закон оптимизации, от него мы и будем отталкиваться, разбирая остальные.
  2. Оптимизировать лучше те места, которые регулярно повторяются в ходе работы. Этот закон относится к циклам и подпрограммам. Если можно хотя бы немного оптимизировать цикл, то делайте это не задумываясь. Если в одной итерации мы добьемся прироста в 2%, то после 1000 повторений это уже будет достаточно большое значение.
  3. Старайтесь не слишком злоупотреблять оптимизацией единичных операций. Этот закон – своеобразное продолжение предыдущего. Оптимизируя фрагмент, который будет вызван лишь один раз, мы вряд ли добьемся ощутимого прироста (но если эффект будет ощутим (>10%, что бывает крайне редко), то оптимизация лишней не будет).
  4. Используйте ассемблер только там, где скорость работы очень важна. Как уже писалось ранее, сейчас ассемблер не дает огромного прироста скорости. Поэтому использовать его стоит лишь в самых «узких» местах программы.
  5. Задумывайтесь над оптимизацией. Неправильная оптимизация может даже навредить программе, увеличить время ее разработки, практически не уменьшив скорость ее работы.


Вырезание условий и свитчей


Компилятор вырежет целую ветку условий или свитчей, если заранее будет уверен в результате сравнения или выбора. Как его в этом убедить? Правильно, константой! Рассмотрим элементарный пример: условие или свитч (неважно) с тремя вариантами:

Вырезание swtich.png

Если объявить 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 напрямую:

Float.png


Заменить Ардуино-функции их быстрыми аналогами


Если в проекте очень часто используется периферия микроконтроллера (АЦП, цифровые входы/выходы, генерация ШИМ…), то нужно знать одну вещь: Ардуино (на самом деле 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), и она будет работать быстрее.