EnglishРусский Map

Профилирование eBPF-кода

title
Профилирование eBPF-кода
type
summary
summary
Метод Сринивасана для замера накладных расходов eBPF-хука на открытие файлов: C-обвязка, perf, JIT-символы
tags
ebpf, linux, performance, profiling, kernel
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 в подобной тестовой обвязке выявил бы раньше, чем о них сообщат пользователи.