Лабораторный практикум по технологиям RISC-V

Лабораторная работа №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
    

    8.2.5 Измерение уменьшения объема бинарного файла от оптимизаций

    Измерение эффекта от соответствующих оптимизаций аналогично описанному ранее алгоритму, за исключением того, что достаточно провести одно измерение объема файла. Для измерения самого объема бинарного файла можно использовать различные утилиты. Утилита командной строки ls позволяет понять общий размер файла в байтах без детализации о распределении объема:

    $ ls -l a.out 
    -rw-r--r-- 1 root root 13423 сен 26  2023 a.out  
    

    Значение в пятой колонке соответствует размеру файла в байтах. О значениях в прочих колонках можно подробнее узнать в справке.

    Для более точной оценки можно использовать утилиту size. Она отображает размер каждой секции в рамках бинарного файла:

    $ size a.out 
       text    data     bss     dec     hex filename
       1524     600       8    2132     854 a.out
    

    8.3. Задание к лабораторной работе

    Подготовьте исходный код программы на языке С, которая будет выполнять заданное вариантом действие со случайной целочисленной матрицей А размера 20 на 20. Необходимо самостоятельно скомпилировать программу с различными опциями для архитектуры RISC-V, а также убедится в работоспособности. Опции для компиляции:

  • Сборка без оптимизации.
  • Оптимизация размера бинарного файла.
  • Оптимизация скорости работы.
  • С помощью флагов компилятора, постарайтесь добится наилучших характеристик для программ пунктов №1 и №2. Для полученных бинарных файлов с наилучшими результатами измерьте объем файла, а также время работы программы (количество измерений - 10).

    Варианты заданий (действия для матрицы):

  • Произведение всех элементов.
  • Сумма всех элементов.
  • Поиск минимального элемента в матрице.
  • Поиск элемента с наименьшим остатком от деления на 5.
  • Поиск строки с наименьшим количеством четных чисел.
  • a[i][j] = a[i][j] * a[i][j]
  • a[i][j] = a[i][j] % 9
  • a[i][j] = a[i][j+1] + a[i][j-1]
  • a[i][j] = a[i][j] * a[j][i]
  • Транспонирование матрицы.
  • Примечание для преподавателей: данную лабораторную работу можно проводить не только в стандартном режиме, описанном выше, но и на соревновательной основе по аналогии с олимпиадами. Для этого предлагается следующий упрощенный алгоритм:

  • Выбрать один из вариантов заданий, выдать его в качестве общего на группу студентов.
  • Зафиксировать тривиальное решение для выбранного задания (исходный код и команду для сборки), которое будет выступать в качестве базового уровня с точки зрения оптимизируемых характеристик (объем файла и скорость).
  • Зафиксировать параметры среды и способа измерения скорости (версии компиляторов и линковщика, ОС, инструментов измерения, параметров виртуальной машины).
  • В качестве задания указать оптимизацию одного или обоих параметров приложения, не меняя его сути и не нарушая корректность работы основного алгоритма.
  • 8.3.1. Описание последовательности выполнения работы

    В качестве первого шага, необходимо реализовать программу согласно выбранному варианту. При реализации программы не забудьте реализовать вывод исходных данных и ответа в консоль. Выполним сборку программы без флагов оптимизации с помощью. компилятора gcc (или путем кросс компиляции, или запуская компилятор непосредственно в рамках RISC-V ОС). Программу необходимо отладить перед оптимизацией - с помощью данных меньшего объема и многократных запусков убедитесь, что она корректно справляется с поставленной задачей и успешно выполняется без сбоев и ошибок.

    Далее, измерим исходные значения времени работы программы и объем бинарного файла с помощью утилит time / multitime и size. При измерении времени работы программы, убедитесь, что вы измеряете время работы программы, а не время ее запуска в рамках процесса qemu-riscv64:

    $ qemu-riscv64 -L "$RISCV/sysroot" time ./hello # Корректное измерение
    $ time qemu-riscv64 -L "$RISCV/sysroot" ./hello # Некорректное измерение
    

    Далее необходимо выполнить оптимизацию с помощью флагов компиляции. Мы рекомендуем увеличивать степень оптимизации постепенно, начиная с флага -O1, контролируя корректность работы самой программы по вводимым значениям и измеряя время работы. Аналогичные действия необходимо предпринять и для оптимизации объема бинарного файла. После окончания оптимизации необходимо повторить измерения и внести полученные данные в отчет.

    8.3.2. Пример выполнения задания на защиту

    Оптимизируйте время работы бинарного файла для программы суммирования остатков от деления на 3 для целочисленного случайного массива из 10 элементов. Предположим, что исходный код программы уже написан. Выполним сборку и измерим время работы на 10 запусках:

    $ gcc main.c -o a.out
    $ multitime -n 10 ./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  
    

    Для начала оптимизации по времени рекомендуется выполнить компиляцию c флагом -O1 и оценить достигнутый эффект.

    $ gcc -O1 main.c -o a.out
    $ multitime -n 10 ./a.out
    …
    ===> multitime results
    1: ./a.out
                Mean        Std.Dev.    Min         Median      Max
    real        0.012       0.002       0.010       0.016       0.020       
    user        0.005       0.005       0.000       0.007       0.013       
    sys         0.008       0.004       0.004       0.007       0.015  
    

    8.4. Вопросы для контроля

  • Для каких задач требуется оптимизация размера бинарного файла?
  • Какие негативные последствия может нести оптимизация времени выполнения программы?
  • Какие флаги отвечают за оптимизацию времени выполнения программы?
  • Страницы:

    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
    

    8.2.5 Измерение уменьшения объема бинарного файла от оптимизаций

    Измерение эффекта от соответствующих оптимизаций аналогично описанному ранее алгоритму, за исключением того, что достаточно провести одно измерение объема файла. Для измерения самого объема бинарного файла можно использовать различные утилиты. Утилита командной строки ls позволяет понять общий размер файла в байтах без детализации о распределении объема:

    $ ls -l a.out 
    -rw-r--r-- 1 root root 13423 сен 26  2023 a.out  
    

    Значение в пятой колонке соответствует размеру файла в байтах. О значениях в прочих колонках можно подробнее узнать в справке.

    Для более точной оценки можно использовать утилиту size. Она отображает размер каждой секции в рамках бинарного файла:

    $ size a.out 
       text    data     bss     dec     hex filename
       1524     600       8    2132     854 a.out
    

    8.3. Задание к лабораторной работе

    Подготовьте исходный код программы на языке С, которая будет выполнять заданное вариантом действие со случайной целочисленной матрицей А размера 20 на 20. Необходимо самостоятельно скомпилировать программу с различными опциями для архитектуры RISC-V, а также убедится в работоспособности. Опции для компиляции:

  • Сборка без оптимизации.
  • Оптимизация размера бинарного файла.
  • Оптимизация скорости работы.
  • С помощью флагов компилятора, постарайтесь добится наилучших характеристик для программ пунктов №1 и №2. Для полученных бинарных файлов с наилучшими результатами измерьте объем файла, а также время работы программы (количество измерений - 10).

    Варианты заданий (действия для матрицы):

  • Произведение всех элементов.
  • Сумма всех элементов.
  • Поиск минимального элемента в матрице.
  • Поиск элемента с наименьшим остатком от деления на 5.
  • Поиск строки с наименьшим количеством четных чисел.
  • a[i][j] = a[i][j] * a[i][j]
  • a[i][j] = a[i][j] % 9
  • a[i][j] = a[i][j+1] + a[i][j-1]
  • a[i][j] = a[i][j] * a[j][i]
  • Транспонирование матрицы.
  • Примечание для преподавателей: данную лабораторную работу можно проводить не только в стандартном режиме, описанном выше, но и на соревновательной основе по аналогии с олимпиадами. Для этого предлагается следующий упрощенный алгоритм:

  • Выбрать один из вариантов заданий, выдать его в качестве общего на группу студентов.
  • Зафиксировать тривиальное решение для выбранного задания (исходный код и команду для сборки), которое будет выступать в качестве базового уровня с точки зрения оптимизируемых характеристик (объем файла и скорость).
  • Зафиксировать параметры среды и способа измерения скорости (версии компиляторов и линковщика, ОС, инструментов измерения, параметров виртуальной машины).
  • В качестве задания указать оптимизацию одного или обоих параметров приложения, не меняя его сути и не нарушая корректность работы основного алгоритма.
  • 8.3.1. Описание последовательности выполнения работы

    В качестве первого шага, необходимо реализовать программу согласно выбранному варианту. При реализации программы не забудьте реализовать вывод исходных данных и ответа в консоль. Выполним сборку программы без флагов оптимизации с помощью. компилятора gcc (или путем кросс компиляции, или запуская компилятор непосредственно в рамках RISC-V ОС). Программу необходимо отладить перед оптимизацией - с помощью данных меньшего объема и многократных запусков убедитесь, что она корректно справляется с поставленной задачей и успешно выполняется без сбоев и ошибок.

    Далее, измерим исходные значения времени работы программы и объем бинарного файла с помощью утилит time / multitime и size. При измерении времени работы программы, убедитесь, что вы измеряете время работы программы, а не время ее запуска в рамках процесса qemu-riscv64:

    $ qemu-riscv64 -L "$RISCV/sysroot" time ./hello # Корректное измерение
    $ time qemu-riscv64 -L "$RISCV/sysroot" ./hello # Некорректное измерение
    

    Далее необходимо выполнить оптимизацию с помощью флагов компиляции. Мы рекомендуем увеличивать степень оптимизации постепенно, начиная с флага -O1, контролируя корректность работы самой программы по вводимым значениям и измеряя время работы. Аналогичные действия необходимо предпринять и для оптимизации объема бинарного файла. После окончания оптимизации необходимо повторить измерения и внести полученные данные в отчет.

    8.3.2. Пример выполнения задания на защиту

    Оптимизируйте время работы бинарного файла для программы суммирования остатков от деления на 3 для целочисленного случайного массива из 10 элементов. Предположим, что исходный код программы уже написан. Выполним сборку и измерим время работы на 10 запусках:

    $ gcc main.c -o a.out
    $ multitime -n 10 ./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  
    

    Для начала оптимизации по времени рекомендуется выполнить компиляцию c флагом -O1 и оценить достигнутый эффект.

    $ gcc -O1 main.c -o a.out
    $ multitime -n 10 ./a.out
    …
    ===> multitime results
    1: ./a.out
                Mean        Std.Dev.    Min         Median      Max
    real        0.012       0.002       0.010       0.016       0.020       
    user        0.005       0.005       0.000       0.007       0.013       
    sys         0.008       0.004       0.004       0.007       0.015  
    

    8.4. Вопросы для контроля

  • Для каких задач требуется оптимизация размера бинарного файла?
  • Какие негативные последствия может нести оптимизация времени выполнения программы?
  • Какие флаги отвечают за оптимизацию времени выполнения программы?
  • Вернуться к учебному плану