Опубликован: 24.11.2024 | Доступ: свободный | Студентов: 5 / 0 | Длительность: 02:06:00
Лекция 4:

Введение в компиляторные оптимизации

< Лекция 3 || Лекция 4: 123 || Лекция 5 >

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, например, последовательность запуска программы не сильно меняется даже при очень разных тестовых сценариях.

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

Полезные материалы

Оптимизация объема кода

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

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

Существует три популярных инструмента для измерения размера кода двоичного файла.

  1. size: GNU Binutils
  2. strings: GNU Binutils
  3. 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-строки, жестко закодированные в двоичном файле. Просмотрев строки, мы можем выяснить, почему та или иная строка оказалась в конечном бинарном файле.
< Лекция 3 || Лекция 4: 123 || Лекция 5 >