Методы оптимизации программного кода кратко

Обновлено: 04.07.2024

Мы тут разбираемся в важных понятиях большой разработки:

Чтобы закрыть тему, поговорим об оптимизации.

Что такое оптимизация

Оптимизация кода — это когда программист берёт код из уже готовой и работающей программы и пытается его улучшить для какой-то цели.

Что пытаются улучшить:

  • скорость работы,
  • скорость загрузки,
  • скорость ответа сервера,
  • стабильность,
  • объём кода.

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

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

Оптимизация скорости работы

Самая частая оптимизация кода — повышение скорости работы.

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

Примеры того, как можно добиться ускорения:

  • Написать функции, которые будут предугадывать действия пользователя и заранее просчитывать некоторые ситуации.
  • Научить программу на какое-то время забирать себе все ресурсы компьютера. Остальным программам плохо, зато эта работает быстро.
  • Заменить короткие, но сложные команды фреймворка на много длинных, но более простых для компилятора, которые в сумме выполняются быстрее.
  • Написать лукап-таблицы: например, если алгоритму нужно сто тысяч раз посчитать значение синуса sin(x) с заранее известным шагом, то можно сразу написать таблицу всех нужных значений. Тогда алгоритм будет не считать синус с нуля, а заглядывать в табличку и быстро там всё находить.
  • Вставить код на ассемблере, чтобы выполнить его в процессоре напрямую, без компилятора высокого уровня — так программа будет работать намного быстрее.

Оптимизация скорости загрузки

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

Этим приёмом часто пользуется компания Apple: они делают свои программы так, чтобы пользователь видел интерфейс практически сразу после запуска, но реально работать с программой можно лишь через 1–2 секунды. Дело в том, что на первое место ставится скорость отрисовки и загрузки интерфейса, а остальные модули программы загружаются на фоне, и на это тоже нужно время. Зато выглядит всё так, как будто программа запустилась моментально.

Вторая ситуация — когда действительно увеличивается скорость загрузки всей программы. Для этого разработчики используют аппаратные возможности железа, и выносят часть функций в отдельные модули. Если они понадобятся — программа их загрузит, а если нет, то и не нужно тратить на это время при запуске.

Оптимизация скорости ответа

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

При этом такие программы могут загружаться по 20–30 секунд или даже несколько минут. Никто не ждёт от них моментальной загрузки, а вот моментальная реакция на запросы — нужна.

Оптимизация для стабильности и отказоустойчивости

Если мы пишем код для больницы, в котором в реальном времени обрабатываем жизненные показатели всех пациентов, то нам важно такое:

  • программа не должна зависать из-за неправильно введённых данных;
  • программа должна работать, даже если сервер не отвечает или сломались датчики;
  • программа не должна падать под нагрузкой.

Короче: программу должно быть очень сложно сломать.

Это значит, что нам нужно сконцентрироваться на обработке исключений, проверках введённых значений, двойных запросах для гарантированного получения точных данных. Если такая программа падает, когда отваливается одна из баз данных, — её точно нужно оптимизировать.

Оптимизация для уменьшения объёма кода

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

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

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

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

Что дальше

Теперь мы полностью готовы работать над своим старым кодом: поддерживать легаси, делать рефакторинг и оптимизировать разные функции. Сделаем всё по очереди, но уже в другой раз.


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

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

Для того чтобы сгенерировать код, который использует имеющиеся команды и ресурсы машины с наибольшей эффективностью, должны быть использованы более сложные схемы трансляции. Они называются оптимизациями, а использующие их компиляторы – оптимизирующими компиляторами. Так же важно придерживаться правила 10/90, которое гласит, что 10% времени потраченное на планирование до начала работы, экономит 90% времени при решении поставленных задач.

Архитектурный дизайн системы особенно сильно влияет на её производительность. Однако выбор алгоритма влияет на эффективность больше, чем любой другой элемент дизайна. Более сложные алгоритмы и структуры данных могут хорошо оперировать с большим количеством элементов, в то время как простые алгоритмы подходят для небольших объёмов данных – накладные расходы на инициализацию более сложного алгоритма могут перевесить выгоду от его использования [1, c.5].

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

Цель данной работы – изучить теоретические основы оптимизации программного кода.

Поставленная цель определила следующие задачи:

Изучить виды и подход к оптимизации кода.

Познакомиться с методиками оптимизации кода.

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

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

Кроме того, не существует универсального решения, которое подходило бы ко всем случаям, поэтому приходится использовать альтернативные решения, для оптимизации только ключевых параметров. Как правило, необходимые ресурсы для достижения требуемого результата, то есть получения полностью оптимальной программы, которую невозможно дальше улучшить, превышают выгоду, которую можно получить, затрачивая эти ресурсы. Именно поэтому оптимальные программы не создают просто потому, что некоторый процесс оптимизации может закончиться раньше. Как показывает практика, в большинстве случаев даже при этом достигаются значительные улучшения [2, c.153].

Встречаются ситуации, когда оптимизированный код вручную, оказывается менее эффективнее кода, сгенерированного компилятором.

Каждый этап от проектирования до оптимизации кода допускает существенное повышение производительности программного обеспечения [3, c. 576].

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

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

Оптимизация в основном фокусируется на одиночном или повторном времени выполнения, использовании памяти, дискового пространства, пропускной способности или некотором другом ресурсе. Это обычно требует компромиссов (tradeoff) – один параметр оптимизируется за счёт других. Например, увеличение размера программного кэша чего-либо улучшает производительность времени выполнения, но также увеличивает потребление памяти. Другие распространённые компромиссы включают прозрачность кода и его выразительность, почти всегда ценой деоптимизации. Сложные специализированные алгоритмы требуют больше усилий по отладке и увеличивают вероятность ошибок.

Оптимизацию производительности следует отличать от рефакторинга. Цель рефакторинга – сделать код программы более легким для понимания. Как и оптимизация, рефакторинг обычно не изменяет поведение программы. Но оптимизация часто затрудняет понимание кода, что противоположно рефакторингу.

ВИДЫ ОПТИМИЗАЦИИ ПРОГРАММНОГО КОДА

Оптимизация кода может проводиться, как и вручную, программистом, так и автоматизировано. В последнем случае оптимизатор может быть, как отдельным программным средством, так и быть встроенным в компилятор [4, c.3].

Хороший оптимизирующий компилятор может повысить быстродействие кода на 40 и более процентов, тогда как многие из методик, используемых программистом вручную, только на 15-30%.

Существуют такие понятия как высокоуровневая и низкоуровневая оптимизация. Высокоуровневые оптимизации в большинстве проводятся программистом, который, оперируя абстрактными сущностями (функциями, процедурами, классами и т.д.) и представляя себе общую модель решения задачи, может оптимизировать дизайн системы. Оптимизации на уровне элементарных структурных блоков исходного кода (циклов, ветвлений и т.д.) тоже обычно относят к высокому уровню; некоторые выделяют их в отдельный ("средний") уровень (Н. Вирт). Низкоуровневая оптимизация производится на этапе превращения исходного кода в набор машинных команд, и зачастую именно этот этап подвергается автоматизации. Впрочем, программисты на ассемблере считают, что никакая машина не превзойдет в этом хорошего программиста (при этом все согласны, что плохой программист сделает еще хуже машины) [5].

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

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

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

Подход выполнения оптимизации по мере написания кода, имеет массу недостатков:

• До создания полностью работоспособной программы найти узкие места в коде почти невозможно. Очень трудно догадаться, на какой участок кода приходится 50% времени выполнения, поэтому, оптимизируя код по мере написания, тратиться много времени на оптимизацию кода, который не нуждается в ней. А на оптимизацию по-настоящему важных участков времени не остается.

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

• Корректность, сокрытие информации, удобочитаемость становятся вторичными целями, хотя улучшить их потом сложнее, чем производительность.

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

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

ПОДХОД К ОПТИМИЗАЦИИ ПРОГРАММНОГО КОДА

Рассматривая целесообразность оптимизации кода, надо придерживаться следующего алгоритма [3, c.591]:

Написать хороший и понятный код, поддающийся легкому изменению

Если производительность не устраивает:

Оценить производительность системы с целью нахождения горячих точек

Выяснить, обусловлено ли плохое быстродействие неадекватным проектом, неверными типами данных или неудачным алгоритмами и определить, уместна ли оптимизация кода, если оптимизация кода неуместна, вернуться к п.1

Оптимизировать узкое место, определенное на этапе (с)

Оценить каждое улучшение.

Если оптимизация не привела к улучшению кода, вернуться к коду, сохраненному на этапе (а) (как правило, более чем в половине случаев попытки оптимизации будут приводить лишь к незначительному повышению производительности или к ее снижению)

Повторить процесс, начиная с п.2.

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

Таким образом, не стоит забывать проводить оптимизацию кода, по возможности применяя специализированные программные средства, но это следует делать аккуратно и с осторожностью, а иногда и приготовиться к неожиданностям от компилятора.

МЕТОДИКИ ОПТИМИЗАЦИИ КОДА

Не существует настолько общих методик, что бы можно было их применить для каждого кода. Однако ряд видов оптимизации кода можно, приспособить к конкретной задаче [6, c.79].

4.1 Логические выражения

Рассмотрим эффективное использование логических выражений.

• Прекращение проверки сразу же после получения ответа

• Использование констант корректного типа

Используйте именованные константы и литералы, имеющие тот же тип, что и переменные, которым они присваиваются. Если константа и соответствующая ей переменная имеют разные типы, перед присвоением константы переменной компилятор должен будет выполнить преобразование типа.

Чуть ниже в таблице 4.4 указаны различия во времени инициализации переменных.

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

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

Виды оптимизации

Оптимизация кода может проводиться как и вручную, программистом, так и автоматизированно. В последнем случае оптимизатор может быть как отдельным программным средством, так и быть встроенным в компилятор (т.н. оптимизирующий компилятор). Кроме того, следует отметить, что современные процессоры могут оптимизировать порядок выполнения инструкций кода.

Существуют такие понятия как высокоуровневая и низкоуровневая оптимизация. Высокоуровневые оптимизации в большинстве проводятся программистом, который, оперируя абстрактными сущностями (функциями, процедурами, классами и т.д.) и представляя себе общую модель решения задачи, может оптимизировать дизайн системы. Оптимизации на уровне элементарных структурных блоков исходного кода (циклов, ветвлений и т.д.) тоже обычно относят к высокому уровню; некоторые выделяют их в отдельный ("средний") уровень (Н. Вирт?). Низкоуровневая оптимизация производится на этапе превращения исходного кода в набор машинных команд, и зачастую именно этот этап подвергается автоматизации. Впрочем, программисты на ассемблере считают, что никакая машина не превзойдет в этом хорошего программиста (при этом все согласны, что плохой программист сделает еще хуже и машины).

Выбор оптимизируемого участка

При оптимизации кода вручную существует еще одна проблема: нужно знать не только, каким образом проводить оптимизацию, но и в каком месте её применить. Обычно из-за разных факторов (медленные операции ввода, разница в скорости работы человека-оператора и машины и т.д.) лишь 10% кода занимают целых 90% времени выполнения (конечно, утверждение довольно умозрительно, и имеет сомнительное основание в виде закона Парето, однако выглядит довольно убедительно у Э. Таненбаума). Так как на оптимизацию придется расходовать дополнительное время, поэтому вместо попыток оптимизации всей программы лучше будет оптимизировать эти "критичные" ко времени выполнения 10%. Такой фрагмент кода называют узким местом или бутылочным горлышком (bottleneck), и для его определения используют специальные программы - профайлеры, которые позволяют замерять время работы различных частей программы.

Вред и польза оптимизаций

Практически ко всему в программировании надо относиться рационально, и оптимизации - не исключение. Считается, что неопытный программист на ассемблере обычно пишет код, который в 3-5 раз медленнее, чем код, сгенерированный компилятором (Зубков). Широко известно выражение по поводу ранних, довольно низкоуровневых (вроде борьбы за лишний оператор или переменную) оптимизаций, сформулированное Кнутом: "Преждевременная оптимизация — это корень всех бед".

К оптимизациям, проводимым оптимизатором, у большинства нет претензий, причем иногда некоторые оптимизации являются практически стандартными и обязательными - например, оптимизация хвостовой рекурсии в функциональных языках (Хвостовая рекурсия - частный вид рекурсии, который может быть приведен к виду цикла).

Однако следует понимать, что многочисленные сложные оптимизации на уровне машинного кода могут сильно замедлить процесс компиляции. Причем выигрыш от них может быть чрезвычайно мал по сравнению с оптимизациями общего дизайна системы (Вирт). Также не следует забывать, что современные, "навороченные" синтаксически и семантически языки имеют множество тонкостей, и программист, который их не учитывает, может быть удивлен последствиями оптимизации.

Например, рассмотрим язык Си++ и т.н. Return-Value Optimization, суть которой в том, что компилятор может не создавать копии возвращаемого функцией временного объекта. Так как в этом случае компилятор "пропускает" копирование, этот прием также называется "Copy elision". Итак, следующий код:

может иметь несколько вариантов вывода:

Как ни странно, все три варианта являются законными, так как в стандарте языка позволяется пропускать вызов копирующего конструктора в данном случае, даже если у конструктора есть побочные эффекты (§12.8 Копирование объектов класса, пункт 15).

Таким образом, не стоит забывать проводить оптимизацию кода, по возможности применяя специализированные программные средства, но это следует делать аккуратно и с осторожностью, а иногда и приготовиться к неожиданностям от компилятора.

PVS-Studio

В статическом анализаторе PVS-Studio реализован набор диагностик, позволяющих обнаруживать некоторые ситуации, когда код может быть оптимизирован. Однако PVS-Studio, как и любой статический анализатор, не может выступать в качестве замены инструментов профилирования. Выявить узкие места могут только динамические анализаторы программ. Статические анализаторы не знают, какие входные данные получают программы и как часто выполняется тот или иной участок кода. Поэтому мы и говорим, что анализатор предлагает выполнить некоторые "микро оптимизации" кода, которые вовсе не гарантируют прироста производительности.

Несмотря на рассмотренный недостаток, анализатор PVS-Studio может выступать хорошим дополнением к инструментам профилирования. Более того, при работе с предупреждениями PVS-Studio, касающимися оптимизации, код часто становится проще и короче. Более подробно этот эффект рассмотрен в статье "Поговорим о микрооптимизациях на примере кода Tizen".

Высокоуровневые языки программирования предлагают множество абстрактных программных конструкций, таких как функции, условные выражения и циклы, которые удивительно повышают производительность нашего труда. Однако один из недостатков написания кода на высокоуровневом языке программирования — потенциально возможное значительное снижение скорости работы программы. В идеале, вы должны писать понятный, простой в сопровождении код, не жертвуя производительностью. По этой причине компиляторы пытаются автоматически оптимизировать код для повышения его быстродействия, и в наши дни они стали весьма изощренными в оптимизациях. Компиляторы могут преобразовывать циклы, условные выражения и рекурсивные функции, исключать целые блоки кода и использовать преимущества архитектуры набора инструкций (instruction set architecture, ISA) целевого процессора, чтобы сделать код быстрым и компактным. Гораздо лучше сосредоточиться на написании понятного кода, чем пытаться делать оптимизации вручную, которые приводят к криптографическому коду, трудному в сопровождении. По сути, ручная оптимизация кода может не дать компилятору выполнить дополнительные или более эффективные оптимизации.

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

Эта статья посвящена оптимизациям, выполняемым компилятором Visual C++. Я намерен обсудить самые важные методы оптимизации и решения, которые должен принимать компилятор, чтобы применить их. Моя цель не в том, чтобы рассказать вам об оптимизации кода вручную, а в том, чтобы показать, почему вы можете довериться компилятору в оптимизации кода в ваших интересах. Эта статья ни в коей мере не претендует на полный анализ оптимизаций, выполняемых компилятором Visual C++. Однако она демонстрирует оптимизации, о которых вы должны знать, и то, как взаимодействовать с компилятором для их применения.

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

Определение оптимизаций кода компилятором

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

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

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

Цель этой статьи — показать, почему вы можете доверять компилятору, продемонстрировав оптимизации, которые выполняются над неэффективным, но читаемым кодом (с применением первого метода). Кроме того, я дам краткое введение в оптимизацию на основе профиля и упомяну некоторые из директив компилятора, позволяющих вам более тонко оптимизировать некоторые части своего кода.

Генерация кода на этапе связывания

Генерация кода на этапе связывания (Link-Time Code Generation, LTCG) — это метод выполнения полных оптимизаций программы (whole program optimizations, WPO), написанной на C/C++. Компилятор C/C++ транслирует каждый файл исходного кода раздельно и создает соответствующий объектный файл. То есть компилятор может применять оптимизации только к индивидуальному файлу исходного кода, а не ко всей программе. Однако некоторые важные оптимизации могут быть выполнены лишь при анализе полной программы. Вы можете применять эти оптимизации в период связывания (link time), а не при компиляции, так как компоновщик (linker) имеет полное представление о программе.

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

Блок предварительной обработки на самом деле выполняется некоторые оптимизации, например свертывание констант, независимо от того, включены или отключены оптимизации. Однако все важные оптимизации выполняются блоком окончательной обработки компилятора, и их можно контролировать с помощью ключей компилятора.

Я создам программу, состоящую из двух файлов исходного кода (source1.c и source2.c) и заголовочного файла (source2.h). Файлы source1.c и source2.c показаны на рис. 1 и 2 соответственно. Заголовочный файл, который содержит прототипы всех функций в source2.c, довольно прост, поэтому я не стану приводить его здесь.

Рис. 1. Файл source1.c

Рис. 2. Файл source2.c

Файл source1.c содержит две функции: square (принимает целое значение и возвращает результат его возведения в квадрат) и main (главная функция программы). Функция main вызывает функцию square и все функции из source2.c, кроме isPrime. Файл source2.c содержит пять функций: cube (возвращает результат возведения в куб переданного целого значения), sum (возвращает сумму всех целых значений от 1 до переданного значения), sumOfcubes (возвращает сумму кубов всех целых значений от 1 до переданного значения), isPrime (определяет, является ли данное целое значение простым) и getPrime (возвращает x-ое простое число). Я опустил проверку ошибок, потому что она не представляет интереса в этой статье.

Код прост, но полезен. В нем есть ряд функций, выполняющих простые вычисления, а некоторые нужны лишь для создания циклов. Функция getPrime самая сложная, потому что она содержит цикл while, из которого вызывается функция isPrime, тоже содержащая цикл. Я буду использовать этот код для демонстрации одной из важнейших оптимизаций (подстановки функций) и некоторых других оптимизаций.

Я буду компилировать этот код в трех разных конфигурациях и изучать результаты, чтобы определить, как он был преобразован компилятором. Если вы будете следовать за мной, вам понадобится ассемблерный выходной файл (создается ключом /FA[s] компилятора) для анализа полученного ассемблерного кода и файл сопоставлений (map file) (создается ключом /MAP компоновщика) для определения выполненных оптимизаций COMDAT (компоновщик тоже может сообщать об этом, если вы используете ключи /verbose:icf и /verbose:ref). Поэтому убедитесь в том, что эти ключи указываются во всех рассматриваемых далее конфигурациях. Кроме того, я буду использовать компилятор C (/TC), чтобы облегчить анализ сгенерированного кода. Однако все, что обсуждается здесь, применимо и к коду на C++.

Конфигурация Debug

Конфигурация Debug используется в основном потому, что все оптимизации стадии окончательной обработки отключаются при задании ключа /Od компилятора без ключа /GL. При компиляции кода в такой конфигурации получаемые объектные файлы будут содержать двоичный код, который точно соответствует исходному коду. Чтобы убедиться в этом, вы можете проанализировать полученные ассемблерные выходные файлы и файл сопоставлений. Эта конфигурация эквивалентна отладочной конфигурации в Visual Studio.

Конфигурация Compile-Time Code Generation Release

Эта конфигурация похожа на конфигурацию Release, при которой оптимизации включены (задан ключ /O1, /O2 или /Ox компилятора), но ключ /GL компилятора не указан. При такой конфигурации полученные объектные файлы будут содержать оптимизированный двоичный код. Однако оптимизации на уровне всей программы не выполняются.

Изучая сгенерированный из source1.c файл листинга ассемблерного кода, вы заметите, что выполнены две оптимизации. Прежде всего первый вызов функции square — square(n) на рис. 1 — полностью исключен за счет оценки вычисления при компиляции. Как это произошло? Компилятор определил, что функция square компактна, поэтому нужно подставить ее тело в код вместо вызова. После этого компилятор определил, что значение локальной переменной n известно и не изменяется между оператором присваивания и вызовом функции. Поэтому он заключил, что можно безопасно выполнять умножение и подставить результат (25). При второй оптимизации второй вызов функции square — square(m) — тоже был заменен телом функции. Однако, поскольку значение m не известно при компиляции, компилятор не мог оценить вычисление и сгенерировал реальный код.

Конфигурация Link-Time Code Generation Release

Конфигурация LTCG Release идентична конфигурации Release в Visual Studio. В этой конфигурации оптимизации включены и указывается ключ /GL компилятора. Этот ключ неявно задается при использовании /O1 или /O2. Он сообщает компилятору генерировать объектные файлы CIL, а не ассемблерные объектные файлы. Благодаря этому компоновщик вызывает блок окончательной обработки компилятора для выполнения WPO, как было описано ранее. Теперь я рассмотрю несколько оптимизаций WPO, чтобы показать колоссальные преимущества LTCG. Листинги ассемблерного кода, генерируемые при этой конфигурации, можно скачать как пакет, сопутствующий этой статье.

Пока подстановка функций включена (/Ob, который включается всякий раз, когда вы запрашиваете оптимизации), ключ /GL позволяет компилятору подставлять функции, определенные в других блоках трансляции, независимо от того, указан ли ключ /Gy компилятора (о нем чуть позже). Ключ /LTCG компоновщика не обязателен и обеспечивает управление только компоновщиком.

В идеале, разработчики предпочитают писать понятный, простой в сопровождении код, не жертвуя производительностью.

Изучая ассемблерный листинг для файла source1.c, вы заметите, что все вызовы функций, кроме scanf_s, были заменены телами этих функций. В итоге компилятор смог выполнить вычисления cube, sum и sumOfCubes. Не была подставлена только функция isPrime. Однако, если бы она была подставлена вручную в getPrime, компилятор все равно сумел бы подставить getPrime в main.

Ввиду важности подстановки компилятор Visual C++ обеспечивает гораздо больше поддержки для этой оптимизации, чем того требует стандарт в отношении контроля за подстановкой. Вы можете сообщить компилятору никогда не подставлять некий диапазон функций с помощью директивы auto_inline. Или указать компилятору никогда не подставлять конкретную функцию (метод), пометив ее как __declspec(noinline). Вы можете пометить функцию ключевым словом inline, чтобы подсказать компилятору, что эту функцию следует подставлять (хотя компилятор может проигнорировать эту подсказку, если подстановка окажется чистой потерей). Ключевое слово inline доступно еще с первой версии C++ — оно было введено в C99. В коде на C и C++ можно использовать специфичное ключевое слово __inline от Microsoft; оно полезно, когда вы применяете старую версию C, которая не поддерживает ключевое слово inline. Более того, вы можете указать ключевое слово __forceinline (в C и C++), чтобы заставить компилятор по возможности всегда подставлять функцию. Наконец, можно сообщить компилятору раскрыть рекурсивную функцию либо до конкретной, либо до неопределенной глубины ее подстановкой с помощью директивы inline_recursion. Заметьте, что компилятор в настоящее время не предлагает никаких средств, которые позволяли бы вам управлять подстановкой в точке вызова, а не в определении функции.

Ключ /Ob0 полностью отключает подстановку. Вы должны использовать этот ключ при отладке (он автоматически указывается в конфигурации Debug в Visual Studio). Ключ /Ob1 сообщает компилятору рассматривать для подстановки только те функции, которые помечены ключевым словом inline, __inline или __forceinline. Ключ /Ob2, который вступает в действие при задании /O[1|2|x], информирует компилятор рассматривать для подстановки любую функцию. На мой взгляд, единственная причина использовать ключевое слово inline или __inline — управление подстановкой с помощью ключа /Ob1.

Компилятор не сможет подставить функцию в определенных условиях. Один из примеров — виртуальный вызов виртуальной функции; такую функцию нельзя подставить, потому что компилятору может быть не известно, какая именно функция будет вызываться. Другой пример — вызов функции по указателю на функцию, а не по имени. Если вы хотите в максимальной мере использовать подстановку, избегайте таких условий. Полный список этих условий изложен в документации MSDN.

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

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

Чтобы включить эти оптимизации от компоновщика, можно сообщить компилятору упаковать функции и переменные в разные разделы, указав ключи /Gy (связывание на уровне функций) и /Gw (оптимизация глобальных данных). Такие разделы называются COMDAT. Вы также можете пометить конкретную глобальную переменную ключевым словом __declspec(selectany), чтобы сообщить компилятору упаковать переменную в COMDAT. Затем вы указываете ключ /OPT:REF компоновщику, и он будет исключать функции и глобальные переменные, не имеющие ссылки (unreferenced). Кроме того, при задании ключа /OPT:ICF компоновщик будет свертывать идентичные функции и глобальные переменные — константы. (ICF расшифровывается как Identical COMDAT Folding.) Ключ /ORDER указывает компоновщику помещать COMDAT'ы в конечный образ в определенном порядке. Заметьте, что все эти оптимизации выполняются компоновщиком и не требуют ключа /GL компилятора. Ключи /OPT:REF и /OPT:ICF следует отключать при отладке по очевидным причинам.

По возможности всегда используйте LTCG. Единственная причина не использовать LTCG — вы хотите распространять созданные объектные и библиотечные файлы. Вспомним, что эти файлы содержат CIL-код, а не ассемблерный код. CIL-код может использоваться компилятором и/или компоновщиком только той версии, с помощью которой он был сгенерирован, что может существенно ограничить область применения объектных файлов: у разработчиков должна быть та же версия компилятора, чтобы они смогли задействовать эти файлы. В этом случае, если только вы не собираетесь распространять объектные файлы для каждой версии компилятора, следует использовать генерацию кода этапа компиляции (compile-time code generation, CTCG). Помимо ограниченного применения, эти объектные файлы во много раз объемнее, чем соответствующие ассемблерные объектные файлы. Однако не забывайте о колоссальном преимуществе объектных CIL-файлов, которые поддерживают оптимизацию WPO.

Оптимизации циклов

Компилятор Visual C++ поддерживает несколько оптимизаций циклов, но я рассмотрю только три из них: развертывание циклов (loop unrolling), автоматическая векторизация (automatic vectorization) и выделение из цикла кода неизменяемых выражений (инвариантов) (loop-invariant code motion). Если вы модифицируете код на рис. 1 так, чтобы в sumOfCubes передавалась m вместо n, то компилятор не сможет определить значение параметра, поэтому он должен компилировать функцию для обработки любого аргумента. Полученная функция высоко оптимизирована и ее размер довольно велик, а значит, компилятор не станет подставлять ее.

Компиляция кода с ключом /O1 приводит к созданию ассемблерного кода, оптимизированного по размеру. В этом случае к функции sumOfCubes никакие оптимизации не применяются. Компиляция с ключом /O2 дает код, оптимизированный по скорости работы. Размер кода будет значительно больше, но сам код будет работать существенно быстрее, так как цикл в sumOfCubes развернут и векторизован. Важно понимать, что векторизация была бы невозможна без подстановки функции cube. Более того, без подстановки функций развертывание циклов было бы не столь эффективным. Упрощенное графическое представление создаваемого ассемблерного кода показано на рис. 3. Схема потока управления одинакова для архитектур x86 и x86-64.

Схема потока управления в sumOfCubes


Рис. 3. Схема потока управления в sumOfCubes

Выражаю благодарность за рецензирование статьи эксперту Microsoft Джиму Хоггу (Jim Hogg).

Читайте также: