Абстрактный:

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

Введение:

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

Существуют различные способы профилирования приложений, и это в основном зависит от типа сборки приложения. Языки программирования, такие как Java, например, в основном создают файлы JAR или Class, для работы которых требуется JVM, поэтому профилировщик в этом случае будет проверять внутренности JVM во время работы. Однако языки, создающие двоичные файлы, такие как C, C++, Rust, Crystal и т. д., потребуют профилирования внешних инструментов. В этой статье основное внимание будет уделено профилированию двоичных файлов, созданных из приложений с использованием valgrind.

Настраивать:

У нас есть приложение, написанное на Crystal, которое выполняет сложные математические вычисления, в основном в виде матричных операций и линейной алгебры. Для выполнения таких операций используется библиотека linalg.

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

Чтобы иметь возможность профилировать приложение, оно должно быть собрано с включенными символами отладки, обычно в компиляторе gcc это флаг -g, однако в Crystal это флаг -d. Крайне важно избегать применения оптимизации времени компиляции, так как имена методов и поток кода будут изменены, и будет сложнее отлаживать. Таким образом, мы построили приложение следующим образом:

$ crystal build -p -o app_debug src/app.cr

Профилирование работает путем выборки приложения каждые несколько секунд, чтобы получить точные результаты, приложение должно работать достаточно долго, чтобы получить как можно больше образцов, поэтому для достижения этого мы использовали размеры матрицы, достаточно большие, чтобы код выполнялся для около 10-20 секунд до профилирования. Следует отметить, что профилирование значительно замедляет работу приложения, так как оно каждый раз сканирует память, поэтому приложение, которое выполнялось за 10-20 с до профилирования, в нашем случае выполнялось около 1 минуты при профилировании. Для профилирования приложения мы использовали инструмент valgrind следующим образом:

$ valgrind --tool=callgrind --keep-debuginfo=yes ./app_debug

Valgrind может работать в различных режимах, нас интересует режим callgrind, поскольку он будет проверять вызовы методов и продолжительность, затраченную на каждый метод, флаг keep-debuginfo сохраняет номер строки при профилировании, а также для дальнейшей помощи в отладке. После профилирования приложения в течение достаточного времени можно остановить профилирование, нажав сочетание клавиш ctrl + C, после чего будет создан файл с именем callgrind.out, этот файл содержит всю информацию о профилировании, которое мы только что инициировали. Для того, чтобы увидеть файл, его нужно преобразовать в визуальный формат, для этого будет использоваться инструмент gprof2dot, его вообще нет в репозиториях большинства дистрибутивов linux, поэтому его нужно собрать из исходников из следующих хранилище. Затем его можно использовать следующим образом:

$ gprof2dot --format=callgrind --output=out.dot callgrind.out.6483

Это создаст файл dot, из которого затем его можно будет преобразовать с помощью библиотеки Graphviz в обычное изображение png как таковое:

dot -Tpng out.dot -o call_graph.png

В зависимости от вашего кода это может быть большое изображение, в нашем случае это был png 4 МБ, и его рендеринг занял много времени.

График понимания:

Выше приведен фрагмент графика вызовов, показывающий, сколько раз вызывался метод и сколько процессорного времени это занимало. Взгляните на голубое поле в правом верхнем углу, оно указывает, что метод Float64.new вызывался 10 752 раза, что составляет 14% от общего времени работы приложения. В нашем коде мы использовали класс BigDecimal для более высокой точности чисел с плавающей запятой, однако базовая линейная алгебра принимала только значения float64, поэтому мы регулярно преобразовывали между двумя типами данных, что влекло за собой значительные накладные расходы без общего повышения точности, например, в в этом случае мы могли бы полностью отказаться от класса BigDecimal в пользу float64.

Еще один момент, на который следует обратить внимание, — это метод to string в Crystal to_s, занимающий около 10% процессорного времени, как указано в самом правом поле посередине. В нашем коде было приличное количество отпечатков для целей отладки, что на самом деле снижало производительность, поскольку отпечатки зависят от ввода-вывода (в данном случае рендеринга экрана), которые тормозят ЦП, поскольку они намного медленнее, чем ЦП, поэтому мы также избавился от ненужных отпечатков. Выполнение только этих двух улучшений позволило увеличить скорость выполнения почти на 20%.

Заключение:

Профилирование позволяет разработчикам увидеть узкие места в своем коде и исправить их. Используя этот подход, мы смогли отказаться от модификации кода для работы на GPU, что является довольно утомительной задачей.