Введение в компиляторные оптимизации
Profile-Guided Optimization
Зная частоту выполнения различных точек программы, компилятор может принимать более хорошие решения по оптимизации в процессе компиляции. Многие оптимизации компилятора основаны на жестко закодированных эвристиках и статическом анализе программы. Эти эвристики могут быть не идеальны для разных точек программы и часто приводят к потере производительности. Компилятор имеет возможность учитывать поведение программы во время выполнения, читая "файл покрытия". Файл покрытия - это, по сути, гистограмма частот выполнения различных точек программы. Для создания файла покрытия существует две методики, которые подробно описаны ниже.
Использование инструментирования
Компиляторы могут вставлять "счётчики" в интересующие точки программы для сбора профилей исполнения. Код инструментируется путем передачи компилятору команды -fprofile-generate. Пример использования:
gcc -O2 -fprofile-generate=/path/to/outputfile test.c -o a.out
Благодаря инструментированию само приложение затем будет регистрировать события/счётчики, которые могут быть использованы компилятором во время следующей компиляции. После завершения работы программы в каталоге /path/to/outputfile/ будет создан файл с расширением .gcda. Затем перекомпиляция приложения с помощью -fprofile-use=/path/to/outputfile приведет к созданию оптимизированной программы.
gcc -O2 -fprofile-use=/path/to/outputfile test.c -o b.out
b.out оптимизируется с помощью собранной на первом шаге информации о профиле. Компилятор часто оптимизирует размещение кода, вставку функций и циклы с учетом информации профиля. Обычно при использовании PGO (Profile-Guided Optimization) наблюдается повышение производительности более чем на 10%.
Использование семплирующих профилировщиков
Семплирующие профилировщики, такие как Linux perf, используют аппаратные счётчики для регистрации определенных событий во время выполнения программы. Программы можно профилировать как с самого начала, так и во время их выполнения. Это делает семплирующий профилировщик удобным для непрерывного профилирования. Накладные расходы таких профилировщиков довольно малы по сравнению с традиционным PGO, поэтому данный подход масштабируется на большое количество систем. Ниже приведен типичный сценарий использования:
perf record -b ./a.out create_gcov --binary=./a.out --profile=perf.data --gcov=a.gcov -gcov_version=1 gcc -O3 -fauto-profile=a.gcov test.c -o b.out
b.out оптимизирован с использованием информации о профиле выборки. create_gcov - это инструмент, который преобразует perf.data в файл покрытия в формате gcov. Инструмент perf имеет множество опций для записи различных аппаратных событий. Следует отметить, что не все события поддерживаются всем оборудованием, и не все функциональные возможности Linux perf поддерживаются в RISC-V.
Соображения при использовании PGO
GO на основе инструментирования в целом прост в использовании, существуют определенные недостатки:
- Инструментирование программы компилятором влияет на некоторые оптимизации.
- Инструментированная программа работает медленнее, поэтому ее нельзя развернуть на большом количестве систем. Из-за этого собранный профиль может быть не очень качественным.
Эти недостатки могут касаться не всех систем, поэтому всегда следует взвешенно оценивать, какие технологии PGO использовать. Качество оптимизации с помощью профиля зависит от тестовых векторов, по которым программа выполнялась при сборе профиля. Но даже при ограниченных тестовых сценариях в некоторых случаях все равно выгодно проводить PGO, например, последовательность запуска программы не сильно меняется даже при очень разных тестовых сценариях.
В дополнение к этому, может возникнуть проблема переобучения в некоторых точках программы, если охват во время сбора профиля был недостаточным. При переоснащении программа может плохо работать в определенных случаях. Чтобы преодолеть это, желательно выполнять непрерывное профилирование и компиляцию. Эта проблема менее актуальна для систем, где программы не часто меняются.
Полезные материалы
Оптимизация объема кода
Размер кода встраиваемого ПО был проблемой в течение очень долгого времени. В то время как хранилища становятся все дешевле и меньше, разработчики находят творческие способы увеличить размер кода за счёт добавления функций или ненужной программной инженерии. Компиляторы прошли долгий путь в оптимизации приложений по размеру кода. В то время как большинство оптимизаций компиляторов были направлены на производительность приложений, в последние годы мы наблюдаем рост оптимизаций размера кода. В этом разделе мы познакомимся с широко используемыми методами уменьшения размера кода приложений. Этот раздел состоит из трех частей:
- Методы измерения: инструменты для измерения размера двоичного файла.
- Оптимизация компилятора: флаги компилятора, которые могут помочь уменьшить размер двоичных файлов приложений.
- Оптимизация исходного кода: методы разработки программного обеспечения для уменьшения размера двоичных файлов приложений.
Измерение размера кода и различных секций
Существует три популярных инструмента для измерения размера кода двоичного файла.
- size: GNU Binutils
- strings: GNU Binutils
- Bloaty
Size
Утилита size может показать размер каждой секции двоичного файла.
size gcc/11/libstdc++.dylib __TEXT __DATA __OBJC others dec hex 1703936 65536 0 1851392 3620864 374000
Strings
Показывает все строки в двоичном файле.
strings gcc/11/libstdc++.dylib | wc -l 2180
Bloaty
Этот инструмент может быть использован для более глубокого анализа двоичных файлов различных платформ. Он также сопоставляет исходным файлам их размеры в скомпилированном виде.
bloaty gcc/11/libstdc++.dylib FILE SIZE VM SIZE -------------- -------------- 29.1% 1.00Mi 29.0%. 1.00Mi __TEXT,__text 25.0% 882Ki 25.0% 882Ki String Table 16.6% 583Ki 16.5% 583Ki Symbol Table 12.3% 433Ki 12.2% 433Ki __TEXT,__eh_frame 5.0% 176Ki 5.0% 176Ki Export Info 4.1% 146Ki 4.1% 146Ki __TEXT,__const 2.5% 87.8Ki 2.5% 87.8Ki Weak Binding Info 1.2% 41.6Ki 1.2% 41.6Ki __DATA,__gcc_except_tab 1.0% 36.9Ki 1.0% 36.9Ki __DATA_CONST,__const 0.9% 33.3Ki 0.9% 33.3Ki __TEXT,__text_cold 0.5% 16.1Ki 0.5% 16.1Ki [10 Others] 0.5% 15.9Ki 0.0% 945 [__DATA] 0.4% 15.0Ki 0.4% 15.0Ki __TEXT,__cstring 0.0% 4 0.3% 11.3Ki [__LINKEDIT] 0.0% 0 0.2% 8.12Ki __DATA,__bss 0.2% 8.01Ki 0.2% 8.01Ki [__DATA_CONST] 0.2% 7.43Ki 0.2% 7.43Ki Function Start Addresses 0.0% 0 0.2% 6.88Ki __DATA,__common 0.2% 6.08Ki 0.2% 6.08Ki Indirect Symbol Table 0.1% 4.59Ki 0.1% 4.59Ki __DATA,__la_symbol_ptr 0.1% 3.44Ki 0.1% 3.44Ki __TEXT,__stubs 100.0% 3.44Mi 100.0% 3.45Mi TOTAL
Оптимизации компилятора для уменьшения размера кода
Здесь приведены наиболее распространенные оптимизации компилятора, которые могут значительно уменьшить размер двоичного файла. Все упомянутые здесь флаги широко используются в индустрии.
- -Os: рассматривался ранее.
- -Wl,--strip-all (или не передавать флаг -g): этот флаг указывает компоновщику удалить раздел отладки.
- -fno-unroll-loops: отключает развертывание цикла, которое является одной из популярных оптимизаций производительности компилятора, увеличивающей размер кода.
- -fno-exceptions: удаляет код обработки исключений из двоичного файла. Обратите внимание, что это не всегда возможно, если есть код, который их "бросает".
- -lto (-flto): включение оптимизации времени компоновки с параметром -flto приводит к агрессивной оптимизации компилятора. Оптимизируются многие функции и глобальные переменные, девиртуализируются многие вызовы. Полученный двоичный файл быстрее и меньше одновременно. Могут быть значительные накладные расходы во время компиляции.
Оптимизация исходного кода
Рефакторинг кода
Перемещение определений функций в файл .c/.cpp. Когда определения функций помещаются в заголовочные файлы, они дублируются в каждой единице трансляции, включающей заголовочный файл. Даже если в итоге остается только одно определение (благодаря One Definition Rule, ODR), эти функции могли быть вставлены в вызывающие их программы, и этот дополнительный объем кода сохранится в двоичном файле. Поэтому хорошей идеей является размещение определений функций в файлах .c/.cpp.
Помимо функций, которые были написаны разработчиками, существуют генерируемые компилятором функции, такие как конструкторы, деструкторы, перегрузки операторов и т. д. Даже эти функции могут вносить вклад в размер кода в зависимости от структуры типа и правил языка. Поэтому программисты могут явно определять эти методы в файле .cpp. Можно сделать либо определение "по умолчанию", либо явное. Например:
В файле test.h определен класс A:
class A { a(); A(A const&); ~A(); };
В файле test.cpp определения инстанцированы:
A::A() = default; A(A const&) = default; A::~A() = default;
Подобно тому, как определения функций в заголовочных файлах увеличивают размер кода, шаблонные функции делают то же самое. Однако уменьшить их накладные расходы - нетривиальная задача. Часто бывает так, что некоторые типы используются чаще, чем другие. Для часто используемых типов мы можем явно инстанцировать их в файле .cpp. Например:
В файле test.h определен шаблон:
template<class T> struct a { void f(T t) { /* */ } };
В файле test.cpp, шаблон явно инстанцирован:
template struct A<int>;
Явные инстанцирования также экономят время компиляции, поскольку инстанцирование происходит один раз. Для получения дополнительных идей по оптимизации исходного кода вы можете посмотреть презентацию Адитьи Кумара на международном форуме RISC-V 2020: "Code Size Compiler Optimizations and Techniques for Embedded Systems".
Атрибуты функций
Атрибуты функций, которые уменьшают потенциал инлайнинга, могут помочь уменьшить размер кода. Например:
- __attribute__((cold))
- __attribute__((noinline))
Обратите внимание, что в некоторых случаях инлайнинг может уменьшить размер кода. В частности, при использовании крошечных функций инлайнинг устраняет накладные расходы на вызов функции, которые могут быть больше, чем размер тела самой функции. Рекомендуется использовать эти атрибуты в ограниченных случаях, так как они влияют на читабельность программ.
Уменьшение размера двоичного файла путем вынесения вычислений из двоичного файла
При хорошем знании оптимизации компилятора и требований языка программирования можно перенести вычисления из двоичного файла. Некоторые из выражений могут быть вычислены во время компиляции, в то время как некоторые другие могут быть отложены до выполнения. Оба подхода помогают уменьшить размер двоичного файла. Ниже приведены мотивирующие примеры.
Раннее вычисление: используя такие возможности языка, как constexpr, static_assert из C++, некоторые выражения могут быть вычислены раньше, например:
constexpr auto gcd(int a, int b) { while (b != 0){ auto t = b; b = a % b; a = t; } return a; } int main() { int a = 11; int b = 121; int j = gcd(a, b); constexpr int i = gcd(10, 12); // saves '2' in the final assembly. return i + j; }
Компилируя программу, представленную выше, используя команду g++ std=c++17 -fno-exceptions -S:
main: mov edx, 121 mov eax, 11 .L2: # inlined call to gcd(a, b) mov ecx, edx cdq idiv ecx mov eax, ecx test edx, edx jne .L2 add eax, 2 # Precomputed value of gcd(10,12) ret
В ассемблере видно, что второй gcd был вычислен во время компиляции, но первый вызов gcd содержит весь код. Это происходит потому, что второй вызов функции gcd является constexpr. Подробнее о выражениях constexpr вы можете узнать на веб-странице constexpr specifier.
Простые приёмы поиска мёртвого кода в бинарном файле
В любом крупном проекте, скорее всего, по разным причинам присутствует "мертвый код". Часть мертвого кода может быть удалена с помощью простых трюков. Например:
- Поиск тестирующего и отладочного кода, поставляемого в продакшн. Нетривиально найти код для тестирования/отладки, просматривая исходный код. Однако поиск в двоичном коде обеспечивает высокое соотношение сигнал/шум. nm можно использовать для поиска имен символов в двоичном коде.
nm <Binary> | grep -i "test\|debug"
- Поиск строк в бинарном файле с помощью инструмента strings. Как объяснялось ранее, strings выводит все C-строки, жестко закодированные в двоичном файле. Просмотрев строки, мы можем выяснить, почему та или иная строка оказалась в конечном бинарном файле.