В этой статье я собираюсь перейти под C и непосредственно посмотреть на сборку, чтобы увидеть, как флаги оптимизации влияют на программу. Он был дан нам по заданию из колледжа. Я думал, что это будет легко, потому что кто-то другой должен был сделать то же самое задолго до меня, поэтому я просто прочитаю ее статью, попробую несколько примеров, и все будет хорошо. Оказывается, я был частично прав. Определенно есть люди, которые выполнили эту задачу, но я не смог найти ни одного источника, спускающегося до уровня сборки, чтобы объяснить оптимизацию. В большинстве статей, которые я читал в Интернете, используется утилита времени в Linux, чтобы показать, уменьшилось ли время работы, а затем показать размер программы, чтобы показать компромисс между скоростью / пространством. Отсюда и эта статья. Я собираюсь взять несколько конкретных примеров оптимизации, указать соответствующие инструкции по сборке и изменения после оптимизации. Мы собираемся стартовать с O1 и идти до O3. Весь код, использованный в статье, можно найти на моей странице на github.

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

-O1

Начиная с самых основных оптимизаций, у нас есть -O1. Флаги оптимизации разделены на группы, потому что было бы очень неприятно применять 10 разных флагов каждый раз, когда вы пытаетесь запустить свою программу, особенно во время разработки (Да, я знаю о сценариях оболочки), помимо этого, Есть также другие характеристики флагов, включенных в определенную группу оптимизации. Например, O1 и O2 применяют все оптимизации, не предполагающие компромиссов между скоростью и пространством. По сути, они не тратят пространство для набора скорости, а O3 тратит пространство для набора скорости и т. Д. Таким образом, конкретные флаги оптимизации сгруппированы по различным уровням, от O1 до O3 и т. Д. Вы можете увидеть конкретные оптимизации, которые применяются, когда мы используем разные флаги оптимизации здесь. Я просто собираюсь показать самую простую оптимизацию, а именно устранение мертвого кода.

У нас есть базовая программа, которая присваивает переменной a значение 10 и возвращает ее. По сути, это не приносит никакой полезной работы.

Код сборки, созданный для моей машины, показан ниже.

Наша красивая маленькая программа превратилась в несколько больший беспорядок. Теперь, сначала, когда я увидел код, я вроде как понял, что делают операторы, но не был уверен, как все работает. Поэтому мне пришлось немного почитать, чтобы выяснить, какие строки имеют отношение к делу и что означают имена регистров. Я прочитал, так что вам не придется. Как вы, возможно, уже знаете, сгенерированная сборка содержит много шаблонного кода, много рутинной работы, поэтому очень мало строк заслуживают нашего внимания, потому что большинство строк специфичны для моей машины. В этом случае наш интерес должен быть сосредоточен на этой линии.

Не вдаваясь в конкретные детали, мы можем видеть, что мы перемещаем значение. $ 10 в показанной здесь сборке означает битовый шаблон для десятичного значения 10, загадочные имена, которым предшествует%, означает, что это имя регистра. Здесь используется rbp, который является базовым указателем или, более широко известным как указатель кадра. Базовый указатель указывает на основание текущего кадра стека. Напоминаю, что у каждой функции есть кадр стека в стеке вызовов. По сути, эта строка говорит о перемещении битовой комбинации десятичной 10 по адресу rbp-4 (вычтите 4 из rbp). Следующий вопрос должен быть о том, почему мы вычитаем 4 из rbp. Как вы, возможно, знаете, структура памяти типичной программы показана ниже.

Как видите, стек растет вниз, статически объявленные в программе данные хранятся в стеке. Поэтому при увеличении стека и установке значений мы вычитаем из rbp и сохраняем. Теперь вопрос должен заключаться в том, почему мы увеличиваем на 4. Число 4 указывает, что мы уменьшаемся на 4 байта, поскольку программа скомпилирована для 32-битной платформы, целое число занимает 4 байта. Инструкция l in movl говорит, что инструкция 32-битная. Думаю, мы полностью развенчали инструкцию. Итак, давайте посмотрим, что оптимизация -O1 делает с нашей сгенерированной сборкой.

Теперь код стал короче. Как я упоминал ранее, большая часть кода в сгенерированной сборке зависит от машины, поэтому нас практически не волнует, что будет удалено, но, как мы видим, линии, показанной на рисунке 1, больше нет. Более того, единственное, что мы делаем, это загружаем аккумулятор (%eax) со значением 0. Теперь мы можем быть уверены, что gcc действительно оптимизирует наши программы, по крайней мере, немного. Давай копнем глубже.

-O2

Для -O2 я покажу вам оптимизацию, которая называется устранением вызовов братьев и сестер. Брачный вызов - это то, что вызывается в конце функции и не имеет ничего общего с текущей функцией и ее возвращаемым значением. Например,

void f() {
   // some code
   g();
}

Давайте разберемся в этом на конкретном примере.

Как мы видим в программе 2, a() вызов b() кажется родственным вызовом. Давайте посмотрим на сборку, созданную для функции a().

Давайте уберем беспорядок и сосредоточимся на важной строке.

Но если мы исключим одноуровневые вызовы, это означает устранение вызова b(), тогда как мы напечатаем оператор, написанный на b()? Одна из идей - встроить код b(), поскольку это всего лишь одна строка. Но в нашем случае это одна строка . В реальных программах функция может состоять из 100 строк и вызываться из 100 разных мест. Если мы встроим вызов, это резко увеличит размер программы. Мы не хотим увеличивать размер программы на -O2. Так что же нам делать? Разве мы не можем позволить звонку b() быть там? Почему так плохо, что мы должны это устранить? Чтобы ответить на этот вопрос, мы понимаем, что вызовы функций действительно распространены в программах, и у нас, вероятно, тысячи из них в реальных программах, не считая кода библиотек времени выполнения и системных вызовов, которые вызываются даже чаще, чем наши функции. . Таким образом, небольшая оптимизация в этой области может в совокупности привести к значительному улучшению. Идея исключения вызова родственной функции состоит не в том, чтобы на самом деле исключить вызов функции, а в том, чтобы исключить инструкцию call. Как мы знаем, инструкция call - это специальная инструкция для вызова подпрограммы, которая выполняет большую внутреннюю работу, такую ​​как выделение кадра стека, изменение значения счетчика программ и других указателей и т. Д. call инструкция есть, все про нее можно прочитать здесь. Но в этом сценарии родственного вызова нам не нужно большинство вещей, выполняемых инструкцией вызова, и мы можем заменить ее более легкой инструкцией jmp. Именно это и делает для нас оптимизация -O2. На скриншоте ниже показан код после оптимизации.

Следует отметить, что для получения этого вывода я отключил встраивание метода с помощью -fno-inline, иначе вызов b() был бы заменен его кодом во время оптимизации. Это был лишь один пример того, как -O2 оптимизирует наш код. Вы можете прочитать полный список на странице, на которую я ссылался выше. O1 / O2 - это оптимизации, которые пытаются улучшить производительность программы без внесения значительных изменений в программный код и размер, что называется компромиссом между пространством и скоростью, как я объяснял ранее.

-O3

O3 разработан, чтобы быть агрессивным в отношении производительности. Таким образом, он не боится обмена дискового пространства на дополнительную производительность, поэтому вы можете заметить, что размер программы значительно увеличился после оптимизации -O3. В случае -O3 я покажу вам пример оптимизации под названием «Отключение циклов». Это немного сложно объяснить непосредственно через ассемблерный код, поэтому я возьму небольшой пример на C и покажу вам, как это работает. Итак, если у нас есть цикл, в котором есть условие, зависящее от переменной, которая не изменяется, мы можем проверить это условие вне цикла и на основе этого условия реплицировать код цикла так, чтобы истинная часть содержала цикл. с операторами исходного тела if, а часть else содержит цикл без операторов исходного тела if. Я знаю, что это утверждение действительно сбивает с толку и может даже быть неправильным, поскольку английский - не мой родной язык. Так что давайте просто перейдем к конкретным вопросам.

void f(int a){
   while(...){
      //Some code
      if(a > 100) printf("a is >100");
   }
}

Как мы видим, каждый раз, когда цикл запускается, проверяется условие в if, и на его основе выполняется тело if. Но проверка условия каждый раз, когда цикл повторяется, является пустой тратой ресурсов, потому что мы знаем значение a, как только функция запускается, мы также знаем, что значение a нигде внутри функции не изменяется, и мы все еще проверяем каждую итерацию if (a ›100), скорее, следует проверять только один раз при запуске функции. Таким образом, -O3 может помочь нам здесь, изменив код таким образом, чтобы условие выполнялось только один раз. Измененный код показан ниже.

//Code listing (1)
void f(int a){
   if(a > 100){
      while(...){
         //Some code
         printf("a is >100");
      }
   } else {
      while(...){
         //Some code
      }
   }
}

Как вы можете видеть в оптимизированной функции, мы переместили проверку условий на верхний уровень и проверяем ее только один раз, в зависимости от результата выполнения другого варианта цикла while. Тело исходного условия if помещается внутрь цикла, который является истинной частью оператора if. Код в части else условия не содержит тела исходного оператора if. Здесь важно отметить, что исходный код содержал цикл while только один раз в теле, но оптимизированный код имеет 2 цикла while на своем месте. Естественно, размер кода будет увеличиваться. Так что будьте осторожны при использовании этих оптимизаций. Ниже приведена анимация, показывающая эту процедуру.

Https://www.youtube.com/watch?v=oR_LJ7xTJPQ

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

Теперь давайте посмотрим, наконец, на сборку. Вот неоптимизированный код.

Я не буду подробно объяснять сборку, потому что статья уже очень длинная, а объяснение петель сборки сделает ее почти вдвое больше. Просто чтобы дать вам обзор, сначала мы выполняем некоторую работу для конкретной машины, затем устанавливаем i в 0, а затем переходим к .L2. В .L2 мы сначала сравниваем значение i с $ 9, что является битовой комбинацией 9, если оно меньше или равно, условие истинно и цикл должен продолжаться, поэтому мы используем jle для проверки и переходим к .L4 если мы должны продолжить, иначе мы вызовем leave и ret, что является return. После оптимизации сгенерированная сборка показана ниже.

Как видите, код стал настолько большим, что мне пришлось использовать возвышенное, чтобы правильно разместить его на одном экране, чтобы я мог сделать снимок экрана. С другой стороны, я могу использовать номера строк для обозначения операторов, так что давайте сделаем это. Сначала в строке 25 мы сравниваем значение переданного параметра с $ 10 (битовая последовательность десятичных 10), как я показал в code listing (1). Если он больше, мы переходим к .L2, который содержит код для printf("Separate");, чтобы быстро убедиться в этом, вы можете видеть, что код в .L2 содержит 2 вызова printf. Мы используем jg, чтобы перейти к .L2, что на самом деле означает прыжок, если он больше. Если он не больше, то мы не будем прыгать и сразу перейдем к коду в .L3, который не содержит кода printf("Separate"). Вы можете убедиться в этом, посмотрев на код, поскольку он содержит только один вызов printf. Это всего лишь несколько примеров того, как gcc оптимизирует программы.

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