У каждого, кто профилировал программы под Linux, бывал момент, когда perf показывает обрубленный стек или приписывает время случайной функции. Развёртка по кадрам стека умирает на первом -fomit-frame-pointer, восстановление по DWARF честно вытягивает цепочку, но замедляет каждый сэмпл в разы. У процессоров Intel есть третий путь, о котором знает не каждый: Last Branch Records, кольцевой буфер регистров, куда кремний сам, без единой лишней инструкции со стороны наблюдателя, складывает адреса последних переходов. perf давно научился этим пользоваться, и за пару минут получается картина точнее любой программной развёртки. Дальше разбираемся, как заглянуть в этот журнал, что в нём лежит, где его границы и какие приёмы дают пользу в реальной работе.

Аппаратный журнал переходов, который ядро процессора ведёт само

LBR устроен проще, чем кажется. На каждом логическом ядре живёт набор MSR-регистров, разбитых на пары "откуда" и "куда". Как только конвейер исполняет переход, будь то условная ветка, вызов функции или возврат из неё, железо записывает в следующую ячейку адрес инструкции перехода и адрес цели, а указатель вершины сдвигается на единицу. Дойдя до конца буфера, записи начинают затирать самые старые, поэтому в любой момент регистры содержат последние N переходов, выполненных этим ядром. История пишется сама, пока журнал включён, а в момент срабатывания счётчика perf просто копирует свежую порцию адресов в сэмпл, поэтому для профилируемой программы всё остаётся бесплатным.

Включается и выключается вся машина одним битом. Он называется LBR и занимает нулевой разряд в регистре управления отладкой IA32_DEBUGCTL с адресом 0x1d9. Рядом живут вспомогательные регистры: MSR_LBR_SELECT 0x1c8 задаёт, какие типы переходов вообще попадают в журнал, MSR_LBR_TOS 0x1c9 хранит номер ячейки, куда записали последний переход. Сами пары адресов начиная с Nehalem лежат с базовых адресов 0x680 для "откуда" и 0x6c0 для "куда", а Skylake и её наследники добавили к парам третий регистр MSR_LBR_INFO 0xdc0, где сидят флаги и счётчик тактов.

Плата за ноль служебных инструкций тоже есть, и она честная. Буфер общий на всё логическое ядро, поэтому два гипертрединг-потока пишут в одни регистры по очереди. Ядро Linux решает проблему сохранением состояния через инструкцию XSAVES, когда профиль собирается с реконструкцией цепочек вызовов: уходящий поток уносит содержимое стека с собой, приходящий получает чистую историю. Для аналитика это означает, что цепочки вызовов соседних потоков не перемешиваются, чего не скажешь о ранних реализациях без сохранения состояния.

Глубина стека LBR в разных поколениях процессоров Intel

Главный вопрос про LBR звучит так: сколько переходов он вообще помнит. Ответ зависит от поколения сильнее, чем хотелось бы, и жёстко прописан в исходниках ядра Linux для каждой линейки процессоров.

У Core 2 стек крошечный, всего 4 записи, а пары адресов лежат совсем по другим базам, 0x40 и 0x60. Первые Atom получили 8 записей, причём на ранних степпингах функцию отключали целиком из-за erratum, и ядро печатало об этом предупреждение прямо при загрузке. Silvermont, то есть Bay Trail, и Xeon Phi поколения Knights Landing держат те же 8 записей.

Настоящая эпоха LBR началась с Nehalem. С 2008 года и вплоть до Broadwell, включая Sandy Bridge, Ivy Bridge и Haswell, глубина стабильна, 16 переходов. Skylake переворачивает таблицу: сразу 32 записи, и это значение унаследовали Kaby Lake, Coffee Lake, Comet Lake, Ice Lake, Tiger Lake и Rocket Lake. Забавно, что 32 записи достались и экономным Goldmont, Goldmont Plus и Tremont, тем самым, что стоят в одноплатниках и недорогих ноутбуках.

Alder Lake, то есть 12-е поколение Core, и серверные Sapphire Rapids переходят на Architectural LBR, поддержку которого ядро получило в выпуске 5.9 осенью 2020 года. Здесь глубина впервые стала конфигурируемой: процессор перечисляет допустимые значения через CPUID-лист 0x1C, ядро выбирает максимум и записывает его в MSR_ARCH_LBR_DEPTH 0x14cf, а сама запись в этот регистр мгновенно очищает весь стек. На этих поколениях максимум по-прежнему 32, зато формат записей стал единым для будущих моделей.

А теперь маленький секрет, из-за которого 32 записи оказываются больше, чем 32. В режиме сборки цепочки вызовов Skylake и новее умеют схлопывать пары: прямой вызов, за которым следует правильно предсказанный возврат, занимает в журнале меньше места, чем два отдельных перехода. Физические записи те же, зато логических кадров в цепочке помещается заметно больше. Для глубоких C++ стеков это настоящее спасение.

Проверка поддержки через sysfs и подготовка инструментов

Прежде чем строить планы, стоит выяснить, что даёт конкретный процессор, и Linux показывает это прямо в sysfs. Число в файле ниже и есть глубина стека в записях:

cat /sys/bus/event_source/devices/cpu/caps/branches
32

Там же, в каталоге caps, лежат branch_counter_nr и branch_counter_width, они появляются на самых свежих моделях, где LBR умеет считать события между переходами. На гибридных процессорах у каждого кластера свой каталог: cpu_core для больших ядер и cpu_atom для экономных, и глубины у них могут отличаться, так что проверять стоит оба. При загрузке ядро печатает строку вида "16-deep LBR", и её легко найти:

sudo dmesg | grep -i "deep LBR"

Для работы нужен свежий perf: режим аппаратных цепочек вызовов появился в perf с ядром 4.1 летом 2015 года, версию подскажет perf --version. Для ручной работы с регистрами пригодится пакет msr-tools с утилитами rdmsr и wrmsr, а модуль ядра msr подгружается одной командой. Виртуальным машинам здесь обычно не везёт: гипервизоры редко пробрасывают LBR гостю, и тогда sysfs честно вернёт ноль или ошибку чтения.

Сырые переходы через perf record и их разбор в отчётах

Самый прямой путь к журналу открывает ключ -j у perf record. Команда ниже собирает профиль со сэмплированием переходов только в пользовательском коде:

perf record -e cycles:u -j any,u -F 99 -- ./bench

Дальше начинается самое интересное: perf умеет фильтровать поток переходов ещё на стороне железа, и список фильтров выглядит так:

  1. any берёт все переходы без разбора, самый общий вариант;
  2. ind_call оставляет только косвенные вызовы, то есть переходы через указатели на функции и виртуальные таблицы;
  3. cond ограничивает выборку условными переходами, ровно теми, на которых спотыкается предсказатель;
  4. u и k задают уровень привилегий: пользовательский код, ядро или оба сразу;
  5. stack включает аппаратную сборку цепочки вызовов, в режиме --call-graph lbr perf ставит его сам;
  6. no_flags и no_cycles ужимают сэмпл, отказавшись от флагов промахов и счётчиков тактов.

Отчёт по таким данным строит perf report с ключом -b, он сам распознаёт записи с ветвлениями и переключается в режим анализа переходов. Сортировки symbol_from и symbol_to показывают, между какими функциями летят переходы, а ключ mispredict добавляет колонку с Y и N, промах и попадание предсказателя:

perf report -b --sort symbol_from,symbol_to,mispredict --stdio

Если нужен сырой поток, его вываливает perf script. Поле brstack печатает каждую пару адресов в формате FROM/TO/EVENT/INTX/ABORT/CYCLES/TYPE/SPEC, где EVENT различает M для промаха предсказания, P для попадания и N для непринятого перехода, а TYPE называет тип: COND, CALL, IND_CALL, RET и другие. Вариант brstacksym подставит вместо адресов имена символов, а brstackinsn покажет целые куски ассемблера вдоль пути, правда, только для записей, снятых с -b или -j any:

perf script -F comm,tid,time,brstacksym

Аппаратные цепочки вызовов через режим lbr в perf record

Сырые переходы полезны, но чаще хочется обычного отчёта с деревом вызовов, только честного. Для этого у perf record есть отдельный режим:

perf record --call-graph lbr -e cycles:u -- ./bench
perf report --no-children --stdio

Красота режима в том, что бинарнику не нужны никакие флаги компиляции: ни -fno-omit-frame-pointer, ни отладочная информация для развёртки. Железо само держит историю переходов, а perf сшивает её в цепочку вызовов уже на этапе разбора. Есть и жёсткие границы: работают только пользовательские цепочки, ядро остаётся невидимым, поддержка начинается с Haswell, и этот режим нельзя совмещать с -j, потому что оба претендуют на один и тот же стек переходов. Параметр можно задать и точечно на событии, например -e cycles/call-graph=lbr/.

Разбухшие стеки, которые не влезли в 32 записи, спасает ключ --stitch-lbr у perf report. Он сшивает соседние фрагменты истории в более длинные цепочки, и на типичных переполнениях стеков результат получается полнее, хотя и не безграничен: пары setjmp и longjmp, где возврат не совпадает с вызовом, сшиваются неправильно, и об этом стоит помнить.

Отдельного упоминания заслуживает --total-cycles. Он выводит самые горячие блоки кода с процентом времени и средними тактами на блок, то есть почти готовый список целей для оптимизации. Размер сэмпла тоже оценим: 32 пары адресов по 8 байт плюс информационные поля дают порядка 700-800 байт, так что при 99 сэмплах в секунду файл растёт умеренно, а вот на высоких частотах и многих ядрах буферы стоит увеличить ключом -m.

Чтение регистров LBR вручную через rdmsr и wrmsr

Иногда хочется увидеть журнал без посредников, и Linux это позволяет. Сначала подгружаем модуль msr и включаем запись одним битом в IA32_DEBUGCTL:

sudo modprobe msr
sudo wrmsr -p 0 0x1d9 1

Теперь на нулевом ядре любой код оставляет следы. Запустим в фоне какую-нибудь активность, привязанную к этому ядру командой taskset, и заглянем в стек. Указатель вершины лежит в 0x1c9, а пары адресов начинаются с 0x680 и 0x6c0:

for i in $(seq 0 31); do
  echo "запись $i: from=$(sudo rdmsr -p 0 $((0x680+i))) to=$(sudo rdmsr -p 0 $((0x6c0+i)))"
done
sudo wrmsr -p 0 0x1d9 0

На 16-записных поколениях вроде Haswell цикл стоит ограничить числом 15, старшие регистры там просто не существуют. На поколениях до Skylake флаги спрятаны в самих значениях FROM: старший бит 63 означает промах предсказания, биты 62 и 61 помечают транзакции TSX, и перед интерпретацией адреса эти старшие биты отсекают. Skylake и новее выносят те же поля в отдельный регистр INFO 0xdc0 плюс номер записи, а счётчик тактов между соседними переходами занимает там младшие 16 бит, то есть максимум 65535 тактов на блок. У части моделей тот же счётчик дублируется в старших 16 битах регистра TO, и на редких очень длинных блоках он просто упирается в потолок.

На поколениях с Architectural LBR адреса другие: управление в 0x14ce, глубина в 0x14cf, записи начинаются с баз 0x1500 и 0x1600, а информационные с 0x1200. Правило безопасности простое: после игры с wrmsr обязательно вернуть бит LBR в ноль, как в примере выше, и помнить, что во время профилирования perf перенастраивает эти регистры сам, так что совать руки в журнал стоит только между запусками.

Практические кейсы, ограничения и где LBR не справляется

Первый классический кейс, поиск виновника косвенных вызовов. Виртуальные методы и указатели на функции прячут цель от статического анализа, но не от железа: -j ind_call,u мгновенно показывает, куда на самом деле уходят переходы через таблицы.

Второй кейс, оценка предсказателя переходов. Снимаем -j cond,u, строим отчёт с сортировкой по mispredict и видим функции с высоким процентом промахов. Каждый промах стоит десятки тактов, и на горячих циклах это превращается в заметные проценты производительности, которые лежат прямо на поверхности.

Третье, глубокие C++ стеки. 32 записи с учётом схлопывания пар покрывают большинство реальных цепочек, но встречаются патологии со стеками в несколько сотен кадров. Без --stitch-lbr отчёт честно покажет усечённую версию, а с ним хотя и с оговорками получится длиннее.

Четвёртое, ядро. LBR видит только пользовательский код, а загадочные тормоза внутри системных вызовов он не покажет. Здесь напрашивается Intel PT, трассировка, которая покрывает на порядки больше инструкций, правда, и цена записи у неё выше. Два механизма отлично дополняют друг друга: первый дёшев и точен, вторая полна и подробна, и выбор зависит от того, что важнее, скорость сбора или полнота картины.

Остаются мелочи, которые портят кровь: деление стека между гипертрединг-соседями, потеря истории при переключении без сохранения, отсутствие на виртуалках. И всё же Last Branch Records остаётся самым дешёвым способом получить честные цепочки вызовов на Intel. Один ключ в командной строке, и вместо догадок появляются адреса, которые процессор собрал сам, точно и почти бесплатно.