Практически каждому разработчику однажды приходилось искать утечки памяти в его приложении / либо же профилировать использование памяти процессом. Задача эта довольно трудоемкая и непростая.

Большинство популярных профилировщиков памяти либо дают неточную информацию (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-нодам, и сам профилировщик должен либо исключать свои аллокации, либо обходиться без них.

Имея эти вводные можно вывести требования, которые нужны от профилировщика памяти:

  1. Поддержка многопоточности — инструмент не должен деградировать при росте числа потоков, а синхронизация должна быть либо lock-free, либо минимальной по времени.

  2. Точный подсчет аллокаций — необходимо отслеживать все malloc/free, сопоставляя их с вызывающим кодом.

  3. Гибкие механизмы раскрутки стека — для приложений собранных без frame-pointer'а требуется полноценная DWARF раскрутка.

  4. Отсутствие зависимости от сторонних библиотек — минимальное количество зависимостей и возможность работы на старых системах

  5. Малая инвазивность — профилировщик не должен влиять на логику приложения, нарушать семантику аллокаций или менять поведение потоков.

Анализ существующих решений

Проведём сравнительный анализ популярных профилировщиков памяти по ключевым характеристикам:

Инструмент

Детализация

Масштабируемость

Раскрутка стека

Преимущества

Ограничения

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:

  1. TraceId: 440645 с 0 активных аллокаций, при этом было совершено всего 1206461 аллокаций (временные аллокации).

  2. 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;
}
  1. Рассчитывается доп. размер, который нужно запросить у aligned_alloc, чтобы удовлетворить выравнивание пользовательских данных и было достаточно места для размещения AllocInfo

  2. Размещаем AllocInfo в первых 16 байтах перед пользовательскими данными. Оставшиеся байты от указателя, вернувшегося из hooks::aligned_alloc до AllocInfo будут пустовать и являться паддингом.

  3. В AllocInfo.offset записываем смещение исходного указателя относительно пользовательских данных (фактически - размер паддинга с учетом AllocInfo) - это alignedSize.

  4. Если 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);
}
  1. Вычисляем изначальный указатель, зная, что в первых 16 байтах перед пользовательскими данными лежит AllocInfo, а в AllocInfo.offset записан полный размер паддинга, если он есть.

  2. Рассматриваем случаи связанные с порядком инициализации библиотек (нужно учесть, что часть аллокаций происходит на этапе конструирования глобальных объектов при старте приложения)

  3. Учитываем деаллокацию, если MemHawk проинициализирован. Тут так же раскручиваем стек на минимальное количество фреймов, необходимо для учета внутренних аллокаций MemHawk.

  4. В самом конце вызываем free на исходном указателе.

Работа с многопоточностью

Следующий важный шаг в дизайне - определить, как уменьшить накладные расходы в многопоточном приложении, т.к. именно это и подтолкнуло изначально к попытке реализовать свой профилировщик.

Традиционные решения:

  1. Пул мьютексов — данное решение тестировалось на этапе реализации хранения метанформации отдельно от данных, по итогу такой подход сильно зависит от соотношения числа потоков и числа мьютексов.

    N.B. Для желающих - можете посчитать вероятность 2 или более потоков начнут лочить один и тот же мьютекс при условии, что мьютекс выбирается равномерно случайно из всего пула.
    Ниже приведены минимальные выкладки, для n потоков, каждый из которых равномерно выбирает один из k мьютексов и считаем вероятность для конкретного мьютекса, что на него попадут 2 или более потока.

x_1, ..., x_n \sim unif(1, k)\\\xi_j = \sum_{i=1}^{n} I\{x_i = j\} \sim Bin(n, \frac{1}{k})P(\xi_j \ge 2) = 1 - P(\xi_j = 0) - P(\xi_j = 1) = 1 - \left(\frac{k - 1}{k}\right)^{n-1}\left(\frac{n + k - 1}{k}\right)

При подстановке параметров n = 16, k = 8, получаем, что даже для зафиксированного мьютекса вероятность взаимной блокировки нескольких потоков ~ 0.61. Если расширить эти вычисления, приняв во внимание все возможные мьютексы, то вероятность получить блокировку 2 или более потоков на каком либо из мьютексов окажется близка к 1.

  1. Lock-free алгоритмы — сложнее в реализации и фактически не решают проблему синхронизации, т.к. потоки конкурируют за доступ к общей структуре данных.

Предлагаемое решение:

  1. TLS + асинхронная агрегация — позволяет обрабатывать аллокации и деаллокации каждого треда в отдельности, существенно уменьшаем частоту блокировок, увеличиваем локальность данных. Платим за это уменьшением дискретизации профилирования.

  2. Lru-cache стектрейсов для каждого потока — минимизируем обращения к глобальному хранилищу стектрейсов при предположении, что для большинства потоков число активных стектрейсов с аллокациями не велико.

  3. Spinlock для доступа к глобальным объектам — используем spinlock для доступа к общим данным, фактически единственный объект, за который могут сражаться треды - глобальное хранилище стектрейсов.

Следующая диаграмма показывает взаимосвязь между компонентами, происходящую при вызове malloc. Единственный компонент, который требует глобального спинлока - StacktraceTracker.

diagram
diagram

Учет собственных аллокаций

Интересная фича — возможность профилировщика учитывать собственные аллокации, однако это сразу накладывает ограничения на дизайн. Основная сложность - корректно обработать ситуацию, когда выделенной памяти под внутренние структуры не хватает и происходит аллокация новой памяти, что триггерит обработку этой аллокации, которая в свою очередь тоже пытается аллоцировать память и образуется рекурсия.

Для отслеживания и обработки таких ситуаций были использованы следующие техники:

  1. 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;
};
  1. Хранение стектрейсов в статически выделенной памяти
    Исходим из допущения, что число внутренних стектрейсов ограничено и не очень велико, поэтому их все можно положить в преаллоцированный массив, который создается на этапе компиляции. Так же используется самописное битовое сжатие для целых чисел, которое сжимает дельты между соседними числами.

    N.B. Можно было бы использовать более эффективные/быстрые библиотеки сжатия целых чисел, однако не у всех открытые лицензии, которые позволяют легко интегрировать их в проект.

  2. Удаление пользовательских фреймов
    В большинстве случаев нет необходимости знать пользовательский стектрейс при срабатывании внутренней аллокации, а значит фреймы пользовательского стектрейса можно убрать из рассмотрения и оставить только те, которые относятся непосредственно к MemHawk.
    Данная техника реализована при помощи thread_local массива фреймов, предшествующих вызову внутренних функций TrackAlloc/TrackDealloc.

  3. Отложенный учет внутренних аллокаций
    Несмотря на все предпринятые меры, рекурсивные внутренние аллокации все равно возможны, для их корректного учета складываем информацию по аллокации в циклический массив, который опустошаем только на первом уровне вложенности. Получаем линеаризированную историю внутренних аллокаций.

Ниже приведена реализация функции 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);
        }
    }
}

Уменьшаем влияние зависимостей

Последняя задача, которая стоит перед нами - аккуратно собрать библиотеку с минимальным числом зависимостей, чтобы портировать ее на другие системы было намного проще. Это чисто техническая задача, которая потребовала лишь времени. Как итог внутрь статически вкомпилены следующие компоненты:

Что еще можно улучшить

Есть несколько фичей, которые пока так и остались нереализованными:

  1. Сброс дампа по сигналу Концептуально несложная доработка (отдельный поток на sigwait для отлова SIGUSR1), но пока что была не нужна.

  2. Ротация логов В данный момент не имплиментировано, имеет смысл в основном для текстового формата.

  3. GUI для protobuf формата Можно сделать адаптер к визуализатору heaptrack, либо написать свой собственный, с доп.возможностями по визуализации числа аллокакций по выделнному трейсу по времени.

Итог

На мой взгляд получилась неплохая библиотека, которая уже может быть полезна для выявления утечек и хотспотов по потреблению памяти/аллокациям за счет понятного текстового формата. Имеется потенциал к улучшению, интеграции с существующими решениями.

Сфера применения - если вам нужна возможность профилировать аллокации, heaptrack для вас слишком дорог с точки зрения перфа, а jemalloc недостаточно информативен, то тогда стоит присмотреться к MemHawk.

Приглашаю попробовать воспользоваться данным профилировщиком в ваших проектах, возможно, позволит найти точки оптимизации, уменьшить потребление памяти.

Ссылка на проект для повышения однокликовой доступности
https://github.com/IlRomanenko/MemHawk

Комментарии (1)


  1. eao197
    23.07.2025 07:10

    Решил написать комментарий чтобы хоть какая-то активность у вашей статьи появилась.
    Но впечатление от написанного очень странное. Вроде бы и проблема серьезная обозначена, и материал структурирован. Но при этом совершенно не цепляет. Не знаю почему и, к сожалению, не могу посоветовать что бы можно было улучшить чтобы привлечь больше внимания.

    Но в любом случае спасибо, что поделились результатами своего труда.