Тулчейн RISC-V и компиляторные оптимизации

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

Разбить на страницы
Показывать лекцию целиком

В этой лекции вы узнаете о часто используемых компиляторных оптимизациях, которые используются для уменьшения размера кода и повышения производительности, а также о различных инструментах и методах профилирования.

Современные оптимизирующие компиляторы, такие как 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 указывает компилятору ослабить некоторые требованияПодробнее об арифметике с плавающей запятой можно узнать в стандарте 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

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

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

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

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

  • Константин Владимиров - Специальный выпуск: лекции по тулчейну, часть 5, анализ кода и профилировка
  • Оптимизация объема кода

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

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

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

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

    Определение и сфера применения встраиваемых систем со временем изменились. В то время как встраиваемые системы используются для обозначения вычислительных систем, выполняющих очень специфические задачи, во многих ситуациях это уже не так. Хотя большинство встраиваемых систем предназначены для выполнения ограниченного набора задач, в зависимости от области применения, сами задачи могут быть простыми или достаточно сложными. Встраиваемые системы могут иметь простые микроконтроллеры, или сложные цифровые сигнальные процессоры (DSP), или даже микропроцессоры. Даже при существенных различиях между системами, некоторые моменты в целом верны для всех:

  • это маломощные устройства или устройства с батарейным питанием;
  • они имеют ограниченный объем памяти;
  • они нечасто обновляют приложения.
  • Могут быть и другие критерии, но эти три являются хорошей отправной точкой для понимания того, как мы можем оптимизировать эти приложения с помощью методов компилятора.

    Оптимизация энергопотребления

    Существуют две точки зрения, как уменьшать энергопотребление во время работы приложения:

  • выполнение/разработка инструкций с низким энергопотреблением;
  • как можно более быстрое выполнение инструкций и переход в режим ожидания.
  • Каждый из подходов имеет свои достоинства и недостатки. Далее кратко описан каждый из них.

    Выполнение/разработка инструкций с низким энергопотреблением

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

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

    Существуют также ситуации, когда система может иметь конфигурацию big.LITTLE. Более мощные процессоры используются только при увеличении потребности в вычислениях, в остальное время вычисления выполняются на маломощных процессорах. Если мы можем скомпилировать тяжелые для вычислений части кода для мощных процессоров (флаг -mtune), а остальные - для маломощных, мы можем воспользоваться этой возможностью. Эта стратегия потребует регулярного обновления флагов сборки в сочетании с оптимизацией под профиль (PGO).

    Как можно более быстрое выполнение инструкций и переход в режим ожидания

    Некоторые процессоры могут иметь расширенные возможности динамического масштабирования напряжения и частоты. Таким образом, при отсутствии работы процессоры переходят в режим ожидания с низким энергопотреблением. В результате имеет смысл запускать приложения как можно быстрее и переложить ответственность за управление питанием на процессор. Чтобы запустить приложение как можно быстрее, мы можем использовать более высокие уровни оптимизации, такие как -O3, -Ofast; мы можем сочетать это с оптимизацией времени компоновки и оптимизацией с учетом профиля для достижения еще большей производительности.

    Оптимизация размера двоичного файла

    Мы уже обсуждали уменьшение двоичного размера приложения в предыдущем разделе "Оптимизации компилятора для уменьшения размера кода".

    Оптимизация приложений, которые не часто меняются

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

    Справочные материалы

  • Адитья Кумар - Оптимизация размера кода компилятора и методы для встраиваемых систем
  • Адитья Кумар и Себастьян Поп - Анализ производительности и оптимизация стандартных библиотек C++
  • Сообщество разработчиков Visual Studio - pragma optimize off работает не так, как ожидалось
  • nm(1) - страница руководства Linux
  • Инструментирующие функции GCC
  • Нитин Кумар - Оптимизация с управлением по профилю (PGO) с использованием GCC на IBM AIX
  • Адитья Кумар - Анализ и оптимизация производительности
  • Винодха Рамасами, Пол Юань, Дехао Чен, Роберт Хундт - Feedback-Directed Optimizations in GCC with Estimated Edge Profiles from Hardware Event Sampling
  • Страницы:

    В этой лекции вы узнаете о часто используемых компиляторных оптимизациях, которые используются для уменьшения размера кода и повышения производительности, а также о различных инструментах и методах профилирования.

    Современные оптимизирующие компиляторы, такие как 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 указывает компилятору ослабить некоторые требованияПодробнее об арифметике с плавающей запятой можно узнать в стандарте 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

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

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

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

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

  • Константин Владимиров - Специальный выпуск: лекции по тулчейну, часть 5, анализ кода и профилировка
  • Оптимизация объема кода

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

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

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

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

    Определение и сфера применения встраиваемых систем со временем изменились. В то время как встраиваемые системы используются для обозначения вычислительных систем, выполняющих очень специфические задачи, во многих ситуациях это уже не так. Хотя большинство встраиваемых систем предназначены для выполнения ограниченного набора задач, в зависимости от области применения, сами задачи могут быть простыми или достаточно сложными. Встраиваемые системы могут иметь простые микроконтроллеры, или сложные цифровые сигнальные процессоры (DSP), или даже микропроцессоры. Даже при существенных различиях между системами, некоторые моменты в целом верны для всех:

  • это маломощные устройства или устройства с батарейным питанием;
  • они имеют ограниченный объем памяти;
  • они нечасто обновляют приложения.
  • Могут быть и другие критерии, но эти три являются хорошей отправной точкой для понимания того, как мы можем оптимизировать эти приложения с помощью методов компилятора.

    Оптимизация энергопотребления

    Существуют две точки зрения, как уменьшать энергопотребление во время работы приложения:

  • выполнение/разработка инструкций с низким энергопотреблением;
  • как можно более быстрое выполнение инструкций и переход в режим ожидания.
  • Каждый из подходов имеет свои достоинства и недостатки. Далее кратко описан каждый из них.

    Выполнение/разработка инструкций с низким энергопотреблением

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

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

    Существуют также ситуации, когда система может иметь конфигурацию big.LITTLE. Более мощные процессоры используются только при увеличении потребности в вычислениях, в остальное время вычисления выполняются на маломощных процессорах. Если мы можем скомпилировать тяжелые для вычислений части кода для мощных процессоров (флаг -mtune), а остальные - для маломощных, мы можем воспользоваться этой возможностью. Эта стратегия потребует регулярного обновления флагов сборки в сочетании с оптимизацией под профиль (PGO).

    Как можно более быстрое выполнение инструкций и переход в режим ожидания

    Некоторые процессоры могут иметь расширенные возможности динамического масштабирования напряжения и частоты. Таким образом, при отсутствии работы процессоры переходят в режим ожидания с низким энергопотреблением. В результате имеет смысл запускать приложения как можно быстрее и переложить ответственность за управление питанием на процессор. Чтобы запустить приложение как можно быстрее, мы можем использовать более высокие уровни оптимизации, такие как -O3, -Ofast; мы можем сочетать это с оптимизацией времени компоновки и оптимизацией с учетом профиля для достижения еще большей производительности.

    Оптимизация размера двоичного файла

    Мы уже обсуждали уменьшение двоичного размера приложения в предыдущем разделе "Оптимизации компилятора для уменьшения размера кода".

    Оптимизация приложений, которые не часто меняются

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

    Справочные материалы

  • Адитья Кумар - Оптимизация размера кода компилятора и методы для встраиваемых систем
  • Адитья Кумар и Себастьян Поп - Анализ производительности и оптимизация стандартных библиотек C++
  • Сообщество разработчиков Visual Studio - pragma optimize off работает не так, как ожидалось
  • nm(1) - страница руководства Linux
  • Инструментирующие функции GCC
  • Нитин Кумар - Оптимизация с управлением по профилю (PGO) с использованием GCC на IBM AIX
  • Адитья Кумар - Анализ и оптимизация производительности
  • Винодха Рамасами, Пол Юань, Дехао Чен, Роберт Хундт - Feedback-Directed Optimizations in GCC with Estimated Edge Profiles from Hardware Event Sampling
  • Вернуться к учебному плану