Лабораторная работа №8. Оптимизации на этапе компиляции
8.1 Цель и задачи
Целью работы является освоение способов оптимизации программ на этапе компиляции для RISC-V.
Для достижения поставленной цели требуется решить следующие задачи:
- Изучить флаги компиляции для оптимизации программ на С.
- Ознакомиться с основными командами для декомпиляции программ.
- На примере HelloWorld приложений изучить разницу при использовании разных типов оптимизаций, а также ограничений, которые они могут потенциально накладывать.
Презентация к блоку "Оптимизации в компиляторах и отладка"
8.2. Основные теоретические сведения
Работа программиста, использующего языки высокого уровня, часто сопряжена с определенным уровнем противоречий. С одной стороны, разработчик должен писать легко читаемые и легко поддерживаемые программы, что выражается в использовании высокоуровневых синтаксических конструкций. С другой стороны, программа должна эффективно работать с учетом конкретного аппаратного обеспечения пользовательского компьютера, что требует учета низкоуровневых особенностей исполнения. Данное противоречие частично разрешается компиляторами - они не просто выполняют переход от языков высокого уровня к машинным инструкциям, но и стараются выбрать наиболее эффективный способ данного перехода. Однако, в ряде случаев, им требуется дополнительная настройка для достижения максимального эффекта. В таком случае применяются методы оптимизации на уровне компилятора и техники инструментирования. Данные инструменты регулируются флагами компилятора. Для определенности, далее мы будем рассматривать тему применительно к компилятору GCC, однако подобные инструменты также есть и в компиляторе LLVM.
8.2.1 Оптимизации на уровне компилятора
Оптимизации на уровне компилятора представляют собой дополнительные процедуры по выбору оптимального преобразования исходного кода программы в машинные инструкции так, чтобы достигнуть максимального значения параметра производительности (время работы и/или объем исполняемого файла). При этом, в зависимости от выбранной интенсивности, компилятор может применять достаточно агрессивные эвристики для оптимизации, что может привести к ошибкам и сбоям в работе программы (например, многопоточные ошибки и ошибки обращения с памятью). Наиболее важные флаги оптимизации для GCC [10]:
- Время работы программы
- -O0 - тривиальный случай, когда оптимизация компилятора не выполняется. Тем не менее оптимизация для конкретного языка в соответствии с требованиями стандарта по-прежнему выполняется. Например, вычисления во время компиляции, требуемые стандартом C++, по-прежнему выполняются.
- -O1 - на этом уровне включается множество оптимизаций, повышающих производительность программы. Например, развертывание циклов (loop unrolling), встраивание функций (inline functions), планирование инструкций (instruction planning) и т. д.
- -O2 позволяет использовать все оптимизации -O1, а также более агрессивные оптимизации в распределении регистров, планировании инструкций, частичном устранении избыточности и т. д. Этот уровень используется при построении кода с преобладанием ветвлений, например, операционных систем.
- -O3. Этот уровень включает в себя все возможности -O2, а также некоторые современные оптимизации, такие как векторизация. -O3 является уровнем оптимизации для максимизации производительности большинства приложений.
- -Ofast или -O3 с -ffast-math. Флаг -ffast-math указывает компилятору ослабить некоторые требования арифметики с плавающей запятой, такие как ассоциативность и коммутативность. Во многих приложениях ошибки, возникающие после ослабления этих требований, вполне допустимы за счёт более высокой производительности. Без -ffast-math многие циклы с операциями с плавающей точкой не могут быть векторизованы.
- Размер бинарного файла
- -Os оптимизирует размер кода. Таким образом, большинство оптимизаций, увеличивающих размер кода, будут менее агрессивными на этом уровне. Это популярная оптимизация среди встраиваемых систем и мобильных приложений, поскольку размер кода там является большой проблемой.
- -Wl,--strip-all (или не передавать флаг -g): этот флаг указывает компоновщику удалить раздел отладки.
- -fno-unroll-loops: отключает развертывание цикла, которое является одной из популярных оптимизаций производительности компилятора, увеличивающей размер кода.
- -fno-exceptions: удаляет код обработки исключений из двоичного файла. Обратите внимание, что это не всегда возможно, если есть код, который их "бросает".
- -lto (-flto): включение оптимизации времени компоновки с параметром -flto приводит к агрессивной оптимизации компилятора. Оптимизируются многие функции и глобальные переменные, девиртуализируются многие вызовы. Полученный двоичный файл быстрее и меньше одновременно. Могут быть значительные накладные расходы во время компиляции.
8.2.2 Инструментирование и профилирование
Описанные выше флаги для оптимизации не являются панацеей - они действуют на всю программу целиком и не учитывают особенностей ее использования, продиктованных действиями пользователя и структурой взаимодействия с ним. Более эффективные оптимизации могут быть достигнуты применением инструментирования - методик, внедряющих дополнительный код в бинарный файл для сбора технической информации о производительности (профиля работы) программы в ходе ее работы. Данная информация может быть передана компилятору чтобы повысить эффективность оптимизаций. Для инструментирования применяются следующие флаги:
- -g, чтобы иметь возможность отлаживать приложение с аннотациями исходного кода, компилятор должен предоставить дополнительную информацию в двоичном файле. Флаг -g указывает компилятору сделать это. Без этого флага отладчик будет показывать только имена глобальных символов и дизассемблер, поскольку он не может связать строку исходного кода со сборкой.
- -finstrument-functions. Этот флаг используется для инструментирования входа и выхода функций. Инструментирование позволяет получить представление о поведении программ. При использовании этого флага также необходимо определить две функции __cyg_profile_func_enter и __cyg_profile_func_exit, которые вызываются соответственно при входе и выходе из каждой вызываемой функции. Если есть функции, которые не должны быть инструментированы, к ним можно добавить __attribute__ ((no_instrument_function)).
- -fprofile-generate, -fprofile-arcs, -pg .Эти флаги используются для инструментирования программ с целью сбора профилей времени выполнения различных точек программы. Это позволяет компилятору проводить оптимизацию с учетом профиля в последующих компиляциях. В зависимости от того, какие флаги вы используете, могут быть достигнуты различные типы инструментирования. Подробный обзор различных флагов приведен на странице руководства.
- -fstack-protector, -fstack-protector-all, -fstack-protector-strong. Эти опции инструментируют уязвимые функции путем вставки защитных переменных в кадр стека. Перед возвратом функции проверяется, что защитная переменная не была перезаписана, что позволяет убедиться в том, что стек не был поврежден. Это тривиальный способ улучшить защиту от атаки на переполнение буфера. Однако это может увеличить размер кода приложения. В случае, если это создает накладные расходы, с этим флагом можно компилировать только критически важные для безопасности части приложения.
Рассмотрим пример использования инструментирования для сбора профиля работы программы. Пример приведен для RISC-V ОС, однако его можно повторить и в гостевой ОС, используя команды для кросс-компиляции. Первое действие это компиляция с флагом -fprofile-generate:
$ gcc -O2 -fprofile-generate=/path/to/outputfile test.c -o a.out
Далее необходимо запустить программу (a.out) несколько раз для сбора профиля:
$ ./a.out
Профиль работы программы в виде файла с расширением .gcda будет сохранен в каталог /path/to/outputfile. После этого можно запустить компиляцию исходного кода с флагом -fprofile-use для дополнительной оптимизации:
$ gcc -O2 -fprofile-use=/path/to/outputfile test.c -o a.out
Важно понимать, что с одной стороны, инструментирование позволяет собрать больше информации о работе ПО, но с другой - за счет внедрения инструментального кода сам процесс измерения может повлиять на измеряемые величины или даже привести к сбоям в работе ПО. Для получения более детальной картины возможно применение внешних инструментов измерения производительности - профилировщиков, например perf, valgrind, callgrind, cachegrind. Такие профилировщики позволяют собрать информацию о самых низкоуровневых особенностях работы программы - эффективность работы кэш-памяти, качество предсказаний переходов, статистика работы с динамической памятью.
8.2.4 Измерение ускорения от оптимизаций
Для того, чтобы убедится в эффективности проводимых оптимизаций, необходимо использовать инструменты для измерения скорости работы программы, а также объема бинарных файлов. Выбор конкретных утилит, а также технологий измерения зависит от множества различных факторов. Например, для измерения времени работы программы:
- структура сценария использования (как пользователь взаимодействует с программой - интерактивно или нет),
- наличие и структура входных данных (что и как пользователь вводит в программу),
- целевая ОС и аппаратное обеспечение (в каких условиях предполагается работа программы),
- тип программы (встраиваемое ПО, веб-приложение, утилита командной строки и тд),
- факторы, влияющие на измерения (наличие фоновых процессов, сетевой трафик),
- требуемая точность измерений.
Данные вопросы подробно изучаются в дисциплине "Метрология ПО". В данной работе мы предлагаем грубый и быстрый способ оценки эффекта ускорения работы ПО от оптимизаций.
Для неответственных измерений времени работы программы в ОС на базе Linux можно использовать утилиту time. Она измеряет время работы программы с момента запуска и до завершения, а также выводит детализацию. Для измерения времени работы скомпилированной ранее программы используйте команду:
$ time ./a.out
Вывод имеет вид:
# Вывод программы a.out … real 0m0,020s user 0m0,005s sys 0m0,015s
где real - общее время работы программы, user - время работы программы в режиме пользователя, sys - время работы программы в режиме ядра (использование системных вызовов и низкоуровневых операций ОС).
Важно отметить, что единичное измерение не обладает ценностью - для оценки производительности важны статистические (множественные наблюдения), проводимые в воспроизводимых условиях. Поэтому, необходимо сделать множество измерений в одинаковых условиях. Также, для оценки эффекта необходимо проводить измерения базового уровня (baseline) - показателей программы до оптимизации. Таким образом, примерный алгоритм измерений будет иметь вид:
- Сохраните исходный бинарный файл программы (baseline).
- Проведите оптимизацию, сохраните отдельно модифицированный бинарный файл программы (optimized).
- Подготовка среды для измерений, исключение помех (закрытие ресурсоемких приложений, подготовка тестовых данных).
- Проведение N запусков для baseline.
- Проведение N запусков для optimized.
- проведите статистическую обработку (подсчет среднего, медианы, минимума, максимума, стандартного отклонения).
Для автоматизации процесса измерений вы можете использовать уже готовую утилиту multitime - она дублирует функции time, а также умеет подводить статистику нескольких последовательных запусков. Например, команда для проведения десяти запусков и вычисления статистики:
$ multitime -n 10 ./a.out
Результат выполнения:
# Вывод программы a.out … ===> multitime results 1: ./a.out Mean Std.Dev. Min Median Max real 0.016 0.002 0.012 0.016 0.021 user 0.007 0.005 0.000 0.007 0.013 sys 0.008 0.004 0.004 0.007 0.015
По умолчанию, multitime может быть не установлена в популярных Linux дистрибутивах (в том числе и в ОС Syntacore Kit), вы можете установить ее командами
$ apt update $ apt install multitime