Привет! Сегодня мы выложили в опенсорс Perforator — систему непрерывного профилирования (continuous profiling), которую используем внутри Яндекса для анализа производительности большинства сервисов.
В Github‑репозитории доступен исходный код системы и инфраструктура для развёртывания своей инсталляции Perforator на кластере Kubernetes. Кроме того, Perforator можно использовать на своём компьютере как более простую замену perf record: профили получаются точнее, а оверхед меньше. Исходный код доступен под лицензией MIT (и GPL для eBPF‑программ) и запускается под x86–64 Linux.
При помощи Perforator и прошлых подходов к задаче профилирования мы регулярно оптимизируем самые крупные сервисы в Яндексе, например Баннерную крутилку или Поиск, на десятки процентов. Кроме того, Perforator реализует недостающий в опенсорсе компонент профилирования для простой автоматической оптимизации программ с использованием profile‑guided optimization. Наши тесты показывают, что использование PGO даёт ускорение около 10% в разных сценариях.
В этой статье
поговорим про профилирование под Linux;
опишем вызовы и сложности, возникающие при профилировании;
изучим, как устроен Perforator внутри;
обсудим, как можно использовать полученную систему.
Зачем профилировать программы
Часто разработчикам приходится выяснять, почему их программа тормозит или использует слишком много ресурсов. Причины могут быть самые разные. Например, заказ от бизнеса на оптимизацию железа или необходимость ускорения программы для увеличения отзывчивости.
Практика показывает, что разработчики не очень хорошо угадывают, где тормозит код. Компьютеры слишком сложны, чтобы простая ментальная модель хорошо предсказывала все детали. На помощь приходят профилировщики — специальные программы, показывающие понятным образом, что происходит внутри программы. С их помощью разработчики находят и оптимизируют узкие места в коде, зачастую добиваясь кратного ускорения. Сайты быстрее загружаются, приложения меньше тормозят, а компании экономят значительные суммы денег на серверах.
Профилировщики настолько полезны, что мэтры программирования не рекомендуют оптимизировать программы вообще, не изучив профиль исполнения. Очень часто при первом запуске программы под профилировщиком находят значительное количество низко висящих фруктов — тривиальных оптимизаций, ускоряющих исполнение на десятки процентов.
Существующие подходы к профилированию
Современный мир разработки справляется с профилированием очень хорошо. Наверное, сколько лет человечество пишет код, столько его и оптимизирует. Существует огромное количество инструментов, позволяющих понять практически всё про исполнение программы, вплоть до анализа потока исполняемых инструкций (Intel PT).
Можно выделить две основных категории профилировщиков:
Инструментирующие профилировщики. Эти инструменты особым образом меняют программу так, чтобы можно было легко собирать статистику о её исполнении. Мы хотим профилировать очень много самых разных программ в разных уголках большого кластера, так что такой подход, скорее всего, не подойдёт: инструментирование зачастую вносит заметный оверхед и, что ещё хуже, требует модификации процесса сборки программ.
Сэмплирующие профилировщики. Можно регулярно останавливать исполнение программы и изучать её состояние. Если повторить процедуру достаточное количество раз, мы получим вполне репрезентативную картину выполнения программы.
Ещё есть смешанные подходы. Например, замечательный tracy одновременно позволяет и инструментировать код, и сэмплировать. Но проблемы инструментирования так или иначе остаются, поэтому дальше будем больше смотреть на сэмплирование.
Профайлер бедного человека
Одна из самых простых и гениальных в своей простоте идей — профайлер бедного человека (ПБЧ). Суть проста: давайте иногда подключаться к интересующей нас программе через gdb, получать стеки всех потоков и продолжать исполнение. Если повторим процедуру несколько раз, получим взвешенный набор стеков. Чем больше времени проведём в конкретном месте программы, тем больше таких стеков соберём. Чем больше стеков соберём, тем выше точность полученного профиля.
Такая идея позволяет получить хорошие результаты за минимальное количество усилий. Например, до внедрения Perforator в Баннерную крутилку — один из самых крупных сервисов Яндекса — долгое время мы пользовались именно ПБЧ. Это очень простой сервис, всего несколько сотен строк кода на Python, который сэкономил огромное количество ресурсов компании. Кстати, про ПБЧ очень интересно рассказывал Антон Полднев.
Однако у ПБЧ есть и минусы. Он достаточно инвазивный и хорошо работает только на кластерах значительного размера, иначе достаточное для репрезентативного профиля число сэмплов собирать сложно. Кроме того, ПБЧ по построению профилирует только wall‑time — реальное время. Например, если ваша программа 50% времени спит, а 50% времени тратит CPU, ПБЧ покажет на профиле и ту, и другую нагрузку. Для анализа CPU time или других событий (например, числа инструкций или промахов мимо кеша процессора) используют альтернативные подходы.
Linux perf
Де‑факто стандартный инструмент для профилирования под Linux — Linux perf. По своей сути это комбайн, вариантов использования perf крайне много, и в них достаточно сложно разобраться. Одним из самых популярных является вариант perf record — сэмплирующий профилировщик. Через подсистему perf_event он подписывается на различные события в системе и собирает релевантную профилю информацию (стеки, имена процессов и потоков и так далее) каждые несколько событий.
Например, команда perf record ‑e instructions ‑c 1 000 000 ‑p 1234
будет сохранять снимок состояния потока каждый миллион инструкций, исполненных в рамках процесса с pid 1234.
Perf позволяет профилировать совсем не только использование времени CPU. Например, можно построить профиль по количеству page faults или доступов в кеши конкретного уровня. Единственное ограничение — поддержка конкретного события со стороны системы (для софтварных событий вроде числа page faults или переключений контекста) или CPU. Занятно, что получить аналогичный ПБЧ профиль по wall‑time через perf нетривиально: нужно как‑то клеить on‑cpu и off‑cpu профили.
FlameGraphs
Для отрисовки профиля обычно используют Flame Graphs от евангелиста профилирования Брендана Грега. Flame Graph — красивый и функциональный интерактивный формат, позволяющий легко находить узкие места и исследовать внутренности программ.
Непрерывное профилирование
В своё время Google выпустил статью про Google Wide Profiler. Это непрерывный профилировщик, который исполняется на всех серверах в нескольких дата‑центрах и позволяет понимать, как работают программы на масштабе всего кластера. Хотя GWP никогда не выкладывался в открытый доступ, из публикаций и рассказов выросла целая отрасль профилирования. Под капотом распределённых профилировщиков обычно находятся классические инструменты, выполняемые на одной машине, вроде того же perf.
Подобные системы собирают огромное количество информации об исполнении сервисов и позволяют отвечать на вопросы в духе «сколько денег сэкономит оптимизация этой функции» или автоматически оптимизировать программы. В частности, Google и Meta* за последние годы выложили в открытый доступ большое количество инструментов и наработок для автоматической post‑link‑оптимизации на основе профиля от сэмплирующего профилировщика (BOLT, AutoFDO). Использование этих подходов позволяет достичь ускорения порядка 10–20% даже относительно сборок с LTO.
Перечисленные технологии достаточно зрелые, используются много лет в самых больших кодовых базах. Однако для удобного использования этих инструментов в CI/CD не хватает одной маленькой детальки: собственно распределённого профилировщика.
Поэтому мы разработали Perforator — распределённый непрерывный профилировщик для дата‑центров.
Зачем писать ещё один профилировщик
Издалека задача выглядит несложной. Надо взять проверенный временем perf, запустить его на всём кластере, тривиальным кодом собрать со всех серверов профили и как‑нибудь проагрегировать. Наверное, этой статьи бы не было, если бы всё прошло по плану. Но, как обычно, дьявол кроется в деталях.
Проблемы с раскруткой стека
Как работает perf? Когда мы делаем perf record -e instructions -c 1000000 -p 1234
, perf через ядро Linux настраивает CPU так, что он начинает считать количество выполненных инструкций через PMU (Performance Monitoring Unit). Это выделенные регистры на процессоре, которые инкрементятся, скажем, каждую инструкцию. Когда счётчик числа событий в PMU переполняется, процессор вызывает особое прерывание. Это прерывание обрабатывается ядром Linux, которое и собирает снимок состояния потока в момент, когда была выполнена миллионная инструкция.
Данная архитектура позволяет очень точно анализировать состояние потока, однако требует реализации значительной части perf прямо внутри ядра Linux (!), так как прерывание необходимо обработать в kernel space. Например, Linux умеет раскручивать стек потока, используя знание про организацию стековых кадров на конкретной архитектуре.
Чтобы perf record начал собирать стеки, нужно добавить флажок --call-graph
. И если поиграться с этим флажком, легко заметить, что часто профили получаются не очень понятные. Типичный битый профиль выглядит как‑то так (в качестве примера выбрана релизная сборка ClickHouse):
Так происходит потому, что современные компиляторы по умолчанию не генерируют указатели стековых кадров (frame pointers). Это позволяет сэкономить пару инструкций на функцию и освободить один регистр, однако лишает нас возможности легко профилировать. У Брендана Грега есть отличный обзор проблемы. Популярным решением стало возвращение frame pointers в сборку. В среднем просадка производительности небольшая, около 1–2%. Такой подход часто используют большие компании и дистрибутивы Linux.
Однако пересобрать все программы и библиотеки с -fno-omit-frame-pointer
сложно. Даже если собрать так основной бинарь, всё равно возникнут системные библиотеки, которые собраны с -fomit-frame-pointer
. И стеки, которые проходят, например, через glibc, получаются битые. Кроме того, точные цифры замедления сильно зависят от конкретной нагрузки. В некоторых сценариях просадка намного заметнее, вплоть до десятков процентов.
Есть альтернативное решение — DWARF. Через него работают отладчики и исключения. Компиляторы достаточно давно начали генерировать специальную секцию, в которой закодировано, как из любой исполняемой инструкции в программе вычислить состояние родительского стекового кадра. Эта секция (.eh_frame
) генерируется даже тогда, когда в программе отключены исключения или их вообще нет (например, в C). Можно отключить генерацию .eh_frame
через флажок -fno-asynchronous-unwind-tables
, однако на практике его использование экономит единицы процентов размера исполняемого файла в обмен на сложности с отладкой и профилированием.
Perf умеет использовать DWARF для раскрутки стека: нужно указать флажок --call-graph=dwarf
. Однако здесь есть очень важный нюанс: DWARF — Тьюринг-полный. Как мы помним, раскрутка стека происходит в прерывании внутри ядра Linux, и поддержка DWARF там практически невозможна: мало того, что его нужно прочитать с диска в прерывании, так и сам код раскрутки получается безумно сложным, потенциально содержащим огромное количество багов. Линус Торвальдс когда-то крайне красноречиво ответил на предложение добавить раскрутку через DWARF в ядро:
I never ever want to see this code ever again.
...
Dwarf unwinder had bugs itself, or our dwarf information had bugs,and in either case it actually turned several "trivial" bugs into a total undebuggable hell.
...
...dwarf is a complex mess ...
...
An unwinder that is several hundred lines long is simply not even remotely interesting to me.
...
just follow the damn chain on the stack without the "smarts" of an inevitably buggy piece of crap.
Поэтому perf делает иначе. Он просто копирует верхушку стека потока в user‑space, а уже потом раскручивает стек в user space, где можно писать произвольно сложный код.
Конечно, весь стек копировать дорого: типично это несколько мегабайт на каждый сэмпл. Поэтому perf вынужден терять часть данных для программ, которые сколько‑нибудь используют стек. Максимум поддерживается 65 528 последних байт стека, при этом размер стека потока под Linux по умолчанию — 8 МБ. Обычно данный подход работает, и получаются неплохие профили. Профиль того же самого инстанса ClickHouse выглядит вот так:
Однако даже на нём видно проблемы. Если приглядеться, то видно стеки, которые раскрутились криво. Один из них выглядит как‑то так:
ZSTD, судя по всему, активно использует стек, и лимита от perf в 65 528 байт не хватило.
Более того, даже без учёта проблем с лимитом на размер стека профили с --call-graph=dwarf
получаются на пару десятичных порядков крупнее, чем аналогичные с --call-graph=fp
. Нужно сохранять вместо небольшого списка адресов возврата весь стек. В итоге массовое использование --call-graph=dwarf
оказывается нецелесообразно дорогим. Профиль за несколько десятков секунд может занимать гигабайты. Если попытаемся уменьшить лимит на размер стека, то профиль начнёт деградировать. Давайте попробуем это проделать:
Устройство Perforator
Perf не подходит. Нужно как‑то раскрутить стек, и обязательно внутри ядра Linux. Какие есть варианты?
Пропатчить ядро. Довольно безумно по сложности: патчи сложно выкатывать и ещё сложнее поддерживать, при этом остаются потенциальные проблемы со стабильностью и безопасностью.
Модуль ядра. Более гибкий механизм для нашего сценария, но стабильность всё равно под вопросом. Так, кстати, была устроена первая версия Google Wide Profiler: использовался OProfile, который до появления Linux perf реализовывал модуль ядра для профилирования. Очень хрупко и ненадёжно.
eBPF. К счастью, относительно недавно появилась крайне мощная технология по запуску небольших не Тьюринг‑полных верифицируемых программ внутри ядра Linux. Сейчас eBPF достаточно популярен для решения задачи профилирования.
Раскрутка стека в eBPF
Linux позволяет запускать eBPF‑программу при срабатывании прерывания от PMU, а также набор вспомогательных функций для чтения памяти в пространстве пользователя или ядра. Таким образом, можно построить аналог perf.
Осталось раскрутить стек, используя DWARF. Вспоминаем, что DWARF сложный и, вообще говоря, Тьюринг‑полный (sic!). К счастью, в конце 2010-х годов мир начал приходить к тому, что эта сложность в DWARF не нужна и можно выразить всё то же самое намного более простыми механизмами (например, 1, 2 и 3). Фундаментальным стало наблюдение из статьи Reliable and Fast DWARF‑Based Stack Unwinding, где авторы обнаружили, что, несмотря на сложность DWARF, компиляторы генерируют более‑менее тривиальные правила раскрутки.
DWARF CFI
Строго говоря, DWARF — это семейство форматов. Для нас представляет интерес DWARF CFI: Call Frame Information. В CFI для каждой инструкции закодировано правило раскрутки: как найти позицию родительского стекового кадра, то есть значения регистров в родительской функции. Вот эти правила и бывают произвольно сложными в терминах программ для виртуальной машины DWARF. Однако современные компиляторы генерируют такую структуру машинного кода, что правило раскрутки всегда тривиально: позиция стека на момент вызова текущей функции вычисляется как смещение от регистра rsp
или rbp
на x86–64. Аналогичные эвристики есть и для других архитектур.
Это значит, что DWARF CFI можно упростить до более‑менее тривиальной структуры, которая теряет всю сложность и легко анализируется eBPF. Дальше дело техники. Полученная схема работает на удивление хорошо. В итоге профилировщик получает из eBPF сырые стеки: набор адресов в бинарном файле.
Символизация
Полученные адреса надо символизировать: преобразовать в имена функций и позиции в исходном коде. Эта задача, как ни странно, тоже не из простых: под Linux необходимое знание обычно лежит также в DWARF, и его тоже крайне сложно парсить. Парсинг требует значительных объёмов памяти и времени CPU. Поэтому мы делаем символизацию в самый последний момент, когда пользователь запросил профиль.
Кроме того, мы почти всегда используем более интересный формат GSYM, который намного компактнее и эффективнее предоставляет структуры для символизации. Информации про GSYM в Интернете немного, его поддерживают разработчики из Meta*, видимо, для аналогичной задачи внутреннего профилировщика.
Perforator конвертирует DWARF для всех бинарных файлов в GSYM, и дальше оперирует только GSYM. Часто это оказывается быстрее, чем символизировать через DWARF, но и такая опция есть.
Оверхед
Полученная конструкция достаточно эффективна. Прямо сейчас мы профилируем такты CPU на значительной части нашего флота через Perforator непрерывно, собирая по 100 сэмплов в секунду с ядра. При этом измеренное замедление пользовательских процессов незначительно — порядка 0,1%. Благодаря этому мы можем включать Perforator сразу на всех. Более того, за месяцы использования системы нам ни разу не пришлось исключать сервисы из‑под профилирования. Поэтому мы верим, что подход достаточно надёжный и стабильный.
Агент использует некоторое количество ресурсов на хосте. Точные цифры сильно зависят от размера хоста, нагрузки, вида бинарных файлов и частоты профилирования, но мы наблюдаем на больших хостах цифры порядка процента CPU хоста и единиц гигабайт памяти.
Архитектура
Агент
На каждом хосте профилируемого кластера мы запускаем хостовый агент Perforator. Его задача — анализировать все процессы на хосте, профилировать интересные (например, контейнеры) и раз в минуту отправлять профили в хранилище.
Кроме того, агент отправляет бинарные файлы вместе с профилями. Это необходимо для офлайн‑символизации: если один и тот же бинарный файл исполняется на тысяче серверов, сильно эффективнее провести символизацию этого файла не на каждой из машинок, а один раз на бэкенде.
Хранилище
Собранные профили нужно где‑то хранить. Для обеспечения масштабируемости конструкции мы собрали хранилище из хорошо известных масштабируемых запчастей. Все бинарные файлы и минутные профили мы храним в S3, а метаинформацию про профили — в ClickHouse. Это позволяет хранить петабайты профилей и за сотни миллисекунд находить интересные профили по сложным селекторам.
Бэкенд
Поверх хранилища находится бэкенд, который реализует удобный gRPC‑интерфейс как для пользователей, так и для агентов. Бэкенд разбит на пачку микросервисов, что позволяет масштабировать нужные компоненты независимо.
Технические детали про организацию кода
Мы опенсорсим инструмент, активно используемый внутри нашего монорепозитория, поэтому в настоящее время используем в качестве системы сборки yamake. Лицензия двойная: почти весь код MIT, за исключением программ на eBPF — это GPL.
Perforator написан на смеси Go (почти весь бэкенд и агент), C (eBPF) и C++ (самые горячие места: агрегация профилей, анализ исполняемых файлов через llvm и так далее). Такая не очень обычная конфигурация стала возможна благодаря гибкости системы сборки. В дальнейшем мы планируем упростить сборку и перейти по возможности на cmake и go build.
Возможности Perforator
Нативные языки
Perforator хорошо поддерживает нативные языки, в первую очередь мы оптимизировали этот сценарий. Точно проверили C++, C, Go, Rust. С большой вероятностью хорошо работают и остальные не слишком экзотические варианты.
Perforator всё ещё иногда не справляется с раскруткой стека. Есть две основных причины:
Раскрутка стека споткнулась о рукописный ассемблерный код. Компиляторы дают возможность разработчикам разметить ассемблерный код для генерации DWARF CFI из него, однако разработчики редко пользуются этой возможностью, а когда пользуются, генерируют достаточно сложные правила CFI.
Некоторые библиотеки и исполняемые файлы отключают генерацию
.eh_frame
через флажок-fno-asynchronous-unwind-tables
. В таком режиме справляется мало какой инструмент.
Однако у нас есть планы, как починить обе проблемы и научиться раскручивать стеки практически идеально.
Интерпретируемые и JIT-компилируемые языки
Кроме нативных языков на масштабе всего кластера крайне интересно одним и тем же инструментом смотреть и на интерпретируемые или JIT‑компилируемые языки. Использование общей системы вместо хороших уже существующих языков позволяет получить кумулятивный эффект от технологии: многие фичи обобщаются и не особо зависят от конкретного языка. Например, можно отобразить профиль из Perforator на исходный код.
Perforator сейчас поддерживает несколько языков. К сожалению, почти каждый новый язык требует довольно неприятных хаков вроде «сохранить смещения внутренних структур для всех версий рантайма Python». Нас вдохновил пример из тестов подсистемы eBPF в Linux, который собирает стеки Python через eBPF, и похожих проектов вроде py‑spy. Сейчас мы умеем раскручивать свежие версии Python (после 3.12). Активно работаем над расширением множества поддерживаемых языков и рантаймов. В первую очередь поддержим профилирование Java без необходимости модифицировать запуск JVM.
Кроме того, Perforator умеет читать де‑факто стандартный механизм для JIT‑компилируемых языков — perf-pid.map
. Этот формат поддерживает значительное число современных рантаймов, однако почти везде нужно включать дополнительные флажки при запуске VM. Так умеют делать Python после 3.12, Java и NodeJS.
FDO: Feedback-driven optimization
Одной из ключевых возможностей Perforator мы считаем поддержку генерации профилей для FDO: feedback‑driven optimization. Это относительно свежий механизм оптимизации программ на основе профиля предыдущих версий той же самой программы. Предыдущая альтернатива — PGO — более громоздкая и из‑за этого сильно менее распространена. Нужно собирать бинарник два раза и в промежутке, как часть пайплайна сборки, поднимать бинарь и воспроизводить реальную нагрузку для снятия профиля. Использование Perforator позволяет сильно упростить процесс. Достаточно просто перед сборкой собрать профиль через API Perforator и сделать его доступным компилятору флажком -fprofile-sample-use
.
Наши бенчмарки показывают ускорение до 10% на самых горячих и оптимизированных программах. Детали можно прочитать в документации.
Быстрые флеймграфы
Изначально для отрисовки профилей мы использовали SVG от оригинального flamegraph.pl. Однако в процессе работы над Perforator мы столкнулись с существенными ограничениями этого формата. По большей части это следствие того, что Perforator собирает значительное количество данных профилирования, заметно больше, чем можно быстро собрать с одного сервера, и профили получаются подробными, но тяжёлыми.
flamegraph.pl достаточно медленный. Даже небольшие профили рендерятся несколько секунд, а типичный результат профилирования в Perforator — минуты.
flamegraph.pl агрессивно удаляет функции с небольшими весами. Это приводит к тому, что профиль сильно теряет в точности и за пару кликов по интересным функциям сильно деградирует.
Если понижать порог, по которому flamegraph.pl удаляет функции, профиль становится крайне большим (вплоть до единиц гигабайт) и неинтерактивным. Отрисовка и клики даже на Apple Silicon занимают десятки секунд из‑за большого количества элементов в DOM: каждая функция в SVG — это несколько нод в DOM, а функций — десятки и сотни тысяч.
С учётом огромного количества интересной информации от профилировщика у нас хотелось научиться хорошо рисовать большие профили. После изучения существующих технологий мы взяли за основу формат flamegraph из замечательного async‑profiler и творчески обработали его: оптимизировали так, что отрисовка флеймграфа на миллион функций занимает меньше 100 мс на современном железе.
Этот формат кардинально меняет восприятие флеймграфов: по ним приятно кликать и исследовать свою программу. На больших программах флеймграфы ощущаются фрактальными: за несколько кликов по редко исполняющимся, но интересным функциям структура профиля всё равно остаётся достаточно подробной.
Данное свойство позволяет использовать флеймграфы не только для оптимизации, но и для чтения и изучения кода. Как ни странно, часто хороший способ познакомиться с объёмной программой — изучить её профиль, а не сразу погружаться в чтение сотен тысяч строк кода. Подробные флеймграфы как раз и упрощают процесс знакомства с кодом.
Локальный профилировщик
Perforator можно запускать в локальном режиме в качестве замены perf record. Это удобный способ получить без какой‑либо модификации программ профиль процесса или всей системы.
Дополнительно Perforator умеет символизировать бинарные файлы без отладочной информации через debuginfod. Достаточно выставить переменную окружения DEBUGINFOD_URLS
в подходящую для вашего дистрибутива. Например, для Ubuntu это будет https://debuginfod.ubuntu.com/
. Подробнее описано в документации.
Wall-time профилировщик
Perforator умеет профилировать не только такты CPU, но и множество других событий по аналогии с perf. Однако, как мы обсудили выше, wall‑time профилировать сложно. Это бывает нужно, когда приходится оптимизировать время ответа программы пользователю. Если она делает хоть что‑то, что отличается от CPU‑bound‑вычислений, то на профиле по CPU cycles данную активность легко пропустить: например, когда процесс читает с диска или ждёт пакетов из сети, CPU он не использует.
Для этого мы научились на уровне агента Perforator объединять время потока на CPU и в ожидании IO. Тут есть нюансы: потоки, которые спят, например, часами, мы не сможем обнаружить. Но для большей части приложений данный метод применим. В итоге получается профиль, где каждый поток занимает одинаковую долю и в котором видно и использование CPU, и ожидание ввода‑вывода.
Профили по срезам: A/B-тестирование
Ещё одной интересной возможностью Perforator стала аннотация собранных стеков различными тегами из кода программы на C++. Как это может пригодиться? Изменения поведения программ часто выкатывают через A/B‑тест. Новую фичу включают на небольшую долю запросов и следят за поведением пользователей или программ на этих запросах. Например, через A/B можно выкатить использование новой тяжёлой ML‑модели или сложного алгоритма. В таком случае новая фича может потреблять сильно больше ресурсов, но на A/B сложно понять, где именно случилось замедление.
Для решения этой проблемы в программах на C++ можно разметить стеки thread‑local‑тегами: строчками или числами, которые дальше будут считаны eBPF‑программой и записаны в профиль. Поэтому можно построить профиль по одному срезу пользователей или A/B‑тесту: записать уникальный идентификатор запроса в теги, после чего отфильтровать полученный профиль по логам системы и отбросить запросы, не попавшие в нужную выборку. Такой механикой мы строим внутри Яндекса профили по некоторым A/B‑тестам.
Из нюансов — данная фича достаточно сильно увеличивает размер профиля, так как каждый сэмпл получает уникальный ключ. Например, раньше по каждому стеку мы получали много сэмплов, которые склеивали в один, а с уникальными тегами так не получится. Изначально для хранения профиля мы использовали стандартный формат из pprof, однако столкнулись с рядом проблем с размером и скоростью обработки профиля. Сейчас же мы сильно дедуплицировали все сущности в профиле, и использование тегов растит размер профиля незначительно.
Minicores: легковесный сбор стеков при фатальных сигналах
Забавной случайной возможностью системы стал сбор стеков при фатальных сигналах. Благодаря гибкости eBPF можно подцепиться на ядерную функцию доставки сигнала потоку, после чего научиться дёшево доставать стеки потоков при получении любых фатальных сигналов, включая SIGKILL.
Это позволяет организовывать легковесный аналог сбора кордампов. На больших кластерах часто ограничивают вероятность откладывания кордампа, чтобы при массовом падении быстрее восстанавливаться. Через Perforator можно с минимальным оверхедом собирать 100% падений.
Более того, сочетая сбор стеков при сигналах с возможностью читать thread‑local переменные, можно понимать, какой именно запрос обрабатывался, что особенно полезно в рантайм‑сервисах.
Ограничения системы
Важно отметить несколько нюансов, потенциально ограничивающих использование системы.
Perforator требует
CAP_SYS_ADMIN
. Через eBPF он может читать произвольную память, включая ядерную, а также ему необходимо уметь читать произвольные бинарные файлы в системе для построения таблиц раскрутки.Perforator требует достаточно свежего ядра Linux, не старше 5.4. В прекоммитных проверках мы проверяем свойства агента на всех LTS‑ядрах после 5.4 включительно. Почему так? eBPF крайне активно развивается, и до 5.4 возможностей eBPF сильно не хватает для написания достаточно сложной программы. В теории можно реализовать бо́льшую часть логики на 4.19, однако это достаточно тяжёлый процесс.
Perforator пока работает под x86–64 Linux. Хоть поддержка ARM и реализуется, мы ещё не готовы выкладывать её. Надеемся, что в будущем она появится.
Раскрутка через DWARF, хоть и хорошо работает почти всегда, иногда всё же ломается. На масштабах нашего флота мы видим единичные проблемы, не связанные с описанными выше сложностями с кодом без
.eh_frame
и рукописным ассемблером, но всё же вероятность ненулевая. В таком случае помогает точечно включать-fno-omit-frame-pointer
.Агент Perforator может требовать заметное количество анонимной памяти для хранения таблиц раскрутки. Это числа порядка единиц гигабайт на больших хостах. У нас есть понятные идеи по оптимизации, хотим целиться в число меньше гигабайта почти всегда.
Как попробовать
Perforator можно попробовать двумя способами.
Простой — запустить локально. Нужно скачать или собрать бинарный файл Perforator, после чего запустить
sudo perforator record -a --duration=60s
. Через минуту Perforator откроет в браузере полученный профиль вашей системы.Сложный — запустить на кластере Kubernetes. Это более трудоёмкий процесс, но мы постарались максимально упростить его. За несколько команд можно развернуть рабочую инсталляцию. Подробнее — в документации.
А ещё можно посмотреть, как в Perforator выглядит результат профилирования.
Мы верим, что такие фундаментальные системные технологии, как операционные системы, компиляторы, системные библиотеки, отладчики и профилировщики, должны быть открыты и развиваться вместе с коммьюнити. А ещё мы верим, что наша разработка будет полезной миру и может принести свою ценность как разработчикам, так и бизнесам. При этом открытость технологии позволяет принимать решения по развитию инфраструктуры профилирования вместе с сообществом.
Например, мы верим, что нужно развивать новые общие форматы: формат хранения правил раскрутки стека вместо DWARF CFI, формат отладочной информации для символизации (GSYM), формат хранения профиля. Мы планируем донести свой опыт до процесса разработки универсального формата профиля в OpenTelemetry.
Perforator теперь есть в открытом доступе на GitHub. Документация и инструкции по установке находятся на perforator.tech.
Мы продолжаем активную разработку, поэтому на старте возможны некоторые шероховатости — их обязательно починим. Будет здорово, если вы заинтересуетесь и попробуете Perforator. Приносите фидбэк в GitHub Issues, а лучше — сразу в Pull Requests!
* Компания Meta признана экстремистской организацией, а её продукты, Facebook и Instagram, запрещены на территории РФ.
Комментарии (21)
maquefel
30.01.2025 08:43Так происходит потому, что современные компиляторы по умолчанию не генерируют указатели стековых кадров (frame pointers). Это позволяет сэкономить пару инструкций на функцию и освободить один регистр, однако лишает нас возможности легко профилировать. У Брендана Грега есть отличный обзор проблемы. Популярным решением стало возвращение frame pointers в сборку. В среднем просадка производительности небольшая, около 1–2%. Такой подход часто используют большие компании и дистрибутивы Linux.
А потом
Однако пересобрать все программы и библиотеки с
-fno-omit-frame-pointer
сложно. Даже если собрать так основной бинарь, всё равно возникнут системные библиотеки, которые собраны с-fomit-frame-pointer
. И стеки, которые проходят, например, через glibc, получаются битые. Кроме того, точные цифры замедления сильно зависят от конкретной нагрузки. В некоторых сценариях просадка намного заметнее, вплоть до десятков процентов.Грег пишет - Ubuntu, Fedora, Arch уже с
-fno-omit-frame-pointer
тогда возникает вопрос, а что используется, если пересобрать "сложно" ? Если уж мы пошли ловить мух - значит у нас всё должно быть под контролем ? А то мало ли как собрали библиотеки.А что можете сказать по поводу ORC unwinder в ядре ?
И хотелось бы пример где одно и то же профилировалось с помощью perf, а потом Perforator.
BigRedEye Автор
30.01.2025 08:43Отличные вопросы, спасибо!
Если уж мы пошли ловить мух - значит у нас всё должно быть под контролем ? А то мало ли как собрали библиотеки.
В большом и сильно гетерогенном окружении это достаточно нетривиально реализовать. На масштабах Яндекса, например, есть точно разные бинарные файлы. Ну и совсем не все исполняемые файлы собираются дистрибутивами. Frame pointers везде – это хороший и правильный, но это не панацея, и так не выйдет достичь идеального качества раскрутки.Например, есть VDSO (стеки приложения через gettimeofday не будут собиратся без DWARF), есть крайне много рукописного ассемблера в ключевых библиотеках и самых горячих местах, есть проприетарные бинарные блобы (libcuda.so), есть JIT-компилированый код и так далее. Нашей основной задачей было добиться идеального качества нативной раскрутки, потому что на масштабе perf нас не устраивал, в том числе из-за проблем на 20% стеков.
А что можете сказать по поводу ORC unwinder в ядре ?
Тоже звучит очень правильно и полезно. В частности, ORC нас вдохновил подумать ещё раз, а можно ли все же DWARF раскручивать, из этого Perforator и вышел. На самом деле, мы бы очень хотели вместе с сообществом по опыту написания профилироващиков и дебаггеров сделать популярным в тулчейнах подобный компактный и простой формат таблиц раскрутки для юзерспейсных приложений. DWARF, все же, is a complex mess. Вон компиляторы значительное количество вычислительных ресурсов тратят на то, чтоб поддержвать DWARF в процессе оптимизации, а будто бы можно сильно проще, если не пытаться генерализовать все подряд.Другое дело, что смысл Perforator в том, что он работает в рамках существующих ограничений.
B хотелось бы пример где одно и то же профилировалось с помощью perf, а потом Perforator.
Вот взял профили Hyprland на свежем Arch, собрал через perf с fp и perforator. Для воспроизведения стоит учитывать, что в Arch все бинари стрипнутые, дебагинфу для perforator достал через debuginfod автоматически, для perf пришлось руками через `perf buildid-cache -a`. Отрендерил через нашу рисовалку профилей: https://perforator.tech/static/perforator-vs-perf/perf.html vs https://perforator.tech/static/perforator-vs-perf/perforator.html. Perf не справляется с блобом от Nvidia, например, и ещё несколько похожих нюансов.
maquefel
30.01.2025 08:43Perf не справляется с блобом от Nvidia, например, и ещё несколько похожих нюансов.
Как раз интересовал момент, когда ВСЁ хорошо - и с перф и с перфоратор, чтобы сравнить =). Но всё равно спасибо, я думаю и так сам в скором времени сравню.
mysherocker
30.01.2025 08:43Планируется ли поддержка arm64? Сейчас с подачи Nvidia серверные вычисления, связанные с видеокартами (GB200 и прочие новые системы с общей памятью) идут в эту сторону.
BigRedEye Автор
30.01.2025 08:43Да, обязательно, над ней активно будем работать. Немного не успели до выхода.
GloveRyba
30.01.2025 08:43Бороться с размером профиля perf частично позволяет компрессия (-z, --compression-level опция). Кроме того, при правильно подобранном уровне снижается и оверхед коллекции.
Не рассматривали SFrame открутку (SFrame based stack tracer for user space in the kernel [LWN.net] )?
Если получилось сделать надежную и быструю DWARF-based открутку стеков, почему бы ее не добавить в perf подсистему Linux, улучшив уже существующий инструмент?
BigRedEye Автор
30.01.2025 08:43Да, с размером на диске помогает, но с трафиком в памяти – нет (на каждый семпл нужно скопировать 65К бессмысленных байт сначала из userspace в kernelspace, потом из kernelspace в userspace, потом ещё внутри perf пару раз потрогать). Memory bandwidh на современных серверах дорогой. Ну и фича довольно новая, экспериментировали пару лет наззад с -z – perf регулярно падал.
Смотрели, да. У нас основная идея – мы не требуем пересборки бинарей. Так что можно только идеями вдохновляться для построения нашей упрощенной таблицы раскрутки для eBPF программы. Но, как в соседней ветке отвечал, в мире явно назрела необходимость начать переходить на какой-то более адекватный формат для раскрутки стека.
Мы думали об этом. Скорее всего, в каком-то виде это можно сделать, с некоторой вероятностью допушим в апстрим. Основные сложности тут скорее технические: мы активно используем LLVM, код as-is переиспользовать будет сложно; ну и архитектура у perf немного отличается от нашей. Но тут очень важно то, что perforator – не только замена perf, но и инфраструктура для cluster-wide профилирования. Там тоже немало подводных камней.
zzzzzzerg
30.01.2025 08:43Планируете ли в рисовалку профилей добавить возможность загружать профили, созданные в других профайлерах?
AterCattus
30.01.2025 08:43Привет. А кроме flamegraph другие форматы отображения доступны? Данный формат временами бывает очень неудобен. Ну или просто возможность получить профиль в prof/pprof формате для дальнейшей работы привычными инструментами.
BigRedEye Автор
30.01.2025 08:43Да. Мы внутри используем pprof (но постепенно мигрируем на новый формат из-за серьезных ограничений в масштабируемости pprof), поэтому через API и CLI можно получить сырой профиль в pprof формате. В CLI надо указать
--format=pprof
.
MrMaxG
30.01.2025 08:43Используете ли вы распределённую трассировку? Комбинация этих двух технологий (профилирование и трассировка) даёт ещё больший эффект на оптимизацию производительности приложений.
Если используете, то почему просто не помогли допилить OpenTelemetry, как это сделал Elastic?
Да и вообще там ребята семимильными шагами идут, уже кучу языков поддерживают.BigRedEye Автор
30.01.2025 08:43Трассировку используем, и Perforator умеет аннотировать семплы идентификаторами трасс. Но там много нюансов: у нас количество семплов настолько большое, что по конкретному идентфикатору трассы лукапить будет крайне дорого. Но уже сейчас внутри активно используем знание про id трассы для профилей по срезам запросов пользователей в батч-режиме.
Про ребят из Elastic знаем, как и про несколько похожих стартапов. eBPF развивается крайне активно, вот только последние годы стало возможным реализовать такого рода профилировщики, и они параллельно с нами развивались в closed source (Elastic купил стартап Optimyze и только совсем недавно выложил в open source). Мы верим, что в нашей разработке есть много полезного для сообщества (в частности, у Elastic агент, это небольшая, хоть и ключевая часть системы).
Сейчас активно думаем, как нам лучше всего проинтегрироваться с сообществом, чтоб кумулятивный эффект был от комбинации разработок.
unreal_undead2
Это даст только понимание где лежит бинарный код - а правила раскрутки как создаёте? Анализ бинарного кода (скажем, как в HPC Toolkit) делается/планируется?
BigRedEye Автор
Часто рантаймы в режиме с perf-pid.map генерируют бинарный код с frame pointers, иначе perf не сможет раскрутить. Поэтому оно не слишком сильно болит. Но в любом случае есть ситуации, где нет ни DWARF, ни frame pointers (рукописный ассемблер из популярного). Для таких ситуаций мы сейчас как раз экспериментируем с анализом бинарного кода для синтеза правил раскрутки стека; есть основания надеяться, что подход сработает
unreal_undead2
По карйней мере в простых случаях (стек фрейм выделяется в прологе и удаляется в эпилоге, в середине sp не меняется и все вызовы cdecl) должно работать надёжно, в том же HPC Toolkit давно сделано (сходу не вспомню в какой конкретно статье описывали детали).
BigRedEye Автор
Да, так и думаем. Эвристика работать очень хорошо должна. Там оно упирается скорее в скорость дизассемблирования всех бинарей, но это решаемо.
Другое дело, что бывают совсем странные случаи, где в рукописном ассемблере сохраняют rsp в xmm регистр; тут стек вообще может не получиться восстановить. Но такого кода, надеюсь, мало
unreal_undead2
Такое скорее только в листовых функциях и на семпле теоретически можно и xmm зачитать. Но я видел только когда какой нибудь rcx в качестве альтернативного фрейм пойнтера при выравнивании стека использовали.
Я с подобным возился когда была актуальна Win32, там сплошные push/pop из за недостатка регистров и паскалевские вызовы, так что посмотреть на пролог/эпилог было недостаточно.
BigRedEye Автор
С xmm проблема скорее в том, что из контекста eBPF-программы их не достать, да и из ядра нетривиально. Но это совсем маргинальный сценарий, конечно, тут проще код переписать. Оригинально вот в этом коде первый раз заметили: https://www.nayuki.io/res/fast-md5-hash-implementation-in-x86-assembly/md5-fast-x8664.S
unreal_undead2
Странный код. Теоретически смысл в том, чтобы за счёт неиспользуемых векторных регистров высвободить побольше скалярных - но кто мешает запользовать r9-r15? Но да, функциональность при этом вполне нужная в production.