Введение в компиляторные оптимизации
В этой лекции вы узнаете о часто используемых компиляторных оптимизациях, которые используются для уменьшения размера кода и повышения производительности, а также о различных инструментах и методах профилирования.
Современные оптимизирующие компиляторы, такие как GCC и LLVM, предоставляют широкий спектр методов оптимизации и инструментирования. В то время как компиляторная оптимизация может повысить производительность или уменьшить размер кода, различные методы инструментирования могут помочь понять внутреннее устройство системы. Широко используемые уровни оптимизации достаточно хороши для большинства популярных аппаратных и программных экосистем. Однако требования современных систем, таких как приложения RISC-V, работающих на устройствах IoT, недостаточно изучены в отрасли. В результате такие системы часто неэффективно оптимизируются с настройками компилятора по умолчанию. Понимание популярных методов оптимизации и инструментирования позволит рассмотреть альтернативы, при попытке повысить производительность, уменьшить размер кода или повысить безопасность приложения.
В этой лекции вы узнаете, как использовать популярные методы компиляции для:
- улучшения производительности приложений;
- улучшения методов инструментирования;
- уменьшения размера кода;
- улучшения характеристик производительности некоторых встраиваемых приложений.
Терминология
Прежде чем познакомиться с различными методами оптимизации, кратко ознакомимся с терминологией, которая будет часто использоваться в дальнейшем. Приведенные ниже термины предназначены только для помощи в понимании курса и не должны рассматриваться как строгие определения.
Производительность
Говоря о производительности приложения, мы обычно имеем в виду, сколько времени требуется для выполнения определенной задачи. Приложение должно выполнять задачи в разумные сроки, чтобы быть практически полезным. Во многих случаях мы хотим, чтобы приложения работали как можно быстрее. Есть разные способы повысить производительность приложений, один из них - использование компиляторных оптимизаций, мы обсудим в этой главе. Следует отметить, что не все части программы должны быть производительными, чтобы их можно было использовать на практике. Только определенные части, которые часто называют "узким местом" (ограничение, при котором теряется доля производительности или пропускной возможности системы), должны быть максимально производительными. Подробнее с темой производительности систем можно ознакомиться по ссылкам в справочном разделе.
Компиляторные оптимизации
Компиляторы выполняют ряд преобразований исходного кода. Хотя некоторые преобразования необходимы для генерации машинного кода, большинство преобразований выполняется для повышения производительности программ или уменьшения размера кода. Эти преобразования называются оптимизациями компилятора. В данной главе представлены оба типа оптимизации. Цель этой главы - дать учащимся возможность эффективно использовать оптимизации. Мы не обсуждаем, как эти оптимизации реализованы в компиляторах.
Инструментирование
Когда компилятор преобразует исходный код, он также может встраивать дополнительный код в программу. Эти преобразования называются инструментированием. Оно используется во многих случаях, одна из распространенных целей - сбор профиля времени выполнения программы. Чтобы собрать профиль времени выполнения, компилятор должен вставить счётчики в определенные части программы, и эти счётчики будут увеличиваться каждый раз, когда выполнение программы достигает места инструментирования. После завершения программы счётчики можно использовать для понимания профиля производительности. Самые ресурсоёмкие части программы наиболее интересны инженерам по производительности.
Флаги компилятора
Промышленные компиляторы, такие как GCC и LLVM, имеют сотни флагов, которые влияют на поведение компилятора. Существует множество флагов компилятора, и нет простого способа их классификации. Но для простоты мы попытаемся классифицировать флаги, чтобы облегчить понимание различных типов флагов:
-
Флаги оптимизации
Такие флаги, как -O2, -O3, -funroll-loops можно отнести к флагам оптимизации, поскольку они указывают компилятору, какие оптимизации следует выполнить.
-
Диагностические флаги
Такие флаги, как -Wall, -Werror, -Wnull-dereference влияют на диагностические выходные данные компилятора.
-
Настройка параметров
Флаги типа --param max-inline-insns-small=70 принимают разные значения, часто числовые, чтобы настроить, какая часть конкретной оптимизации будет выполнена.
-
Флаги инструментирования
Такие флаги, как -finstrument-function, -profile-generate, включают инструментирование. Инструментированный двоичный файл будет собирать профили времени выполнения, которые могут помочь с оптимизацией, обнаружением ошибок и т. д.
-
Флаги компоновщика
Такие флаги, как -lpthread, который используется компоновщиком для поиска определений символов, принятия решений по оптимизации и т. д.
-
Флаги, предоставляющие значение
Такие флаги, как -D, -fprofile-use, -stdlib=libstdc++, предоставляют компилятору дополнительные входные данные, которые могут помочь в оптимизации, диагностике, инструментировании и т. д.
Оптимизация производительности
Компиляторы предлагают различные оптимизации для повышения производительности и/или уменьшения размера кода. Набор оптимизаций компилятора объединяется в зонтичных флагах компилятора, называемых "уровнями оптимизации". Ниже представлены уровни оптимизации, распространенные среди большинства компиляторов:
-O0
Это тривиальный случай, когда оптимизация компилятора не выполняется. Тем не менее оптимизация для конкретного языка в соответствии с требованиями стандарта по-прежнему выполняется. Например, вычисления во время компиляции, требуемые стандартом C++, по-прежнему выполняются. Этот уровень очень полезен для целей отладки в сочетании с флагом компилятора -g. Поскольку -O0 не выполняет оптимизацию, время компиляции является самым быстрым, что весьма полезно для итеративной разработки.
-O1
На этом уровне включается множество оптимизаций, повышающих производительность программы. Например, развертывание циклов, встраивание функций, планирование инструкций и т. д. Этот уровень оптимизации используется редко, поскольку сейчас доступны более агрессивные уровни оптимизации.
-O2
Это один из самых популярных уровней оптимизации. Он позволяет использовать все оптимизации -O1, а также более агрессивные оптимизации в распределении регистров, планировании инструкций, частичном устранении избыточности и т. д. Этот уровень используется при построении кода с преобладанием ветвлений, например, операционных систем.
-O3
Этот уровень включает в себя все возможности -O2, а также некоторые современные оптимизации, такие как векторизация. -O3 является фактическим уровнем оптимизации для максимизации производительности большинства приложений. -O3 также используется для тестов производительности, поскольку в нем присутствуют все "проверенные в боях" оптимизации компилятора.
-Ofast
Это просто -O3 с -ffast-math. Флаг -ffast-math указывает компилятору ослабить некоторые требования1Подробнее об арифметике с плавающей запятой можно узнать в стандарте IEEE 754. арифметики с плавающей запятой, такие как ассоциативность и коммутативность. Во многих приложениях ошибки, возникающие после ослабления этих требований, вполне допустимы за счёт более высокой производительности. Без -ffast-math многие циклы с операциями с плавающей точкой не могут быть векторизованы.
-Os
-Os оптимизирует размер кода. Таким образом, большинство оптимизаций, увеличивающих размер кода, будут менее агрессивными на этом уровне. Это популярная оптимизация среди встраиваемых систем и мобильных приложений, поскольку размер кода там является большой проблемой.
-g
Чтобы иметь возможность отлаживать приложение с аннотациями исходного кода, компилятор должен предоставить дополнительную информацию в двоичном файле. Флаг -g указывает компилятору сделать это. Без этого флага отладчик будет показывать только имена глобальных символов и дизассемблер, поскольку он не может связать строку исходного кода со сборкой.
-finstrument-functions
Этот флаг используется для инструментирования входа и выхода функций. Инструментирование позволяет получить представление о поведении программ. При использовании этого флага также необходимо определить две функции __cyg_profile_func_enter и __cyg_profile_func_exit, которые вызываются соответственно при входе и выходе из каждой вызываемой функции. Если есть функции, которые не должны быть инструментированы, к ним можно добавить __attribute__ ((no_instrument_function)).
-fprofile-generate, -fprofile-arcs, -pg
Эти флаги используются для инструментирования программ с целью сбора профилей времени выполнения различных точек программы. Это позволяет компилятору проводить оптимизацию с учетом профиля в последующих компиляциях. В зависимости от того, какие флаги вы используете, могут быть достигнуты различные типы инструментирования. Подробный обзор различных флагов приведен на странице руководства gcc(1) - Linux manual page.
-fstack-protector, -fstack-protector-all, -fstack-protector-strong
Эти опции инструментируют уязвимые функции путем вставки защитных переменных в кадр стека. Перед возвратом функции проверяется, что защитная переменная не была перезаписана, что позволяет убедиться в том, что стек не был поврежден. Это тривиальный способ улучшить защиту от атаки на переполнение буфера. Однако это может увеличить размер кода приложения. В случае, если это создает накладные расходы, с этим флагом можно компилировать только критически важные для безопасности части приложения. Более подробную информацию об использовании этого флага можно найти здесь.