В этой лекции вы узнаете о часто используемых компиляторных оптимизациях, которые используются для уменьшения размера кода и повышения производительности, а также о различных инструментах и методах профилирования.
Современные оптимизирующие компиляторы, такие как 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 указывает компилятору ослабить некоторые -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
Эти опции инструментируют уязвимые функции путем вставки защитных переменных в кадр стека. Перед возвратом функции проверяется, что защитная переменная не была перезаписана, что позволяет убедиться в том, что стек не был поврежден. Это тривиальный способ улучшить защиту от атаки на переполнение буфера. Однако это может увеличить размер кода приложения. В случае, если это создает накладные расходы, с этим флагом можно компилировать только критически важные для безопасности части приложения. Более подробную информацию об использовании этого флага можно найти здесь.
Зная частоту выполнения различных точек программы, компилятор может принимать более хорошие решения по оптимизации в процессе компиляции. Многие оптимизации компилятора основаны на жестко закодированных эвристиках и статическом анализе программы. Эти эвристики могут быть не идеальны для разных точек программы и часто приводят к потере производительности. Компилятор имеет возможность учитывать поведение программы во время выполнения, читая "файл покрытия". Файл покрытия - это, по сути, гистограмма частот выполнения различных точек программы. Для создания файла покрытия существует две методики, которые подробно описаны ниже.
Компиляторы могут вставлять "счётчики" в интересующие точки программы для сбора профилей исполнения. Код инструментируется путем передачи компилятору команды -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.
GO на основе инструментирования в целом прост в использовании, существуют определенные недостатки:
Эти недостатки могут касаться не всех систем, поэтому всегда следует взвешенно оценивать, какие технологии PGO использовать. Качество оптимизации с помощью профиля зависит от тестовых векторов, по которым программа выполнялась при сборе профиля. Но даже при ограниченных тестовых сценариях в некоторых случаях все равно выгодно проводить PGO, например, последовательность запуска программы не сильно меняется даже при очень разных тестовых сценариях.
В дополнение к этому, может возникнуть проблема переобучения в некоторых точках программы, если охват во время сбора профиля был недостаточным. При переоснащении программа может плохо работать в определенных случаях. Чтобы преодолеть это, желательно выполнять непрерывное профилирование и компиляцию. Эта проблема менее актуальна для систем, где программы не часто меняются.
Размер кода встраиваемого ПО был проблемой в течение очень долгого времени. В то время как хранилища становятся все дешевле и меньше, разработчики находят творческие способы увеличить размер кода за счёт добавления функций или ненужной программной инженерии. Компиляторы прошли долгий путь в оптимизации приложений по размеру кода. В то время как большинство оптимизаций компиляторов были направлены на производительность приложений, в последние годы мы наблюдаем рост оптимизаций размера кода. В этом разделе мы познакомимся с широко используемыми методами уменьшения размера кода приложений. Этот раздел состоит из трех частей:
Существует три популярных инструмента для измерения размера кода двоичного файла.
Утилита size может показать размер каждой секции двоичного файла.
size gcc/11/libstdc++.dylib __TEXT __DATA __OBJC others dec hex 1703936 65536 0 1851392 3620864 374000
Показывает все строки в двоичном файле.
strings gcc/11/libstdc++.dylib | wc -l 2180
Этот инструмент может быть использован для более глубокого анализа двоичных файлов различных платформ. Он также сопоставляет исходным файлам их размеры в скомпилированном виде.
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-строки, жестко закодированные в двоичном файле. Просмотрев строки, мы можем выяснить, почему та или иная строка оказалась в конечном бинарном файле.Определение и сфера применения встраиваемых систем со временем изменились. В то время как встраиваемые системы используются для обозначения вычислительных систем, выполняющих очень специфические задачи, во многих ситуациях это уже не так. Хотя большинство встраиваемых систем предназначены для выполнения ограниченного набора задач, в зависимости от области применения, сами задачи могут быть простыми или достаточно сложными. Встраиваемые системы могут иметь простые микроконтроллеры, или сложные цифровые сигнальные процессоры (DSP), или даже микропроцессоры. Даже при существенных различиях между системами, некоторые моменты в целом верны для всех:
Могут быть и другие критерии, но эти три являются хорошей отправной точкой для понимания того, как мы можем оптимизировать эти приложения с помощью методов компилятора.
Существуют две точки зрения, как уменьшать энергопотребление во время работы приложения:
Каждый из подходов имеет свои достоинства и недостатки. Далее кратко описан каждый из них.
Процессор имеет множество инструкций, с помощью которых можно выполнить одно и то же вычисление. Каждый тип инструкций потребляет разное количество системных ресурсов и подходит для определенных случаев. Например, операция с плавающей запятой может быть более дорогостоящей, чем целочисленные операции. В некоторых встраиваемых аппаратных средствах модули с плавающей запятой изначально отсутствуют, и для выполнения таких операций в редких случаях используются программные процедуры.
Компиляторы, насколько нам известно, не дают напрямую выбрать только инструкции с низким энергопотреблением. В результате этот подход применим в основном для инженеров по аппаратному обеспечению. В ограниченных ситуациях инженеры компиляторов могут воспользоваться преимуществами этого подхода, когда они имеют лучшее представление о процессоре и работающих приложениях; например, векторизация может быть отключена, поскольку векторные блоки часто потребляют больше энергии, чем скалярные инструкции.
Существуют также ситуации, когда система может иметь конфигурацию big.LITTLE. Более мощные процессоры используются только при увеличении потребности в вычислениях, в остальное время вычисления выполняются на маломощных процессорах. Если мы можем скомпилировать тяжелые для вычислений части кода для мощных процессоров (флаг -mtune), а остальные - для маломощных, мы можем воспользоваться этой возможностью. Эта стратегия потребует регулярного обновления флагов сборки в сочетании с оптимизацией под профиль (PGO).
Некоторые процессоры могут иметь расширенные возможности динамического масштабирования напряжения и частоты. Таким образом, при отсутствии работы процессоры переходят в режим ожидания с низким энергопотреблением. В результате имеет смысл запускать приложения как можно быстрее и переложить ответственность за управление питанием на процессор. Чтобы запустить приложение как можно быстрее, мы можем использовать более высокие уровни оптимизации, такие как -O3, -Ofast; мы можем сочетать это с оптимизацией времени компоновки и оптимизацией с учетом профиля для достижения еще большей производительности.
Мы уже обсуждали уменьшение двоичного размера приложения в предыдущем разделе "Оптимизации компилятора для уменьшения размера кода".
Если приложение меняется нечасто, то становится дешевле собирать профили времени выполнения и выполнять оптимизацию с учетом профиля. Приложение можно инструментировать с помощью флагов PGO, как обсуждалось ранее, и запустить его для обычных сценариев использования. Наличие комплексного набора тестов также может помочь в получении расширенных профилей производительности, что поможет компилятору эффективно оптимизировать приложение.
В этой лекции вы узнаете о часто используемых компиляторных оптимизациях, которые используются для уменьшения размера кода и повышения производительности, а также о различных инструментах и методах профилирования.
Современные оптимизирующие компиляторы, такие как 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 указывает компилятору ослабить некоторые -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
Эти опции инструментируют уязвимые функции путем вставки защитных переменных в кадр стека. Перед возвратом функции проверяется, что защитная переменная не была перезаписана, что позволяет убедиться в том, что стек не был поврежден. Это тривиальный способ улучшить защиту от атаки на переполнение буфера. Однако это может увеличить размер кода приложения. В случае, если это создает накладные расходы, с этим флагом можно компилировать только критически важные для безопасности части приложения. Более подробную информацию об использовании этого флага можно найти здесь.
Зная частоту выполнения различных точек программы, компилятор может принимать более хорошие решения по оптимизации в процессе компиляции. Многие оптимизации компилятора основаны на жестко закодированных эвристиках и статическом анализе программы. Эти эвристики могут быть не идеальны для разных точек программы и часто приводят к потере производительности. Компилятор имеет возможность учитывать поведение программы во время выполнения, читая "файл покрытия". Файл покрытия - это, по сути, гистограмма частот выполнения различных точек программы. Для создания файла покрытия существует две методики, которые подробно описаны ниже.
Компиляторы могут вставлять "счётчики" в интересующие точки программы для сбора профилей исполнения. Код инструментируется путем передачи компилятору команды -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.
GO на основе инструментирования в целом прост в использовании, существуют определенные недостатки:
Эти недостатки могут касаться не всех систем, поэтому всегда следует взвешенно оценивать, какие технологии PGO использовать. Качество оптимизации с помощью профиля зависит от тестовых векторов, по которым программа выполнялась при сборе профиля. Но даже при ограниченных тестовых сценариях в некоторых случаях все равно выгодно проводить PGO, например, последовательность запуска программы не сильно меняется даже при очень разных тестовых сценариях.
В дополнение к этому, может возникнуть проблема переобучения в некоторых точках программы, если охват во время сбора профиля был недостаточным. При переоснащении программа может плохо работать в определенных случаях. Чтобы преодолеть это, желательно выполнять непрерывное профилирование и компиляцию. Эта проблема менее актуальна для систем, где программы не часто меняются.
Размер кода встраиваемого ПО был проблемой в течение очень долгого времени. В то время как хранилища становятся все дешевле и меньше, разработчики находят творческие способы увеличить размер кода за счёт добавления функций или ненужной программной инженерии. Компиляторы прошли долгий путь в оптимизации приложений по размеру кода. В то время как большинство оптимизаций компиляторов были направлены на производительность приложений, в последние годы мы наблюдаем рост оптимизаций размера кода. В этом разделе мы познакомимся с широко используемыми методами уменьшения размера кода приложений. Этот раздел состоит из трех частей:
Существует три популярных инструмента для измерения размера кода двоичного файла.
Утилита size может показать размер каждой секции двоичного файла.
size gcc/11/libstdc++.dylib __TEXT __DATA __OBJC others dec hex 1703936 65536 0 1851392 3620864 374000
Показывает все строки в двоичном файле.
strings gcc/11/libstdc++.dylib | wc -l 2180
Этот инструмент может быть использован для более глубокого анализа двоичных файлов различных платформ. Он также сопоставляет исходным файлам их размеры в скомпилированном виде.
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-строки, жестко закодированные в двоичном файле. Просмотрев строки, мы можем выяснить, почему та или иная строка оказалась в конечном бинарном файле.Определение и сфера применения встраиваемых систем со временем изменились. В то время как встраиваемые системы используются для обозначения вычислительных систем, выполняющих очень специфические задачи, во многих ситуациях это уже не так. Хотя большинство встраиваемых систем предназначены для выполнения ограниченного набора задач, в зависимости от области применения, сами задачи могут быть простыми или достаточно сложными. Встраиваемые системы могут иметь простые микроконтроллеры, или сложные цифровые сигнальные процессоры (DSP), или даже микропроцессоры. Даже при существенных различиях между системами, некоторые моменты в целом верны для всех:
Могут быть и другие критерии, но эти три являются хорошей отправной точкой для понимания того, как мы можем оптимизировать эти приложения с помощью методов компилятора.
Существуют две точки зрения, как уменьшать энергопотребление во время работы приложения:
Каждый из подходов имеет свои достоинства и недостатки. Далее кратко описан каждый из них.
Процессор имеет множество инструкций, с помощью которых можно выполнить одно и то же вычисление. Каждый тип инструкций потребляет разное количество системных ресурсов и подходит для определенных случаев. Например, операция с плавающей запятой может быть более дорогостоящей, чем целочисленные операции. В некоторых встраиваемых аппаратных средствах модули с плавающей запятой изначально отсутствуют, и для выполнения таких операций в редких случаях используются программные процедуры.
Компиляторы, насколько нам известно, не дают напрямую выбрать только инструкции с низким энергопотреблением. В результате этот подход применим в основном для инженеров по аппаратному обеспечению. В ограниченных ситуациях инженеры компиляторов могут воспользоваться преимуществами этого подхода, когда они имеют лучшее представление о процессоре и работающих приложениях; например, векторизация может быть отключена, поскольку векторные блоки часто потребляют больше энергии, чем скалярные инструкции.
Существуют также ситуации, когда система может иметь конфигурацию big.LITTLE. Более мощные процессоры используются только при увеличении потребности в вычислениях, в остальное время вычисления выполняются на маломощных процессорах. Если мы можем скомпилировать тяжелые для вычислений части кода для мощных процессоров (флаг -mtune), а остальные - для маломощных, мы можем воспользоваться этой возможностью. Эта стратегия потребует регулярного обновления флагов сборки в сочетании с оптимизацией под профиль (PGO).
Некоторые процессоры могут иметь расширенные возможности динамического масштабирования напряжения и частоты. Таким образом, при отсутствии работы процессоры переходят в режим ожидания с низким энергопотреблением. В результате имеет смысл запускать приложения как можно быстрее и переложить ответственность за управление питанием на процессор. Чтобы запустить приложение как можно быстрее, мы можем использовать более высокие уровни оптимизации, такие как -O3, -Ofast; мы можем сочетать это с оптимизацией времени компоновки и оптимизацией с учетом профиля для достижения еще большей производительности.
Мы уже обсуждали уменьшение двоичного размера приложения в предыдущем разделе "Оптимизации компилятора для уменьшения размера кода".
Если приложение меняется нечасто, то становится дешевле собирать профили времени выполнения и выполнять оптимизацию с учетом профиля. Приложение можно инструментировать с помощью флагов PGO, как обсуждалось ранее, и запустить его для обычных сценариев использования. Наличие комплексного набора тестов также может помочь в получении расширенных профилей производительности, что поможет компилятору эффективно оптимизировать приложение.
Для получения официальных документов о завершении программы дополнительного профессионального образования (удостоверения о повышении квалификации, дипломов о профессиональной переподготовке и MBA) необходимо предоставить:
Внимание! Вы можете не заказывать доставку бумажной версии официального документы, а скачать его в электронном виде и распечатать самостоятельно. Информация о выданном документе в течение 1 месяца загружается в Федеральную информационную систему «Федеральный реестр сведений о документах об образовании и (или) о квалификации, документах об обучении» - ФИС ФРДО.
Доступ на новый сайт осуществляется с использованием адреса электронной почты, который был указан вами при регистрации на "старом". Мы постарались перенести все ваши данные с прежнего ресурса, однако не исключена вероятность потери части информации.
При возникновении проблемы со входом, воспользуйтесь функцией сброса пароля
Если вы обнаружите несоответствия, пожалуйста, сообщите нам.