Discovery
Попробуйте другие наши приложения
Куча лжи: отладка утечки памяти в vLLM
23 января 2026 г.

Переведено с помощью Struxel

Автор: Матис Фелардос

Январь 2026

Несколько месяцев назад наша команда исследовала предполагаемую утечку памяти в vLLM. Сначала мы думали, что проблему будет легко обнаружить, что она будет ограничена верхними уровнями кодовой базы. Но чем глубже мы копали, тем сложнее она становилась. Эта статья открывает нашу новую серию «Глубокое погружение в инженерию», в которой мы расскажем, как мы решаем технические задачи и создаем решения в Mistral AI.

Проблема впервые появилась во время предварительных производственных испытаний распределенного обслуживания с одной из наших передовых моделей. Использование памяти неуклонно росло, но только при определенных условиях: с vLLM, с нашей моделью Mistral Medium 3.1 и при включенной компиляции графов. Не было сбоев или ошибок, только медленное линейное увеличение системной памяти на 400 МБ в минуту при производственной нагрузке. Через несколько часов это привело бы к состоянию «недостаточно памяти».

За этим последовала методичная охота, начиная с инструментов Python высокого уровня и заканчивая трассировкой на уровне ядра, пока мы, наконец, не обнаружили истинный источник. Вот как мы отследили его и что это показало о скрытых рисках слоев зависимостей в современном программном обеспечении.

E2f0fa05 5d77 4acc Ac9b 43e10db5769c Copie

Не тот вид восходящего графика, который нам нравится видеть в Grafana

Утечка, которая играла в прятки.

Первоначально наш подход следовал стандартному пути устранения неполадок: мы стремились изолировать источник утечки, воспроизведя проблему на меньшей модели с меньшим количеством включенных производственных оптимизаций. Но после попыток с разными настройками и моделями нам не удалось воспроизвести это в другой среде. Ошибка присутствовала только в распределенной настройке Prefill/Decode с NIXL.

Учитывая центральную роль, которую распределение Prefill/Decode (P/D) играет в нашей истории, давайте рассмотрим основные механизмы работы этой конфигурации вывода. Распределенное P/D разделяет обработку запроса на два этапа, которые обрабатываются разными экземплярами:

  1. Сначала маршрутизатор отправляет «предварительный запрос» (устанавливая max_tokens=1 и устанавливая пустой набор метаданных KV Transfer) в экземпляр vLLM для вычисления кэша KVCache для запроса.

  2. После завершения маршрутизатор передает метаданные KVCache вместе с «запросом на декодирование» в экземпляр vLLM для декодирования.

  3. Передача KVCache инициируется через NIXL, а генерация токенов происходит в экземпляре vLLM для декодирования путем использования и расширения переданного KVCache.

Утечка наблюдалась только на стороне декодирования этой распределенной настройки, что позволяет предположить, что передача кэша KV через NIXL является причиной утечки. В нашей настройке NIXL полагается на UCX (Unified Communication X), высокопроизводительную библиотеку связи, предназначенную для обмена данными в распределенных системах. UCX обеспечивает оптимизированную передачу данных по большому набору технологий, включая Infiniband, низкозадержную, высокопроизводительную технологию взаимосвязи, обычно используемую в HPC и центрах обработки данных.

Обзор распределенного развертывания обслуживания P/D

Обзор распределенного развертывания обслуживания P/D.

На протяжении всего оставшегося времени нашего исследования мы работали в этой настройке и начали с инструментов профилирования памяти Python, чтобы определить источник утечки.

Мы попробовали Memray и Guppy 3, но ни один из них не показал утечку, и все, что они позволили нам наблюдать, было нормальным. Попытка использовать GDB привела к сбою всего процесса. Наша настройка vLLM также была слишком сложной для таких инструментов, как Valgrind, что делало их непрактично медленными или даже невозможными в использовании.

Стало ясно, что требуется более мощный инструмент для отслеживания утечки. Но прежде чем инвестировать больше времени, мы решили убедиться, что эту утечку можно воспроизвести другим. Мы обратились к команде vLLM, открыв проблему в их репозитории GitHub, что помогло подтвердить, что мы не единственные, кто сталкивается с этой проблемой, и что необходимо более глубокое исследование.

Подсчет malloc и free с помощью Heaptrack.

Чтобы лучше отслеживать происходящее, мы обратились к Heaptrack: профилировщику памяти, который переопределяет операции памяти, такие как malloc или free, и записывает эти события вместе со стеками вызовов.

Миллиан Вольф, создатель Heaptrack, написал отличное вводное сообщение в блоге, чтобы помочь вам начать работу с этим инструментом. Это двухэтапный процесс: сначала запустите программу с трассировкой, затем интерпретируйте данные.

Чтобы отслеживать выделения памяти, происходящие в рабочем процессе vLLM, мы установили LD_PRELOAD в libheaptrack_preload.so через vLLM, чтобы убедиться, что эта библиотека загружается перед любой другой и переопределяет поведение функций выделения памяти, предоставляя нам дамп данных.

Затем мы смогли визуализировать эти данные с помощью heaptrack_interpret:

$ git clone https://github.com/KDE/heaptrack.git
$ cd heaptrack && mkdir build && cd build && cmake .. && sudo make install

# Установка LD_PRELOAD=/path/to/libheaptrack_preload.so создает временный файл с именем heaptrack.<pid> , здесь pid равен 2028233

$ /usr/local/lib/heaptrack/libexec/heaptrack_interpret < heaptrack.2028233 | gzip > heaptrack.vllm.2028233.gz

Heaptrack предоставляет подробный интерактивный график всех выделений кучи, вплоть до уровня функции. Мы можем отслеживать каждое malloc и free с четким разбивкой использования памяти.

72a017c9 7ffc 44dd A311 534ea3fefe1d Copie

Использование памяти, отображаемое Heaptrack для нашего рабочего процесса vLLM

В этот момент можно задаться вопросом: где же утечка памяти? Действительно, единственное видимое увеличение памяти было связано с ленивой инициализацией NIXL.

Чтобы убедиться, что утечка действительно происходит в этой конфигурации, мы запустили бенчмарк vLLM и создали два снимка Heaptrack с помощью heaptrack_interpret: один в начале и один ближе к концу. Хотя сама куча памяти оставалась стабильной, максимальный объем резидентной памяти (RSS), о котором мы поговорим в следующем разделе, отличался между двумя снимками. Это расхождение было видно во вкладке сводки Heaptrack.

Image5

Разница в максимальном RSS в Heaptrack: до (1) и после (2) бенчмарка

Это означало, что утечка происходит за пределами кучи, и, следовательно, не является частью памяти, которую анализирует Heaptrack. Нам нужно было изменить инструменты, чтобы отслеживать выделения памяти за пределами кучи.

За пределами кучи: понимание резидентной памяти и системных выделений.

Linux Virtual Memory Layout

Чтобы понять, почему Heaptrack не смог обнаружить утечку, сначала нужно уточнить, что именно включает в себя размер резидентного набора (RSS). RSS представляет собой часть памяти процесса, хранящуюся в оперативной памяти, и она содержит не только кучу. В частности, она включает в себя:

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

  • Стек, в котором хранятся локальные переменные и кадры вызова функций.

  • Анонимные отображения памяти, которые представляют собой области памяти, выделяемые непосредственно с помощью системного вызова mmap без файла-подложки. Они часто используются пользовательскими аллокаторами или malloc для больших блоков памяти. Адреса анонимных отображений обычно находятся между адресным пространством кучи и адресным пространством стека, в области, известной как сегмент отображения памяти.

Хотя malloc может использовать sbrk для небольших выделений, современные реализации обычно предпочитают использовать mmap с анонимными отображениями, поскольку это более гибко и позволяет выделять огромные страницы (страницы памяти размером 2 МБ или 1 ГБ в зависимости от вашей конфигурации).

Heaptrack подключается только к функциям malloc и free glibc. Это означает, что он может отслеживать все традиционные выделения кучи и анонимных отображений, выполняемые malloc напрямую, но он не отслеживает память, выделяемую с помощью прямых вызовов mmap или других системных механизмов, находящихся вне контроля glibc.

К счастью, не все потеряно, когда речь идет об отслеживании происходящего. Файловая система /proc — это специальная папка в Linux, которая служит API ядра, предоставляя виртуальный интерфейс для взаимодействия с работающими процессами и обеспечивая доступ в режиме реального времени к деталям процесса, таким как:

  • /proc/<pid> /fd, в котором перечислены все открытые файловые дескрипторы процесса.

  • /proc/<pid> /maps, в котором показана подробная карта областей памяти процесса, включая кучу, стек, общие библиотеки и анонимные отображения.

  • И многое другое (простая команда ls в /proc/<pid> / показывает, что доступно).

Чтобы продолжить наше расследование, мы использовали команду pmap, которая считывает /proc/<pid> /maps и представляет использование памяти в удобочитаемом формате. Наша цель состояла в том, чтобы отслеживать изменения в областях памяти с течением времени, поэтому мы запустили:

$ watch -n 1 "pmap -X $pid | (head -n 2 && tail -n +3 | sort -k7 -nr)"

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

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

Страницы памяти, перечисленные нашей командой pmap, отсортированные по размеру RSS, облегчили выявление подозрительных выделений. Оранжевая точка выделяет пример.

Это поведение характерно для mremap, системного вызова, используемого для изменения размера или перемещения существующих областей памяти без их освобождения. В отличие от realloc, который работает в куче и полагается на управление памятью glibc, mremap работает на более низком уровне и часто используется пользовательскими аллокаторами, библиотеками или даже кодом ручного управления памятью для динамической настройки макетов памяти.

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

Это было нашим первым конкретным указанием на то, что утечка происходит не в куче, а в анонимных областях памяти, размер которых изменяется без надлежащего освобождения.

Отслеживание утечки с помощью BPFtrace.

Наше расследование сузило утечку до необработанных вызовов mmap или mremap, но нам нужно было подтвердить, какой из них является причиной. Наша первая попытка заключалась в использовании LD_PRELOAD с небольшой пользовательской библиотекой C, которая регистрировала каждый вызов mmap и mremap, поскольку Heaptrack этого не делал, в надежде перехватить те, которые нас интересовали. Однако этот подход имел ограничения: не все mmap/mremap проходят через glibc. Наши пользовательские хуки видели некоторые выделения, но они не соответствовали адресам, которые вызывали утечку в нашем выводе pmap. Области, вызывающие утечку, все еще росли, и наши LD_PRELOAD хуки их не отслеживали. Это предполагало, что эти выделения выполнялись вручную с помощью системного вызова или что был задействован другой механизм перехвата.

Чтобы получить полную картину, мы обратились к BPFtrace, инструменту для отслеживания системных вызовов и событий ядра в реальном времени. Он использует виртуальную машину eBPF ядра Linux, которая выполняет легковесный, предварительно проверенный байт-код, прикрепленный к точкам трассировки или зондам, что обеспечивает безопасный и эффективный анализ. BPFtrace также используется некоторыми инструментами Kubernetes для обнаружения аномального или опасного поведения в кластерах, такого как несанкционированный доступ или злоупотребление ресурсами, и все это без риска для стабильности ядра.

Мы также рассматривали возможность использования strace, но его зависимость от PTRACE делала его слишком медленным для эффективного анализа проблемы на данном этапе. Вместо этого мы написали скрипт BPFtrace для регистрации каждого вызова mmap и mremap с их аргументами и трассировками стека, включая вызовы, которые не проходят через glibc. Вот скрипт, который мы написали с помощью Le Chat:

tracepoint:syscalls:sys_enter_mmap /pid == (uint64)$1/ {
    printf("Stack trace:\n%s\n", ustack(perf));
    printf("PID/TID: %d %d | ", pid, tid);
    printf("ENTER mmap(addr=%p, len=%d, prot=%d, flags=%d, fd=%d, off=%d)\n",
           args-> addr, args-> len, args-> prot, args-> flags, args-> fd, args-> off);
}
tracepoint:syscalls:sys_exit_mmap /pid == (uint64)$1/ {
    printf("PID/TID: %d %d | ", pid, tid);
    printf("EXIT mmap: ret=%p\n", args-> ret);

}
tracepoint:syscalls:sys_enter_munmap /pid == (uint64)$1/ {
    printf("PID/TID: %d %d | ", pid, tid);
    printf("ENTER munmap(addr=%p, len=%d)\n", args-> addr, args-> len);
}
tracepoint:syscalls:sys_exit_munmap /pid == (uint64)$1/ {
    printf("PID/TID: %d %d | ", pid, tid);
    printf("EXIT munmap: ret=%d\n", args-> ret);
}
tracepoint:syscalls:sys_enter_mremap /pid == (uint64)$1/ {
    printf("PID/TID: %d %d | mremap", pid, tid);
    printf("old_addr=%p, old_len=%d, new_len=%d, flags=%d, new_addr=%p)\n",
           args-> addr, args-> old_len, args-> new_len, args-> flags, args-> new_addr);
}
tracepoint:syscalls:sys_exit_mremap /pid == (uint64)$1/ {
    printf("PID/TID: %d %d | ", pid, tid);
    printf("EXIT mremap: ret=%p\n", args-> ret);
}

Мы запустили скрипт от имени root с помощью этой команды, заменив $pid на PID процесса vLLM worker, у которого, согласно pmap, происходила утечка:

bpftrace /host/script_bpftrace.txt $pid > out_$pid.txt

По сути, этот скрипт:

  • Отслеживает системные вызовы mmap, munmap и mremap при их входе в ядро.

  • Выводит идентификатор потока, тип вызова, запрошенный адрес и длину.

  • Выводит трассировку стека пользовательского пространства (ustack в BPFtrace), чтобы определить, откуда был сделан вызов.

Вот пример вывода этого скрипта:

Stack trace:
	7ffff7d6b88d syscall+29 (/usr/lib/x86_64-linux-gnu/libc.so.6)
PID/TID: 441359 441359 | ENTER mmap(addr=(nil), len=151552, prot=3, flags=34, fd=-1, off=0)
PID/TID: 441359 441359 | EXIT mmap: ret=0x7fd8a78ee000

Выдержка из вывода скрипта BPFtrace. Важно, что здесь указано syscall+29.

На этом этапе давайте обобщим собранную информацию:

  • pmap показал нам подозрительно растущие выделения и их базовые адреса.

  • BPFtrace позволил нам понять, что эти адреса были получены с помощью вызовов mmap, а не mremap. Это было для нас неожиданностью, поскольку mremap казался идеальным кандидатом для выделения памяти, которое постоянно растет.

  • Еще более интересно то, что вызовы исходили из syscall+29, также известного как сырой оберточный вызов syscall glibc, который позволяет пользователям выполнять сырой системный вызов через API, например: syscall(SYS_mmap, ...)

Это был значительный прогресс, но требовалось дальнейшее расследование. В то время как BPFtrace показал нам трассировку стека пользовательского пространства самого системного вызова (ustack в их документации), он предоставил нам только первый элемент трассировки вызовов пользователя, а не полный контекст пользовательского пространства, ведущий к выделению. Мы могли видеть, где был вызван mmap, но не предыдущие вызывающие функции. Эта информация была бы для нас важна, поэтому мы попытались понять, почему мы не можем ее получить, и сначала предположили, что это связано с отключенными указателями кадров в нашей настройке, и решили изучить это направление.

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

К сожалению, переход с Ubuntu 22.04 LTS на Ubuntu 24.04 LTS (в которой указатели кадров включены для нативных библиотек) не помог. Это указывало на то, что виновата уже оптимизированная зависимость Python, которая отключила указатели кадров.

Мы некоторое время размышляли над ситуацией: какая библиотека Python может выполнять такие прямые системные вызовы, обходя обычные вызовы стандартной библиотеки? На этом этапе у нас было два потенциальных кандидата:

  1. UCX (Unified Communication X), высокопроизводительная коммуникационная библиотека, используемая для ускорения сетевых операций и RDMA (Remote Direct Memory Access). UCX является зависимостью NIXL, которая используется vLLM для распределенного обслуживания. UCX известна своими низкоуровневыми оптимизациями памяти, включая собственные аллокаторы памяти.

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

Имея обе эти версии, нам нужен был способ углубиться в проблему. На этом этапе мы обратились к автоматизации GDB.

Автоматизация GDB пришла на помощь.

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

Однако, изучив наши журналы BPFtrace, мы заметили, что каждый вызов mmap, вызывающий утечку, исходил из одного и того же адреса. Как мы уже говорили в предыдущем разделе, этот адрес не указывал напрямую на mmap, но находился внутри syscall glibc, который является тонким оберточным слоем. Эта функция обходит обычный оберточный слой mmap glibc, что объясняет, почему наши хуки LD_PRELOAD не смогли перехватить его раньше. Мы попытались использовать LD_PRELOAD для перехвата syscall glibc, но, как ни странно, это тоже не сработало, что оставило нам почти никаких способов динамического отслеживания этих вызовов.

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

  1. Мы установили условную точку останова на адресе syscall, которая срабатывает только в том случае, если номер системного вызова соответствует SYS_mmap.

  2. Мы временно остановились в конце системного вызова mmap, чтобы проверить возвращаемое значение, которое является выделенным адресом, и распечатали полный стек вызовов.

  3. Мы сразу же вывели весь доступный контекст: адрес возврата, стек вызовов и любые другие соответствующие регистры или память.

  4. Мы запустили этот условный скрипт в течение нескольких секунд во время выполнения теста и сравнили захваченные адреса возврата с нашим мониторингом pmap, чтобы убедиться, что они соответствуют известным областям утечки.

Вот скрипт, который можно запустить с помощью gdb -x gdb_script.txt:

# Подключиться к процессу
attach 2199304

# Открыть файл журнала для вывода
set logging file syscall_output.txt
set logging on

# Установить условную точку останова на syscall для rdi == 9 (mmap)
break syscall if $rdi == 9

# Команды для точки останова syscall
commands
  silent
  # Установить временную точку останова в точке возврата
  tbreak *0x00007ffff7d9525d
  # Команды для временной точки останова
  commands $bpnum + 1
    silent
    set $ret_val = $rax
    bt
    printf "Syscall returned: rax = 0x%012lx\n", $ret_val
    continue
  end
  continue
end

# Запустить процесс
continue

Этот подход дал нам два основных преимущества:

  • Мы получили полный стек вызовов в пользовательском пространстве в момент выделения памяти, что BPFtrace не мог надежно предоставить.

  • Мы также могли сопоставить возвращенные адреса с нашим выводом pmap, чтобы убедиться, что они соответствуют областям утечки.

Короче говоря, мы превратили GDB в целевой, неинтрузивный наблюдатель, который останавливается только тогда, когда происходит утечка, выводит все необходимое и позволяет процессу продолжать работу. Это, наконец, позволило нам установить связь между вызовами mmap и растущими анонимными областями, которые мы видели в pmap.

Первый стек вызовов показал, что Python (строка #5) вызывает mmap через UCX (строка #4), что было неожиданно, поскольку в нормальных обстоятельствах Python должен вызывать mmap glibc напрямую.

#0  syscall () at ../sysdeps/unix/sysv/linux/x86_64/syscall.S:29
#1  0x00007ffc61759ac2 in ucm_orig_mmap_syscall () from /.venv/lib/python3.12/site-packages/.nixl.mesonpy.libs/plugins/../../nixl.libs/libucm-e091ff91.so.0.0.0
#2  0x00007ffc61753bd1 in ?? () from /.venv/lib/python3.12/site-packages/.nixl.mesonpy.libs/plugins/../../nixl.libs/libucm-e091ff91.so.0.0.0
#3  0x00007ffc61753e3b in ucm_event_dispatch () from /.venv/lib/python3.12/site-packages/.nixl.mesonpy.libs/plugins/../../nixl.libs/libucm-e091ff91.so.0.0.0
#4  0x00007ffc61754009 in ucm_mmap () from /.venv/lib/python3.12/site-packages/.nixl.mesonpy.libs/plugins/../../nixl.libs/libucm-e091ff91.so.0.0.0
#5  0x0000000000674ac0 in _PyMem_ArenaAlloc (_unused_ctx=<optimized out> , size=<optimized out> ) at ../Objects/obmalloc.c:138
...

Еще более странным был второй стек вызовов, в котором Python (строка #8) вызывал munmap через UCX (строка #7), но каким-то образом вызывал выделение памяти mmap (строка #1) в процессе:

#0  syscall () at ../sysdeps/unix/sysv/linux/x86_64/syscall.S:38
#1  0x00007ffc60f58ac2 in ucm_orig_mmap_syscall () from /.venv/lib/python3.12/site-packages/.nixl.mesonpy.libs/plugins/../../nixl.libs/libucm-e091ff91.so.0.0.0
#2  0x00007ffc60fae47c in ?? () from /.venv/lib/python3.12/site-packages/.nixl.mesonpy.libs/plugins/../../nixl.libs/libucs-311e600f.so.0.0.0
#3  0x00007ffc60f9b9c4 in ucs_mpool_grow () from /.venv/lib/python3.12/site-packages/.nixl.mesonpy.libs/plugins/../../nixl.libs/libucs-311e600f.so.0.0.0
#4  0x00007ffc60f9bbf5 in ucs_mpool_get_grow () from /.venv/lib/python3.12/site-packages/.nixl.mesonpy.libs/plugins/../../nixl.libs/libucs-311e600f.so.0.0.0
#5  0x00007ffc60fafe2c in ?? () from /.venv/lib/python3.12/site-packages/.nixl.mesonpy.libs/plugins/../../nixl.libs/libucs-311e600f.so.0.0.0
#6  0x00007ffc60f52e3b in ucm_event_dispatch () from /.venv/lib/python3.12/site-packages/.nixl.mesonpy.libs/plugins/../../nixl.libs/libucm-e091ff91.so.0.0.0
#7  0x00007ffc60f5313b in ucm_munmap () from /.venv/lib/python3.12/site-packages/.nixl.mesonpy.libs/plugins/../../nixl.libs/libucm-e091ff91.so.0.0.0
#8  0x0000000000607d15 in _PyThreadState_PopFrame (tstate=0xba6ac8 <_PyRuntime+459656> , frame=<optimized out> ) at ../Python/pystate.c:2992
...

Это было неожиданно, поскольку munmap предназначен для освобождения памяти, а не для выделения. Тот факт, что во время операции munmap происходит вызов mmap в UCM (модуле управления памятью UCX), указывал на то, что в управлении пулом памяти UCX что-то идет не так.

Определение роли хуков памяти UCX.

Мы поделились этими выводами с командой vLLM, которая помогла подтвердить и уточнить наше понимание проблемы. Вместе мы обнаружили, что UCX использует механизм перехвата mmap для оптимизации операций с памятью для InfiniBand, включая предварительное кэширование данных для передачи (функция, называемая Registration Cache или RCache). Управление памятью для InfiniBand часто является дорогостоящим из-за необходимости аппаратной регистрации памяти.

Однако этот механизм по умолчанию перехватывает все вызовы mmap, а не только те, которые связаны с UCX или операциями InfiniBand. Этот широкий перехват объясняет, почему наши предыдущие попытки отследить выделения с помощью нашего mmap-хука на основе LD_PRELOAD не удались: UCX динамически изменяет записи в глобальной таблице смещений (GOT), используемые приложениями для вызова таких функций, как mmap и munmap. Именно поэтому наши предыдущие хуки полностью обходились. Мы также обнаружили, что этот механизм перехвата автоматически отключается при обнаружении Valgrind, что помешало бы нам использовать Valgrind для более глубокого анализа.

GOT — это структура данных, используемая динамическим компоновщиком для разрешения вызовов функций в динамически связанных библиотеках. Когда программа запускается, динамический компоновщик заполняет GOT фактическими адресами функций, таких как mmap, из общих библиотек.

Изменение GOT во время выполнения обычно считается плохой практикой, поскольку это может привести к нестабильности, усложнить отладку и нарушить предположения, на которых полагаются другие части программы или библиотеки. Но UCX делает это по веской причине. Действительно, UCX делает это для управления своим Registration Cache, который отслеживает "зарегистрированную" (или "закрепленную") память. Это память, которая была закреплена на месте, что гарантирует, что ее виртуальное и физическое адресное отображение останется фиксированным. Это позволяет сетевым адаптерам напрямую передавать данные между сетевой структурой и оперативной памятью без участия ЦП, что имеет решающее значение для производительности, но требует тщательного управления, поскольку зарегистрированная память является ограниченным ресурсом.

Решение проблемы.

Теперь, когда мы узнали, что в игре задействован механизм перехвата mmap, мы поняли, что это на самом деле хорошие новости, поскольку мы можем полностью отключить его, установив переменную среды UCX_MEM_MMAP_HOOK_MODE=none.Это успешно отключило это поведение, что решило проблему с утечкой памяти без ущерба для производительности. Хуки mmap полезны при отправке различных блоков памяти по RDMA, но в случае использования vLLM нам нужно обрабатывать только один большой, непрерывный блок памяти: всю память KVCache Manager vLLM. NIXL нужно было только один раз зарегистрировать память для своих передач. Поэтому в случае использования vLLM отключение механизма перехвата было безопасным и не оказало негативного влияния на производительность распределенного обслуживания.

После обсуждения этого с командой UCX мы выяснили, что UCX не освобождает память сразу после вызова munmap. Вместо этого он перемещает регион в очередь на отмену для последующей очистки. Эту очередь управляет пул памяти UCX, который динамически расширяется, чтобы вместить больше записей по мере необходимости. В результате регионы памяти накапливались, не освобождаясь, и растущая очередь требовала дополнительных выделений, что объясняет, почему mmap вызывался во время операций munmap. В качестве альтернативного решения проблемы утечки памяти можно установить переменную среды UCX_RCACHE_MAX_UNRELEASED=1024 (значение по умолчанию — inf), что ограничивает количество не освобожденных регионов памяти в очереди, заставляя UCX инициировать очистку после достижения этого порога.

Дело в том, что этого не должно было произойти в первую очередь. NIXL и vLLM действительно вызывали функцию ucp_worker_progress(), которая должна была инициировать очистку пула памяти. Почему это не произошло в этом конкретном крайнем случае, пока неясно. Но это показало, что установка значения по умолчанию UCX_RCACHE_MAX_UNRELEASED равным бесконечности была неверной. Команды UCX и NIXL решили изменить это поведение в будущей версии NIXL. А пока мы внедрили исправление в репозиторий vLLM, чтобы помочь сообществу избежать той же утечки.

Краткое изложение расследования.

Краткое изложение расследования:

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

  2. Мы попытались упростить среду и получить минимальный воспроизводимый пример. К сожалению, ошибка воспроизводилась только в сложной среде, с большой моделью и включенным дезагрегированием.

  3. Мы использовали Memray, Guppy 3 и Heaptrack для анализа утечки. Ничего очевидного из этих инструментов не получилось. Однако мы заметили что-то необычное в метриках Heaptrack. Объем резидентной памяти был необычно большим, поэтому мы решили изучить это направление.

  4. Используя pmap, мы смогли увидеть выделения RSS, которые продолжали расти, с соответствующими базовыми указателями.

  5. Мы хотели получить больше информации о том, кто выполняет эти вызовы. С помощью небольшого скрипта и BPFtrace мы обнаружили, что утечки происходят из вызовов mmap. Даже при наших лучших усилиях нам не удалось собрать полные трассировки стека (что позволило бы нам точно определить проблемное место вызова), но это привело нас к выводу, что системные вызовы выполняются сильно оптимизированным пакетом.

  6. Благодаря собранной информации мы смогли настроить очень конкретные точки останова GDB, которые будут срабатывать только при проблемных вызовах.

  7. Мы обнаружили, что UCX выполняет эти вызовы. Хотя цель этих вызовов законна: улучшить производительность для передач InfiniBand, они создавали все более и более растущие выделения в RSS и не позволяли нам развертывать дезагрегированное обслуживание в течение нескольких дней.

  8. Зная источник утечки, решение было легко найти: установка UCX_MEM_MMAP_HOOK_MODE=none решила нашу проблему. Мы обсудили это расследование в репозитории vLLM и внедрили исправление для сообщества.

Что мы узнали.

Современные программные стеки построены на слоях зависимостей, каждая из которых добавляет сложность и потенциальные точки отказа. Хотя эти абстракции значительно повышают производительность программистов, они не полностью защищают их от проблем в стеке. Поэтому важно быть готовым к глубокому анализу при отладке. Однако в этих средах это редко бывает просто, особенно когда оптимизация производительности приводит к тонким крайним случаям. UCX является отличным примером этого. Его конструкция ориентирована на производительность, но способ перехвата вызовов mmap может создавать трудно отслеживаемые риски. Этот опыт еще раз продемонстрировал, насколько сложно диагностировать проблемы в глубоко связанных системах.

Это расследование также показывает важность прозрачности и сотрудничества при работе с критически важными для производительности зависимостями. Мы благодарны за сотрудничество с командами vLLM, NIXL и UCX в подтверждении и решении этой проблемы. Их опыт был критически важен для достижения решения, и мы надеемся на продолжение нашей совместной работы.

Мы выражаем благодарность следующим лицам за их помощь и сотрудничество в решении этой проблемы:

  • Роберт Шоу (Red Hat, vLLM и llm-d Maintainer)

  • Уилл Итон (Red Hat, vLLM и llm-d Maintainer)

  • Николо Луккези (Red Hat, vLLM и llm-d Maintainer)

  • Михаил Бринский (NVIDIA, NIXL Maintainer)

  • Леонид Генкин (NVIDIA, UCX Maintainer)

  • Натан Беллалу (NVIDIA, UCX и NIXL Maintainer)

Следующая глава ИИ — за вами.