Coding

Стремительное развитие рынка устройств – как пользовательских, так и промышленных – предъявляет повышенные требования к оптимизации их производительности. От носимых медицинских устройств и бортовых компьютеров автомобилей до серверных стоек и производственных линий – все устройства должны быть оптимизированы по производительности с учетом характеристик электроники и исполняемого ПО. Вопросы производительности наиболее остро стоят для Java-приложений. Особняком стоит проблема профилирования на встраиваемых (embedded) платформах, не оказывая существенного влияния на работу приложения и не останавливая работу. Иногда это необходимо делать в рабочем (production) окружении.

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

  1. При профилировании Java-приложения инструмент вносит искажения в работу программы, часть процессорного времени отбирает сам профилировщик. Так, например, мне в своей работе приходилось сталкиваться со случаями искажения работы приложения в несколько раз. В итоге разработчик видит не реальную картину работы приложения, а очень приблизительную. Зачастую на основе таких данных невозможно принять верное решение.
  2. Java-профилировщики не могут показать поведение самой виртуальной машины Java (JVM), сборщика мусора (GC) и JIT-компилятора.
  3. Иногда требуется выполнить профилирование на встраиваемых (embedded) платформах в рабочем (production) окружении, не оказывая существенного влияния на работу приложения, не останавливая работу. Многие популярные инструменты просто не предоставляют такой возможности.

Моя статья посвящена рассмотрению проверенного на практике решения, которое снимает все вышеперечисленные проблемы. Наиболее удачным для профилирования Java-приложений на встраиваемых платформах без потери их производительности и без искажения получаемых данных будет использование связки нескольких инструментов, а именно: Linux perf, perf-map-agent и FlameGraphs.

  • Perf лишён описанных выше недостатков, но при этом он не умеет определять JITed Java код, и в этой ситуации ему на помощь приходит perf-map-agent.
  • Perf-map-agent – это Java-агент, который подключается к виртуальной машине и генерирует файлы соответствия JIT-кода для perf.
  • В дальнейшем данные, собранные perf-ом, обрабатываются с помощью FlameGraphs и выводятся в виде прекрасных диаграмм, удобных для анализа различных данных, например: Instructions executed, CPU cycles spent, cache hits, cache misses, branch misprediction. Диапазон выводимых параметров зависит от того, какие возможности CPU предлагает для perf.

Приведу пример такой диаграммы со стеком вызовов, где каждому вызову соответствует свой прямоугольник, а его ширина — это количество собранных сэмплов (рис. 1).

Рис. 1. Flame Graph

Теперь рассмотрим внедрение этого стека решений более детально.

Шаг 1: Установка perf в Linux (Ubuntu, Debian)

$ apt-get install linux-perf

Шаг 2: Настройка ядра для обращения к perf счётчикам

Разрешим всем пользователям использовать perf:

$ echo 0 | sudo tee /proc/sys/kernel/perf_event_paranoid

Разрешим пользователям получать символы ядра:

$ echo 0 | sudo tee /proc/sys/kernel/kptr_restrict

Теперь обратимся к общей методологии использования perf.

Шаг 3: Получение списка поддерживаемых счётчиков событий

$ perf list

Шаг 4: Получение высокоуровневой статистики

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

$ perf stat -d -d -d -p $PID sleep 10 > perf_stat_ddd.txt 2>&1

Эти данные покажут высокоуровневую статистику приложения, на основании которой разработчик сможет понять, где кроется проблема: в CPU frontend, CPU backend или же вовсе процессор не является узким местом. Например, когда приложение ограничено пропускной способностью сети или скоростью дисковых операций.

Например:

Performance counter stats for process id ‘1018’:

80 007,47 msec cpu-clock  # 8,000 CPUs utilized
23 065 context-switches # 288,287 M/sec
500 cpu-migrations # 6,249 M/sec
363 086 page-faults # 4538,178 M/sec
34 737 200 848 cycles  # 434177,020 GHz  (30,73%)
81 315 387 195 instructions # 2,34 insn per cycle (38,45%)
14 868 912 156 branches  # 185845140,500 M/sec (38,48%)
136 313 613 branch-misses # 0,92% of all branches (38,53%)
17 790 132 862 L1-dcache-loads # 222357204,520 M/sec (38,56%)
326 141 420 L1-dcache-load-misses # 1,83% of all L1-dcache hits (38,56%)
64 401 058 LLC-loads  # 804942,793 M/sec  (30,80%)
19 811 845 LLC-load-misses # 30,76% of all LL-cache hits (30,76%)
<not supported> L1-icache-loads
251 303 204 L1-icache-load-misses   (30,72%)
17 438 022 455 dTLB-loads # 217956209,519 M/sec (30,72%)
6 546 801 dTLB-load-misses # 0,04% of all dTLB cache hits (30,72%)
4 009 666 iTLB-loads # 50116,440 M/sec  (30,72%)
2 507 515 iTLB-load-misses # 62,54% of all iTLB cache hits (30,72%)
<not supported> L1-dcache-prefetches
<not supported> L1-dcache-prefetch-misses

10,001317995 seconds time elapsed

Давайте посмотрим на эту таблицу более пристально. О чем говорят эти данные? А первую очередь стоит обратить внимание на два ключевых значения: “cycles” и “instructions”. Эти счётчики показывают, сколько было выполнено процессором циклов и инструкций за определённый интервал времени. Одна из ключевых метрик в анализе производительности приложения – это отношение выполненных инструкций к циклам, а именно:

Чем выше значение IPC, тем лучше. На современных процессорах IPC может быть и выше 1, то есть за один такт может быть выполнено более одной инструкции.

Теперь, имея IPC, а в вышеприведенном примере IPC=2,34, что уже довольно неплохой результат, можно попытаться понять в какую сторону двигаться.

Для текущего примера:

Во-первых, нужно стремиться к тому, чтобы приложение занимало 100% процессорного времени и при этом выполняло полезную работу. Сейчас используется только 8% CPU.

Во-вторых, необходимо обратить внимание на LLC-load-misses: в текущем примере промахи в Last Level Cache составляют 30% от попаданий. В дальнейшем эту метрику следует взять для более подробного изучения и построить для неё flame graph. Как это сделать, я рассказываю ниже.

В целом, разработчику необходимо помнить, что конвейер процессора имеет Front-End и Back-End. Front-End выполняет первую часть работы – выборку инструкций, предсказание ветвления, декодирование инструкций в микрооперации (uOps). Таким образом, такие метрики как branch-misses, L1-icache-load-misses показывают проблемы Front-End.

Back-End, в свою очередь, отправляет микрооперации в исполнительные блоки на исполнение. На этом этапе для исполнения микрооперации всё должно быть готово: кэш должен содержать необходимые данные, а исполнительные юниты должны быть свободны. В этом случае метрики L1-dcache-load-misses, LLC-load-misses будут показывать проблемы в Back-End.

Кстати, perf имеет два счётчика именно для проблем в Back-end и Front-end:

stalled-cycles-frontend

stalled-cycles-backend

Шаг 5: Получение профиля приложения с “горячими” методами

Запись функций, которые занимают процессорное время, принадлежащие процессу PID, с частотой 99 Гц, в течение 10 секунд:

$ perf record -F 99 -p $PID sleep 10

Запись стека функций, занимающих процессор. Например, для частоты записи 99 Гц, в течение 10 секунд:

$ perf record -F 99 -ag — sleep 10

В результате выполнения появится файл perf.data.

Шаг 6: Указание конкретного события

Вот так можно записать статистику о промахах в CPU кэш – запись каждого сотого промаха со всей системы на протяжении 10 секунд:

$ perf record -e cache-misses -c 100 -a — sleep 10

Шаг 7: Просмотр результатов в консольном интерфейсе

Получив файл perf.data, его можно открыть в консоли командой $ perf report.

Список значений символов в полученном отчёте:

[.] : user level

[k]: kernel level

[g]: guest kernel level (virtualization)

[u]: guest os user space

[H]: hypervisor

Шаг 8: JVM профилирование

Для профилирования Java-приложений понадобится агент, который я предлагаю скачать из github: git clone.

Сборка происходит следующим образом:

$ cmake .

$ make

В bin есть сразу несколько скриптов-помощников для запуска perf, например, чтобы собрать perf.data:

$ ./bin/perf-java-report-stack.sh $PID $PERF_OPTIONS

Также существует скрипт для генерации диаграммы вызовов:

$ ./bin/perf-java-flames.sh $PID $PERF_OPTIONS

Шаг 9: Создание FlameGraph диаграмм вручную

Первым делом необходимо получить свежий FlameGraph:

$ git clone

Затем, имея perf.data файл, объединяем стеки у одинаковых методов:

$ perf script | ../FlameGraph/stackcollapse-perf.pl > perf.collapsed

Теперь можно сделать красивый профиль приложения:

$ ../FlameGraph/flamegraph.pl perf.collapsed > profile.svg

Для получения более точной картины профиля иногда помогает опция сохранения фрейма стека. Для этого Java-приложению нужно добавить опцию -XX:+PreserveFramePointer и запустить запись профиля, например:

$ java -XX:+PreserveFramePointer -jar myapp.jar &

$ ./perf-map-agent/bin/perf-java-report-stack.sh $JAVA_PID $PERF_OPTIONS

Подытожим вышесказанное. Связка инструментов perf + perf-map-agent + FlameGraphs позволяет изучить работу не только самого приложения, но и JVM. Можно получить данные для улучшения работы GC или другого компонента JVM. Эти инструменты помогают, даже если проблема с производительностью кроется в самой операционной системе.