Практически каждому разработчику однажды приходилось искать утечки памяти в его приложении / либо же профилировать использование памяти процессом. Задача эта довольно трудоемкая и непростая.
Большинство популярных профилировщиков памяти либо дают неточную информацию (sampling), либо значительно замедляют выполнение (Valgrind, heaptrack). При этом в многопоточной среде они плохо масштабируются и требуют сложной настройки.
Желание совместить детализацию аналогичную heaptrack
и при этом решить проблему с многопоточностью привело к созданию MemHawk
. Новый профилировщик сочетает преимущества традиционного подхода с учетом каждой аллокации, но при этом предоставляет статистику по каждому уникальному стектрейсу без записи каждой аллокации в лог.
В данной статье рассмотрим какие ключевые вопросы нужно решить при разработке своего профайлера, чем не угодили существующие решения и на какие ограничения пришлось пойти.
Сравнительный бенчмарк
Сравнительные данные на синтетическом тесте (16 потоков, создание и удаление std::list
на 10**6
элементов). Ссылка на код бенчмарка.
Profiler / Allocator |
Workers |
Time |
Speedup |
---|---|---|---|
tcmalloc.so + heap profiling |
16 |
50991ms |
82.8x |
heaptrack.so |
16 |
25512ms |
41.4x |
libmemhawk.so + dwarf unwinding (default) |
16 |
1555ms |
2.52x |
libmemhawk.so + frame unwinding |
16 |
1182ms |
1.91x |
libmemhawk.so + dwarf unwinding + jemalloc |
16 |
1120ms |
1.81x |
libmemhawk.so + frame unwinding + jemalloc |
16 |
843ms |
1.36x |
system malloc (baseline) |
16 |
616ms |
1x |
jemalloc.so + heap sampling |
16 |
311ms |
0.5x |
Ссылка на github проекта для самых нетерпеливых: https://github.com/IlRomanenko/MemHawk
Требования к профилировщику памяти
Выделение памяти — одна из наиболее частотных операций в любом приложении. Типичное приложение совершает порядка 100 тысяч вызовов malloc
/free
в секунду. Это делает детальное профилирование крайне затратным как по времени, так и по влиянию на производительность. Дополнительно усложняет задачу тот факт, что аллокации происходят из множества потоков, часто распределённых по NUMA-нодам, и сам профилировщик должен либо исключать свои аллокации, либо обходиться без них.
Имея эти вводные можно вывести требования, которые нужны от профилировщика памяти:
Поддержка многопоточности — инструмент не должен деградировать при росте числа потоков, а синхронизация должна быть либо lock-free, либо минимальной по времени.
Точный подсчет аллокаций — необходимо отслеживать все malloc/free, сопоставляя их с вызывающим кодом.
Гибкие механизмы раскрутки стека — для приложений собранных без frame-pointer'а требуется полноценная DWARF раскрутка.
Отсутствие зависимости от сторонних библиотек — минимальное количество зависимостей и возможность работы на старых системах
Малая инвазивность — профилировщик не должен влиять на логику приложения, нарушать семантику аллокаций или менять поведение потоков.
Анализ существующих решений
Проведём сравнительный анализ популярных профилировщиков памяти по ключевым характеристикам:
Инструмент |
Детализация |
Масштабируемость |
Раскрутка стека |
Преимущества |
Ограничения |
---|---|---|---|---|---|
heaptrack |
каждая аллокация |
ограниченная (mutex) |
DWARF |
Наличие GUI, высокая детализация |
Высокий overhead, зависимость на libunwind |
tcmalloc |
каждая аллокация |
ограниченная (mutex) |
frame-pointer |
Быстрый аллокатор |
Нет поддержки DWARF, тяжелые логи |
jemalloc |
семплирование, сходимость по вероятности |
высокая (sampling) |
DWARF |
Быстрый аллокатор |
Нетривиальность в использовании, не является профайлером. |
BPF (bcc) |
каждая аллокация, но с потерями |
ограниченная |
frame-pointer |
Возможность профилировки ядра |
Возможность потери информации по аллокации |
Таким образом, ни одно решение не предоставляет одновременно: точность событий, низкий overhead и поддержку масштабирования.
Исходя из имеющихся вводных напрашивался профилировщик памяти, который бы находился между heaptrack
и jemalloc
: позволял бы эффективно работать в условиях многопоточного приложения, но предоставлял бы точный подсчет числа аллокаций.
N.B. Интересующиеся, как сделано семплирование в jemalloc
, приглашаются в статью - https://github.com/jemalloc/jemalloc/blob/dev/doc_internal/PROFILING_INTERNALS.md
Архитектура MemHawk
Сначала пройдемся по основным особенностям библиотеки, примерам ее использования, а потом погрузимся в детали реализации.
Основные особенности
Агрегация аллокций по стектрейсам — учитываем все аллокации, но гранулярность увеличиваем до конкретного стектрейса.
Thread-local хранилище — каждый поток имеет свой трекер, что устраняет необходимость в глобальных блокировках.
Два режима записи — либо текстовый формат с топ-10 стектрейсов по суммарному потреблению памяти / числу аллокаций, либо protobuf файл с полным дампом стектрейсов и суммаризированных аллокаций.
Схлопывание рекурсии — уменьшаем число уникальных стектрейсов за счет схлопывания повторяющихся фреймов (аналог RLE).
Два режима раскрутки стека — через DWARF (libunwind) и по frame-pointer(absl). Выбор режима возможен через конфигурацию.
Статическая линковка — избавляемся от проблемы с возможным отсутствием зависимостей/несовпадения версий.
Возможность самопрофилирования — MemHawk корректно учитывает собственные аллокации и может рапортовать их при необходимости (по умолчанию эта опция выключена).
От чего пришлось отказаться
Гранулярность до единичной аллокации — как показала практика использования heaptrack, этого практически никогда не нужно, а ресурсов требует значительно + существенно увеличивает дамп файл.
Возможность inject'инга в работающее приложение — реализация данного функционала возможна, но на этапе тестирования PoC`а она показала худшие результаты по скорости работы, большее число блокировок и требования к памяти.
Пример использования
В качестве подопытного процесса у нас сегодня будет https://github.com/KDE/filelight. На его примере явным образом увидим стектрейсы, которые можно оптимизировать.
Для начала запустим процесс с профилировщиком:
❯ LD_PRELOAD=./libmemhawk.so filelight
N.B. Помимо механизма LD_PRELOAD
можно также использовать https://github.com/NixOS/patchelf, который позволяет добавить библиотеку в список необходимых для динамического загрузчика. Данный метод работает даже с бинарями, у которых выставлен suid/guid бит (при условии, что у вас есть root права). Либо же можно вкомпилить профилировщик в ваше приложение, хоть это и не советуется.
В результате в текущей директории появится 3 файла (main_log, stacktraces, summary), с указанием имени процесса и pid
:
❯ ll
total 11M
-rwxr-xr-x 1 ilyasm ilyasm 11M Jul 20 17:21 libmemhawk.so
-rw------- 1 ilyasm ilyasm 4.5K Jul 20 17:22 memhawk_filelight_314622_main_log.log
-rw-r--r-- 1 ilyasm ilyasm 55K Jul 20 17:22 memhawk_filelight_314622_stacktraces.log
-rw-r--r-- 1 ilyasm ilyasm 37K Jul 20 17:22 memhawk_filelight_314622_summary.log
Содержимое main_log.log
В файле main_log лежат логи MemHawk, по дефолту уровен логирования - Info. По выходу процесса репортятся статистики по каждому трекеру, включая кеш-миссы для lru кеша стектрейсов.
❯ /usr/bin/cat memhawk_filelight_314622_main_log.log
[Info]:init_memhawk :: [314622]
[Info]:MemHawk :: Start MemHawk()
[Info]:PostponedConstruct :: MemHawk fully initialized
[Info]:TrackingWorker :: Tracking worker started
[Info]:TrackingWorker :: Tracking worker finished
[Info]:Stop :: Total trackers: 26, empty: 0, max postponed: 4
[Info]:Stop :: Inner traces: (316, 2642), external: 173945
[Info]:PrintTracker :: TrackerId: 0, allocSummaries: (0,127), lru: 256, cache: (miss-ratio: 0.456, misses: 436792, usages: 958184), allocs: 958184, deallocs: 988303
[Info]:PrintTracker :: TrackerId: 1, allocSummaries: (0,0), lru: 256, cache: (miss-ratio: 0.454, misses: 380, usages: 837), allocs: 837, deallocs: 795
[Info]:PrintTracker :: TrackerId: 2, allocSummaries: (0,0), lru: 33, cache: (miss-ratio: 0.009, misses: 33, usages: 3563), allocs: 3563, deallocs: 2277
...
[Info]:PrintTracker :: TrackerId: 23, allocSummaries: (0,127), lru: 256, cache: (miss-ratio: 0.001, misses: 812, usages: 1173918), allocs: 1173918, deallocs: 478297
[Info]:PrintTracker :: TrackerId: 24, allocSummaries: (0,127), lru: 256, cache: (miss-ratio: 0.001, misses: 1317, usages: 1287975), allocs: 1287975, deallocs: 526882
[Info]:PrintTracker :: TrackerId: 25, allocSummaries: (0,127), lru: 256, cache: (miss-ratio: 0.002, misses: 1936, usages: 1223661), allocs: 1223661, deallocs: 503216
[Info]:Stop :: InnerTracker
[Info]:PrintTracker :: TrackerId: 0, allocSummaries: (0,127), lru: 0, cache: (miss-ratio: 0.000, misses: 0, usages: 0), allocs: 210795, deallocs: 6332
Содержимое summaries.log
В основном файле summaries.log находятся как раз агрегированная информация об аллокациях с привязкой к стектрейсам. Так же публикуется информация о том, сколько действительно выделено памяти приложением, какой оверхед на хранение метаинформации, сколько памяти используется MemHawk
.
Публикуются 2 вида стектрейсов: по суммарному активному размеру и по количеству аллокаций всего. Первые - вносят наибольший вклад в потребление памяти приложением, вторые - кандидаты на оптимизацию.
Каждая запись содержит:
TraceId
стектрейса, по которому его можно искать в файлеstacktraces.log
Cуммарный размер активных аллокаций
Число активных аллокаций
Суммарное число аллокаций
Средний размер аллокации (считается как
суммарный размер активных
/число активных аллокаций
)Оверхед на хранение информации по аллокациям
2025-07-20T17:22:13.1626456+03:00
Application heap: size: 332.598 mb, active: 6089431, total: 11116384, average: 57.272 bytes, overhead: 92.918 mb
MemHawk heap: size: 48.140 mb, active: 204186, total: 210301, average: 247.217 bytes, overhead: 3.117 mb
Total traces: 173462, updated since last time: 18569
External index:
ByActiveSize
TraceId: 440633, size: 82.386 mb, active: 1199826, total: 1199826, average: 72.000 bytes, overhead: 18.308 mb
TraceId: 440636, size: 40.886 mb, active: 1198535, total: 1198535, average: 35.771 bytes, overhead: 18.288 mb
TraceId: 440637, size: 27.462 mb, active: 1199826, total: 1199826, average: 24.000 bytes, overhead: 18.308 mb
TraceId: 440641, size: 27.168 mb, active: 100223, total: 208339, average: 284.247 bytes, overhead: 1.529 mb
...
ByTotalCount
TraceId: 440645, size: 0.000 mb, active: 0, total: 1206461, average: 0.000 bytes, overhead: 0.000 mb
TraceId: 440637, size: 27.462 mb, active: 1199826, total: 1199826, average: 24.000 bytes, overhead: 18.308 mb
TraceId: 440633, size: 82.386 mb, active: 1199826, total: 1199826, average: 72.000 bytes, overhead: 18.308 mb
TraceId: 440636, size: 40.886 mb, active: 1198535, total: 1198535, average: 35.771 bytes, overhead: 18.288 mb
...
N.B. По данному логу хорошо видны точки возможной оптимизации для filelight
:
TraceId: 440645
с 0 активных аллокаций, при этом было совершено всего1206461
аллокаций (временные аллокации).TraceId: 440637
,TraceId: 440633
- стектрейсы, аллоцирующие большое число маленьких объектов (24 и 72 байта на каждую аллокацию).
Содержимое stacktraces.log
В файле stacktraces
находятся стектрейсы, которые были упоминались хотя бы один раз в summaries.log
, таким образом отфильтровываются стектрейсы не представляющие интереса. По каждому стектрейсу выполняется резолвинг символов, если это возможно. В примере ниже хорошо видно, что для filelight есть дебажные символы, а вот для libQt6Core - уже нет.
Формат записи: <vaddr>: <binary> + <offset> : <resolved name>
TraceId: 440633
0x55762bfb8659: /usr/bin/filelight + 31659: _ZN9Filelight11LocalLister4scanERK10QByteArrayS3_
0x55762bfba461: /usr/bin/filelight + 33461: _ZNSt17_Function_handlerIFvvEZN9Filelight11LocalLister4scanERK10QByteArrayS5_EUlvE_E9_M_invokeERKSt9_Any_data
0x55762bfb89a2: /usr/bin/filelight + 319a2: _ZN9Filelight11LocalLister4scanERK10QByteArrayS3_
0x55762bfba461: /usr/bin/filelight + 33461: _ZNSt17_Function_handlerIFvvEZN9Filelight11LocalLister4scanERK10QByteArrayS5_EUlvE_E9_M_invokeERKSt9_Any_data
0x55762bfb6482: /usr/bin/filelight + 2f482: _ZN9QRunnable16QGenericRunnable6HelperISt8functionIFvvEEE4implENS0_10HelperBase2OpEPS6_Pv
0x7ff13b7277b3: /usr/lib/libQt6Core.so.6.9.1 + 3277b3:
0x7ff13b71ee69: /usr/lib/libQt6Core.so.6.9.1 + 31ee69:
0x7ff13aea57eb: /usr/lib/libc.so.6 + 957eb: start_thread
0x7ff13af2918c: /usr/lib/libc.so.6 + 11918c: __clone3
TraceId: 440645
0x7ff13b6a06a2: /usr/lib/libQt6Core.so.6.9.1 + 2a06a2: _ZN10QArrayData9allocate1EPPS_xNS_16AllocationOptionE
0x7ff13b62d0c8: /usr/lib/libQt6Core.so.6.9.1 + 22d0c8: _ZN10QByteArrayC1EPKcx
0x55762bfb7d3f: /usr/bin/filelight + 30d3f: _ZN11POSIXWalker4nextEv
0x55762bfb876b: /usr/bin/filelight + 3176b: _ZN9Filelight11LocalLister4scanERK10QByteArrayS3_
0x55762bfba461: /usr/bin/filelight + 33461: _ZNSt17_Function_handlerIFvvEZN9Filelight11LocalLister4scanERK10QByteArrayS5_EUlvE_E9_M_invokeERKSt9_Any_data
0x55762bfb89a2: /usr/bin/filelight + 319a2: _ZN9Filelight11LocalLister4scanERK10QByteArrayS3_
0x55762bfba461: /usr/bin/filelight + 33461: _ZNSt17_Function_handlerIFvvEZN9Filelight11LocalLister4scanERK10QByteArrayS5_EUlvE_E9_M_invokeERKSt9_Any_data
0x55762bfb6482: /usr/bin/filelight + 2f482: _ZN9QRunnable16QGenericRunnable6HelperISt8functionIFvvEEE4implENS0_10HelperBase2OpEPS6_Pv
0x7ff13b7277b3: /usr/lib/libQt6Core.so.6.9.1 + 3277b3:
0x7ff13b71ee69: /usr/lib/libQt6Core.so.6.9.1 + 31ee69:
0x7ff13aea57eb: /usr/lib/libc.so.6 + 957eb: start_thread
0x7ff13af2918c: /usr/lib/libc.so.6 + 11918c: __clone3
Использование addr2line
В случае, если дебажные символы лежат отдельно, то резолвинг имен функций все равно возможен после профилирования, для этого достаточно воспользоваться утилитой addr2line
. Ее так же можно использовать, чтобы получать стектрейс инлайненных функций. Для этого указываете путь до бинаря/отдельно лежащих дебажных символов и смещение, которое выдал MemHawk
.
❯ addr2line -f -i -C -e /usr/lib/libQt6Core.so.6.9.1 0x3277b3
QThreadPool::metaObject() const
??:?
Более подробный пример с отдельно лежащими дебажными символами, в качестве бинаря используется синтетический тест нагрузки.
❯ strip --only-keep-debug ./bench_allocs -o symbols.sym
❯ strip --strip-all ./bench_allocs -o stripped
❯ nm stripped
nm: stripped: no symbols
❯ nm symbols.sym | head -n 2
00000000000050e8 B __bss_start
U __cxa_atexit@GLIBC_2.2.5
❯ LD_PRELOAD=./libmemhawk.so ./stripped
Workers: 8 Time: 1130ms
❯ ll
total 11M
-rwxr-xr-x 1 ilyasm ilyasm 109K Jul 20 18:11 bench_allocs
-rwxr-xr-x 1 ilyasm ilyasm 11M Jul 20 17:21 libmemhawk.so
-rw------- 1 ilyasm ilyasm 1.9K Jul 20 18:12 memhawk_stripped_408056_main_log.log
-rw-r--r-- 1 ilyasm ilyasm 2.3K Jul 20 18:12 memhawk_stripped_408056_stacktraces.log
-rw-r--r-- 1 ilyasm ilyasm 3.0K Jul 20 18:12 memhawk_stripped_408056_summary.log
-rwxr-xr-x 1 ilyasm ilyasm 19K Jul 20 18:12 stripped
-rwxr-xr-x 1 ilyasm ilyasm 98K Jul 20 18:12 symbols.sym
❯ grep "TraceId: 14" memhawk_stripped_408056_stacktraces.log -A 6
TraceId: 14
0x564cb099d44a: /home/ilyasm/projects/cpp/memhawk/temp/stripped + 244a:
0x7fa1004e51a4: /usr/lib/libstdc++.so.6.0.34 + e51a4: execute_native_thread_routine
0x7fa1001807eb: /usr/lib/libc.so.6 + 957eb: start_thread
0x7fa10020418c: /usr/lib/libc.so.6 + 11918c: __clone3
❯ addr2line -f -i -C -e ./stripped 0x244a
??
??:0
❯ addr2line -f -i -C -e ./symbols.sym 0x244a
bar()
/home/ilyasm/projects/cpp/memhawk/tests/bench_allocs.cpp:28
foo()
/home/ilyasm/projects/cpp/memhawk/tests/bench_allocs.cpp:34
worker()
/home/ilyasm/projects/cpp/memhawk/tests/bench_allocs.cpp:48
Опции запуска
Есть множество опций, регулирующих различные аспекты работы профайлера. Все опции передаются через переменную окружения MEMHAWK_OPTS
. Полный список можно получить запустив профайлер на любом бинаре с передачей опции MEMHAWK_OPTS=help=1
. Разделитель для опций - :
❯ MEMHAWK_OPTS=help=1 LD_PRELOAD=./libmemhawk.so filelight
full set of fields in config:
progname_regex =
memhawk.tracking_worker = true
memhawk.max_postponed = 512
memhawk.dumping_period = 1000
memhawk.lru_size = 256
memhawk.int_tracker.dump = false
memhawk.int_tracker.filename = {}
memhawk.ext_tracker.dump = false
memhawk.ext_tracker.filename = {}
memhawk.writers.text_writer.enabled = true
memhawk.writers.text_writer.filename = {}
memhawk.writers.text_writer.ext.enabled = true
memhawk.writers.text_writer.ext.by_size_count = 10
memhawk.writers.text_writer.ext.by_total_count = 10
memhawk.writers.text_writer.int.enabled = false
memhawk.writers.text_writer.int.by_size_count = 10
memhawk.writers.text_writer.int.by_total_count = 10
memhawk.writers.proto_writer.enabled = false
memhawk.writers.proto_writer.filename = {}
memhawk.collapse_depth = 8
logging.main_log = true
logging.log_level = Info
logging.log_dir = ./
unwind.absl = false
unwind.track_depth = 32
help = false
Дизайн библиотеки
Данная секция описываем, как и почему были сделаны те или иные инженерные решения при проектировании и разработке профилировщика.
Хранение информации по аллокациям
Для начала давайте посчитаем, сколько информации нужно сохранить с каждой аллокацией, чтобы при деаллокации корректно учесть изменение.
N.B. Размер может разниться в зависимости от платформы, поэтому вычисления ниже приведены для x86_64
8 байт - сам указатель
8 байт - размер выделяемой памяти
8 байт - размер смещения для выравнивания (опционально)
4-8 байт - id стектрейса, из которого была произведена аллокация.
Нижняя оценка аллокации - 12 байт (размер + id стектрейса). Уменьшение памяти, которое требуется на саму аллокацию возможно - например при разделении аллокаций на относительно небольшие (с размером до 2**32
) и крупные (более 2**32
).
Итоговая оценка - 8--32 байта в зависимости от реализации.
N.B. В зависимости от реаллизации malloc, примерно похожии цифры вы будете видеть в виде оверхеда непосредственно вашей библиотеки, реализующей malloc. Поэтому использование большого количества маленьких объектах считается плохим тоном в любом приложении.
Дальше есть как минимум 2 варианта того, где хранить информацию про аллокацию - сразу вместе с данными или отдельно.
Рассмотрим оба подхода:
-
Хранение отдельно от данных:
Плюсы: можно безопасно загружать/выгружать профайлер, подцеплять его к работающему процессу (как и делает heaptrack).
Минусы: необходимо хранить еще и сам указатель, что увеличивает накладные расходы.
-
Хранение вместе с данными:
Плюсы: уменьшаем размер доп.информации по аллокации, т.к. в вызове free уже знаем указатель, который необходимо деаллоцировать.
Минусы: теряем возможность прицепить профайлер к работающему процессу, выгрузить из работающего процесса, т.к. нужно в вызовах malloc/free учесть смещения относительно пользовательских данных.
Изначально профайлер проектировался с хранением аллокаций отдельно, но первые эксперименты показали несостоятельность этой идеи. Оверхед на доп.хранение указателей и доп. блокировки общих данных оказался слишком велик. Дальнейшая реализация пошла по пути хранения метаинформации по аллокации вместе с данными (как и реализуют многие аллокаторы).
Для сравнения в glibc реализации malloc (glibc malloc chunks design) минимальный размер выравнивания аллокации - 16 байт. И каждая аллокация сделанная через malloc будет выровнена как минимум на 16 байт. Именно это значение и было выбрано в качестве ограничения на размер структуры с метаинформацией по аллокации.
Формат структуры, в которой хранится информация по аллокациям.
struct AllocInfo
{
uint64_t size{};
uint32_t offset{}; // in case of alignment
uint32_t traceId{};
};
N.B. 4 байта на смещение вместо 8 байт - компромисс, который был сделан при дизайне библиотеки. При указании выравнивания больше 4 байт профайлер вызывает abort.
Сама структура располагается сразу перед пользовательскими данными, при этом возможен дополнительный паддинг в зависимости от требования к выравниванию данных. Схема расположения в памяти:
[ указатель от malloc() ] ->
[ padding (if necessary) ...]
[ AllocInfo (16 байт) ]
[ пользовательские данные ] <- [ указатель для пользователя ]
Для примера рассмотрим что происходит при вызове aligned_alloc
.
constexpr size_t AdditionalSize = sizeof(AllocInfo);
// Ensure, that we won't ruin malloc invariant
static_assert(alignof(max_align_t) == sizeof(AllocInfo));
auto align_ceil(auto value, size_t alignment)
{
return (value + alignment - 1) / alignment * alignment;
}
...
void* hawk_aligned_alloc(size_t align, size_t size)
{
if (unlikely(!hooks::gl_initialised))
{
return mmap_alloc_aligned(size, align);
}
LogTrace("requested: " fSzt, size);
auto alignedSize = align_ceil(AdditionalSize, align);
void* ptr = hooks::aligned_alloc(align, (size + alignedSize));
if (unlikely(!ptr))
{
return nullptr;
}
AllocInfo* info = reinterpret_cast<AllocInfo*>(reinterpret_cast<char*>(ptr) + alignedSize - AdditionalSize);
*info = AllocInfo{size, static_cast<uint32_t>(alignedSize)};
ptr = reinterpret_cast<char*>(ptr) + alignedSize;
LogTrace("result: " fPtr, ptr);
if (auto memhawk = hooks::GetMemHawk(); likely(memhawk))
{
auto trace = Stacktrace::Unwind(*hooks::gl_unwind.TrackDepth, *hooks::gl_unwind.UseAbslStacktraces);
memhawk->TrackAlloc(*info, std::move(trace));
}
return ptr;
}
Рассчитывается доп. размер, который нужно запросить у
aligned_alloc
, чтобы удовлетворить выравнивание пользовательских данных и было достаточно места для размещенияAllocInfo
Размещаем
AllocInfo
в первых 16 байтах перед пользовательскими данными. Оставшиеся байты от указателя, вернувшегося изhooks::aligned_alloc
доAllocInfo
будут пустовать и являться паддингом.В
AllocInfo.offset
записываем смещение исходного указателя относительно пользовательских данных (фактически - размер паддинга с учетомAllocInfo
) - этоalignedSize
.Если
MemHawk
проинициализирован, то раскручиваем стек и сохраняем информацию по аллокации в локальный для треда трекер.
Логика при деаллокации похожая - нужно исходя из указателя на пользовательские данные найти корректный указатель, на котором можно позвать free
.
void hawk_free(void* ptr)
{
// skip nullptr
if (!ptr)
{
return;
}
AllocInfo* info = reinterpret_cast<AllocInfo*>(reinterpret_cast<char*>(ptr) - AdditionalSize);
ptr = reinterpret_cast<char*>(ptr) - info->offset;
// check if pointer was allocated during statics initialisation
if (unlikely(info->traceId == InvalidTraceId))
{
mmap_free(ptr, info->size);
return;
}
// highly unlikely situation, got pointer
// that wasn't allocated from memhawk during statics initialisation
// and memhawk wasn't constructed yet
if (unlikely(!hooks::gl_initialised))
{
// will be printed into stderr
LogError("Got pointer from static-initialisation, that wasn't allocated via memhawk: " fPtr, ptr);
abort();
}
LogTrace("requested: " fPtr, ptr);
if (auto memhawk = hooks::GetMemHawk(); likely(memhawk))
{
auto trace = Stacktrace::Unwind(MinUnwindDepth, *hooks::gl_unwind.UseAbslStacktraces);
memhawk->TrackDealloc(*info, trace);
}
hooks::free(ptr);
}
Вычисляем изначальный указатель, зная, что в первых 16 байтах перед пользовательскими данными лежит
AllocInfo
, а вAllocInfo.offset
записан полный размер паддинга, если он есть.Рассматриваем случаи связанные с порядком инициализации библиотек (нужно учесть, что часть аллокаций происходит на этапе конструирования глобальных объектов при старте приложения)
Учитываем деаллокацию, если
MemHawk
проинициализирован. Тут так же раскручиваем стек на минимальное количество фреймов, необходимо для учета внутренних аллокацийMemHawk
.В самом конце вызываем
free
на исходном указателе.
Работа с многопоточностью
Следующий важный шаг в дизайне - определить, как уменьшить накладные расходы в многопоточном приложении, т.к. именно это и подтолкнуло изначально к попытке реализовать свой профилировщик.
Традиционные решения:
-
Пул мьютексов — данное решение тестировалось на этапе реализации хранения метанформации отдельно от данных, по итогу такой подход сильно зависит от соотношения числа потоков и числа мьютексов.
N.B. Для желающих - можете посчитать вероятность 2 или более потоков начнут лочить один и тот же мьютекс при условии, что мьютекс выбирается равномерно случайно из всего пула.
Ниже приведены минимальные выкладки, дляn
потоков, каждый из которых равномерно выбирает один изk
мьютексов и считаем вероятность для конкретного мьютекса, что на него попадут 2 или более потока.
При подстановке параметров n = 16
, k = 8
, получаем, что даже для зафиксированного мьютекса вероятность взаимной блокировки нескольких потоков ~ 0.61. Если расширить эти вычисления, приняв во внимание все возможные мьютексы, то вероятность получить блокировку 2 или более потоков на каком либо из мьютексов окажется близка к 1.
Lock-free алгоритмы — сложнее в реализации и фактически не решают проблему синхронизации, т.к. потоки конкурируют за доступ к общей структуре данных.
Предлагаемое решение:
TLS + асинхронная агрегация — позволяет обрабатывать аллокации и деаллокации каждого треда в отдельности, существенно уменьшаем частоту блокировок, увеличиваем локальность данных. Платим за это уменьшением дискретизации профилирования.
Lru-cache стектрейсов для каждого потока — минимизируем обращения к глобальному хранилищу стектрейсов при предположении, что для большинства потоков число активных стектрейсов с аллокациями не велико.
Spinlock для доступа к глобальным объектам — используем spinlock для доступа к общим данным, фактически единственный объект, за который могут сражаться треды - глобальное хранилище стектрейсов.
Следующая диаграмма показывает взаимосвязь между компонентами, происходящую при вызове malloc
. Единственный компонент, который требует глобального спинлока - StacktraceTracker
.
Учет собственных аллокаций
Интересная фича — возможность профилировщика учитывать собственные аллокации, однако это сразу накладывает ограничения на дизайн. Основная сложность - корректно обработать ситуацию, когда выделенной памяти под внутренние структуры не хватает и происходит аллокация новой памяти, что триггерит обработку этой аллокации, которая в свою очередь тоже пытается аллоцировать память и образуется рекурсия.
Для отслеживания и обработки таких ситуаций были использованы следующие техники:
TLS трекер уровня вложенности Концепция, подсмотренная у
heaptrack
. Используетсяthread_local
переменная которая выступает как счетчик глубины рекурсии. Ниже приведена используемая RAII обертка.
template <typename T>
struct RecursionGuard
{
RecursionGuard()
{
level++;
}
~RecursionGuard()
{
level--;
}
explicit operator bool() const
{
return level == 1;
}
uint32_t Level() const
{
return level;
}
ABSL_CONST_INIT static inline thread_local uint32_t level = 0;
};
-
Хранение стектрейсов в статически выделенной памяти
Исходим из допущения, что число внутренних стектрейсов ограничено и не очень велико, поэтому их все можно положить в преаллоцированный массив, который создается на этапе компиляции. Так же используется самописное битовое сжатие для целых чисел, которое сжимает дельты между соседними числами.N.B. Можно было бы использовать более эффективные/быстрые библиотеки сжатия целых чисел, однако не у всех открытые лицензии, которые позволяют легко интегрировать их в проект.
Удаление пользовательских фреймов
В большинстве случаев нет необходимости знать пользовательский стектрейс при срабатывании внутренней аллокации, а значит фреймы пользовательского стектрейса можно убрать из рассмотрения и оставить только те, которые относятся непосредственно кMemHawk
.
Данная техника реализована при помощиthread_local
массива фреймов, предшествующих вызову внутренних функцийTrackAlloc/TrackDealloc
.
Отложенный учет внутренних аллокаций
Несмотря на все предпринятые меры, рекурсивные внутренние аллокации все равно возможны, для их корректного учета складываем информацию по аллокации в циклический массив, который опустошаем только на первом уровне вложенности. Получаем линеаризированную историю внутренних аллокаций.
Ниже приведена реализация функции MemHawk::TrackAlloc
с учетом всех описанных техник, MemHawk::TrackDealloc
аналогична.
ABSL_CONST_INIT thread_local ThreadTracker* gtl_tracker = nullptr;
ABSL_CONST_INIT thread_local std::array<void*, 8> gtl_retPtrs = {};
...
void MemHawk::TrackAlloc(AllocInfo& info, Stacktrace&& trace)
{
const RecursionGuard<RetPtrTag> retPtrGuard;
const auto span = trace.GetTrace();
const auto level = retPtrGuard.Level();
if (likely(span.size() > 1))
{
gtl_retPtrs[level] = span[1];
}
const absl::Cleanup retCleanup = [level]() { gtl_retPtrs[level] = nullptr; };
const RecursionGuard<AllocTag> guard;
if (guard)
{
// external allocation
if (unlikely(gtl_tracker == nullptr))
{
RegisterThread();
}
auto lockedTracker = gtl_tracker->LockTracker();
lockedTracker.SaveTraceId(info, std::move(trace));
lockedTracker.TrackAlloc(info);
}
else
{
// internal allocation of memhawk
const RecursionGuard<InnerAllocTag> innerGuard;
// track only inner memhawk's stacktraces in order to reduce index size
trace.ShrinkByPtr(gtl_retPtrs[level - 1]); // level can't be less than 1
if (level > 1 && !trace.GetTrace().empty())
{
// don't interested in previous memhawk call
// malloc->trace->malloc and free->trace->malloc will be squashed into trace->malloc
trace.ShrinkBySize(trace.GetTrace().size() - 1);
}
// set trace id manually, otherwise there can be malloc recursion upon inserting into tracker caches
info.traceId = m_innerBtTracker.InsertStacktrace(std::move(trace));
if (innerGuard)
{
ProcessPostponed();
auto lockedTracker = m_innerTracker->LockTracker();
lockedTracker.TrackAlloc(info);
}
else
{
PostponeAlloc(info);
}
}
}
Уменьшаем влияние зависимостей
Последняя задача, которая стоит перед нами - аккуратно собрать библиотеку с минимальным числом зависимостей, чтобы портировать ее на другие системы было намного проще. Это чисто техническая задача, которая потребовала лишь времени. Как итог внутрь статически вкомпилены следующие компоненты:
libunwind — https://github.com/libunwind/libunwind.git
-
boost — https://github.com/boostorg/boost
N.B. Добавление protobuf в качестве зависимости существенно увеличивает время компиляции, т.к. помимо биндинга к плюсам необходимо еще собрать сам protoc.
Что еще можно улучшить
Есть несколько фичей, которые пока так и остались нереализованными:
Сброс дампа по сигналу Концептуально несложная доработка (отдельный поток на sigwait для отлова SIGUSR1), но пока что была не нужна.
Ротация логов В данный момент не имплиментировано, имеет смысл в основном для текстового формата.
GUI для protobuf формата Можно сделать адаптер к визуализатору heaptrack, либо написать свой собственный, с доп.возможностями по визуализации числа аллокакций по выделнному трейсу по времени.
Итог
На мой взгляд получилась неплохая библиотека, которая уже может быть полезна для выявления утечек и хотспотов по потреблению памяти/аллокациям за счет понятного текстового формата. Имеется потенциал к улучшению, интеграции с существующими решениями.
Сфера применения - если вам нужна возможность профилировать аллокации, heaptrack
для вас слишком дорог с точки зрения перфа, а jemalloc
недостаточно информативен, то тогда стоит присмотреться к MemHawk
.
Приглашаю попробовать воспользоваться данным профилировщиком в ваших проектах, возможно, позволит найти точки оптимизации, уменьшить потребление памяти.
Ссылка на проект для повышения однокликовой доступности
https://github.com/IlRomanenko/MemHawk
eao197
Решил написать комментарий чтобы хоть какая-то активность у вашей статьи появилась.
Но впечатление от написанного очень странное. Вроде бы и проблема серьезная обозначена, и материал структурирован. Но при этом совершенно не цепляет. Не знаю почему и, к сожалению, не могу посоветовать что бы можно было улучшить чтобы привлечь больше внимания.
Но в любом случае спасибо, что поделились результатами своего труда.