Профилирование eBPF-кода
- title
- Профилирование eBPF-кода
- type
- summary
- summary
- Метод Сринивасана для замера накладных расходов eBPF-хука на открытие файлов: C-обвязка, perf, JIT-символы
- tags
- ebpf, linux, performance, profiling, kernel
- sources
- profiling-ebpf-code
- created
- 2026-07-29
- updated
- 2026-07-29
- lang
- ru
- translation_of
- profiling-ebpf-code
- source_updated
- 2026-07-29
- translated
- 2026-09-01
- translator
- lllm/antigravity/gemini-3.7-flash-medium
Навин Сринивасан описал способ замера накладных расходов LSM-хука eBPF на openat. Эта публикация - о методике, а не о результатах: автор прямо говорит, что цифры зависят от логики хука, и намеренно опускает замеры задержки до и после. В сухом остатке - рецепт, изолирующий стоимость хука в ядре от всего остального в системе.
The harness
Бенчмарк представляет собой один C-файл практически без лишнего кода, поскольку любая операция внутри цикла замера времени отразится на результатах.
for (uint64_t i = 0; i < n; i++) {
uint64_t t0 = now_ns();
long fd = syscall(SYS_openat, AT_FDCWD, path, O_RDONLY); /* raw, no libc wrapper */
uint64_t t1 = now_ns();
if (fd >= 0) close(fd);
d[i] = (uint32_t)(t1 - t0);
}
В основе решения лежат четыре приёма. now_ns() вызывает clock_gettime(CLOCK_MONOTONIC, …), который отрабатывает через VDSO и не требует системного вызова, поэтому само чтение часов не уходит в ядро. Открытие файла выполняется через syscall(SYS_openat, …), а не через обёртку openat() из libc, что исключает лишний слой userspace'а на этом пути. Массив результатов выделяется через mmap с MAP_POPULATE, фиксируется через mlock и однократно заполняется для преаллокации всех страниц (page fault), чтобы исключить прерывания посреди цикла. И никакого вывода до окончания цикла - проход printf запускается уже после, по сохранённым замерам.
Один и тот же файл повторно открывается n раз на прогретом кэше, в чём и заключается суть: измеряется путь системного вызова и хука, а не файловая система или диск. Первые 10% замеров отбрасываются для прогрева, поэтому из 100 000 итераций остаётся 90 000 полезных для подсчёта p50/p99.
Условия запуска важны не меньше самого кода:
sudo taskset -c 3 chrt -f 99 ./bench /etc/hostname 100000 > /tmp/samples.txt
taskset -c 3 привязывает процесс к одному CPU, чтобы миграции между ядрами не создавали шум, а chrt -f 99 выставляет ему приоритет SCHED_FIFO 99 - так бенчмарк выполняется с приоритетом перед почти всеми остальными задачами, пока не завершится, не заблокируется или не будет прерван. Тест запускается один раз с отключённой eBPF-программой для получения базовой линии, затем ещё раз - с подключённой.
Making BPF programs show up in perf
Скомпилированная в JIT программа BPF по умолчанию выглядит как анонимная память ядра, поэтому в профиле она отображается лишь как голый адрес. Это исправляется двумя sysctl'ами:
sudo sysctl -w net.core.bpf_jit_enable=1
sudo sysctl -w net.core.bpf_jit_kallsyms=1
При включённом bpf_jit_kallsyms каждая загруженная программа получает запись вида bpf_prog_<hash>_<name> в /proc/kallsyms, и perf может сопоставлять фреймы стека с именами программ. Сринивасан перед записью проверяет наличие символов с помощью bpftool prog show с фильтром по lsm и поиска grep'ом в /proc/kallsyms по шаблону bpf_prog_[0-9a-f]+_ с ограничением по security|path|file|open. Кроме того, он использует собственную сборку ядра, поэтому нужный бинарник perf отсутствует в $PATH и вызывается напрямую как /usr/lib/linux-tools/6.8.0-134-generic/perf - для корректной работы версии perf и ядра должны совпадать.
Этап записи:
sudo $PERF record -g --call-graph fp -e cycles:k -F 997 -o ~/perf.data \
-- taskset -c 3 chrt -f 99 ./bench /etc/hostname 200000 > /tmp/samples.txt
-e cycles:k собирает сэмплы циклов только в режиме ядра, исключая из профиля цикл бенчмарка в userspace'е и оставляя только вход в системный вызов, VFS, LSM и выполнение BPF. --call-graph fp разворачивает стек по указателям фреймов (frame pointers). Частота сэмплирования 997 Гц намеренно выбрана некруглым числом, чтобы не синхронизироваться с периодическими процессами в системе. Отчёт формируется через perf report --stdio --sort comm,dso,symbol; в оригинале также приводится визуализация того же perf.data в виде flamegraph'а, построенного с помощью Inferno.
Reading the output
Граф вызовов после прогона наглядно показывает картину:
|–90.52%–do_dentry_open
--89.78%–bpf_lsm_file_open
--89.30%–0xffffffffc0288c18
|–87.40%–bpf_prog_b06f413955402a4b_tail_call_security_check
| |–77.57%–bpf_prog_934361d723613c1c_enforce_access_policy
| | |–57.87%–bpf_prog_a0f18f4b0b140d77_path_check_callback
| | | |–29.94%–bpf_probe_read_kernel
| | | | |–18.88%–copy_from_kernel_nofault
| | | | --4.99%–copy_from_kernel_nofault_allowed
| | | |–4.88%–htab_map_hash
| | | --2.29%–copy_from_kernel_nofault
Почти всё время do_dentry_open уходит на bpf_lsm_file_open и вызываемые им по цепочке tail call'ы. Таким образом, хук - это не погрешность округления на пути открытия файла, а фактически весь этот путь и есть. Цепочка tail call'ов сопоставляется с именованными программами благодаря настройке kallsyms, а основные накладные расходы приходятся на bpf_probe_read_kernel (29.94% от всех замеренных циклов ядра). Большая их часть - это copy_from_kernel_nofault, безопасное к сбоям копирование, за которое платит каждое чтение через probe. Доля поменьше - htab_map_hash, поиск по хэш-таблице. Такой профиль указывает на необходимость сокращения числа probe-чтений или добавления кэша перед картой, а не на микрооптимизацию количества инструкций, видимых верификатору. Один фрейм остаётся нераспознанным (0xffffffffc0288c18) - это трамплин (trampoline) между LSM-хуком и программой.
Это практическая часть замеров для всех материалов базы по хукам политик eBPF. В ebpf-sock-ops описывается родственная точка подключения на жизненном цикле сокета TCP, где аналогичный вопрос встаёт для установки соединений, а не открытия файлов; bypassing-dpi-with-ebpf - пример, когда программа встраивается в этот путь при каждом исходящем соединении. little-snitch-linux - пример инструмента, eBPF-слой которого находился бы ровно там же, где и bpf_lsm_file_open, а его известная проблема под нагрузкой - переполнение таблиц кэша внутри ядра - как раз из тех вещей, которые p99 в подобной тестовой обвязке выявил бы раньше, чем о них сообщат пользователи.