Когда память утекает, но код чист
Вы ставите Ollama с свежей Llama 3.2 90B или разворачиваете vLLM для Qwen 2.5 72B. Все работает. Модель отвечает. Потом проходит час, два, десять запросов. И в самый неподходящий момент - хлоп. Killed. В логах ясно: Out of memory. Системный мониторинг показывает, что процесс съел всю доступную RAM и swap.
Первая мысль - утечка в Python, C++, в самом инференс-движке. Начинаешь искать memory_profiler, снимать дампы, писать баги в репозитории. Но проблема часто не в них. Она глубже. В самом фундаменте - в аллокаторе памяти стандартной библиотеки C (glibc), которую использует практически каждый сервер LLM, написанный на Python, C++ или Go.
Запомните: если память процесса в top или htop (столбец RES/VIRT) стабильно растет, но внутри процесса (например, через tracemalloc в Python) утечек не видно - это почти наверняка фрагментация кучи glibc.
Что ломается внутри malloc
glibc malloc (аллокатор ptmalloc2) - не волшебство. Он управляет двумя типами памяти: heap (куча) через системный вызов brk/sbrk и anonymous mapping (анонимные отображения) через mmap.
- Куча (brk): один непрерывный регион памяти. Быстрый для мелких объектов. Но если вы выделяете и освобождаете объекты разного размера (а LLM серверы постоянно создают и удаляют буферы для токенов, промежуточных вычислений, KV-cache), эта куча превращается в швейцарский сыр. Свободные блоки памяти перемешаны с занятыми. Это фрагментация.
- Mmap-регионы: каждый большой запрос памяти (по умолчанию > 128 KB) выделяется отдельным блоком через
mmap. Когда такой блок освобождается, его можно сразу вернуть ядру. Фрагментации нет.
Проблема в пороге. По умолчанию glibc использует mmap для запросов больше 128 KB. А что делает LLM сервер? Он постоянно выделяет буферы для запросов, которые могут быть и 64 KB, и 100 KB. Они попадают в кучу. Фрагментируют ее. И самое главное - glibc крайне неохотно возвращает память из кучи обратно ядру.
Два волшебных рычага: MMAP_THRESHOLD и TRIM_THRESHOLD
Здесь в игру вступают переменные окружения, которые управляют поведением malloc. Они недокументированы для обычных пользователей, но DevOps и системные инженеры знают их десятилетиями.
| Переменная | Значение по умолчанию | Что делает |
|---|---|---|
MALLOC_MMAP_THRESHOLD_ |
128 KB (131072) | Запросы памяти больше этого порога выделяются через mmap, а не из кучи. |
MALLOC_TRIM_THRESHOLD_ |
128 KB (131072) | Если свободная память на верху кучи превышает этот порог, glibc попробует вернуть её ядру через madvise. |
Стратегия проста: заставить glibc выделять больше памяти через mmap (где нет фрагментации) и заставить его чаще возвращать свободную память ядру.
1 Диагностика: точно ли это фрагментация кучи?
Перед тем как крутить ручки, убедитесь. Запустите ваш сервер LLM (Ollama, vLLM, Text Generation Inference) и дайте ему поработать под нагрузкой. Затем установите gdb и подключитесь к процессу (это безопасно для работы, если делать аккуратно).
# Находим PID процесса LLM сервера
ps aux | grep -E '(ollama|vllm|text-generation-launcher)'
# Подключаемся gdb (не забудьте установить debug symbols, если нужно)
gdb -p
# В gdb выполняем команду для вывода статистики malloc
(gdb) call malloc_stats()
# Или, если хотите увидеть карту памяти процесса, выйдите из gdb (Ctrl+D) и:
cat /proc/<PID>/maps | head -30
В выводе malloc_stats() смотрите на строки system bytes (память, которую glibc получил от ядра) и in use bytes (память, которую использует ваша программа). Если первое сильно больше второго - у вас фрагментация. Иногда разница достигает 2-3x.
2 Эксперимент: настройка переменных окружения
Теперь самое простое. Останавливаем сервер. Выставляем переменные и запускаем заново. Значения подбираются экспериментально, но для большинства LLM серверов работает такая комбинация:
export MALLOC_MMAP_THRESHOLD_=131072 # 128 KB - можно оставить как есть или уменьшить
export MALLOC_TRIM_THRESHOLD_=131072 # 128 KB - оставляем
# Или более агрессивный вариант для сильно фрагментирующих нагрузок:
export MALLOC_MMAP_THRESHOLD_=65536 # 64 KB - больше аллокаций через mmap
export MALLOC_TRIM_THRESHOLD_=65536 # 64 KB - чаще тримим
Запускаем сервер с этими переменными. Для Ollama это будет выглядеть так:
MALLOC_MMAP_THRESHOLD_=65536 MALLOC_TRIM_THRESHOLD_=65536 ollama serve
Для vLLM или TGI добавьте переменные в команду запуска или в systemd service файл. Если вы сталкивались с проблемами vLLM на многопроцессорных системах, эта настройка может стать дополнительным стабилизирующим фактором.
Важно: не ставьте MALLOC_MMAP_THRESHOLD_ слишком низко (например, 4096). Каждое выделение через mmap создает отдельную VMA (Virtual Memory Area), что увеличивает нагрузку на ядро и может замедлить работу. Начните с 65536 (64 KB) и наблюдайте.
3 Мониторинг результатов: смотрим на графики
После применения настроек мониторинг обязателен. Смотрите не только на общее потребление памяти (RES), но и на:
- Стабильность: память перестала бесконечно расти?
- Производительность: не появились ли лаги? Выделение через mmap медленнее, чем через кучу.
- Количество mmap-регионов:
cat /proc/<PID>/maps | grep -c "^[0-9a-f].*\[anon\]"
Если память все еще утекает, возможно, проблема не только во фрагментации. Например, у вас может быть реальная утечка в коде, или ваш CPU инференс на старом сервере упирается в какие-то другие лимиты.
Ошибки, которые убьют ваш сервер быстрее OOM
С этими настройками можно настрелять себе в ногу. Вот как:
| Ошибка | Последствие | Как избежать |
|---|---|---|
Выставить MALLOC_TRIM_THRESHOLD_=1 |
glibc будет пытаться тримить память после освобождения КАЖДОГО байта. Катастрофа для производительности. | Не опускайтесь ниже 4096. 65536 - безопасный порог. |
Выставить MALLOC_MMAP_THRESHOLD_ в гигабайт |
Практически все аллокации пойдут в кучу. Фрагментация станет еще хуже, чем по умолчанию. | Если хотите уменьшить mmap-аллокации, повышайте очень осторожно, не более 1-2 MB. |
Использовать настройки в Docker без --privileged |
Некоторые версии glibc требуют прав для изменения этих параметров после запуска. Настройки просто проигнорируются. | Устанавливайте переменные окружения при запуске контейнера: docker run -e MALLOC_MMAP_THRESHOLD_=65536 ... |
А если не сработает? Альтернативные аллокаторы
Иногда glibc настолько безнадежен для вашей конкретной нагрузки, что проще заменить аллокатор. Два главных кандидата:
- jemalloc (от Facebook): разработан для многопоточных серверов с долгой жизнью. Значительно уменьшает фрагментацию. Установите
libjemalloc2и запускайте сервер сLD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2. - tcmalloc (от Google): тоже хорош для многопоточности, но может вести себя нестабильно с некоторыми версиями Python. Если ваш LLM сервер написан на C++ (как llama.cpp), tcmalloc может дать прирост производительности.
Переход на jemalloc - часто самое радикальное и эффективное решение. Особенно для долгоживущих серверов LLM, которые должны работать неделями без перезапуска.
Итог: когда это поможет, а когда нет
Настройка MALLOC_MMAP_THRESHOLD_ и MALLOC_TRIM_THRESHOLD_ - это не серебряная пуля. Она решит проблему, если:
- Ваш сервер LLM постоянно выделяет и освобождает буферы среднего размера (от 4 KB до 128 KB).
- Вы видите рост RES в
top, но внутренние инструменты мониторинга (например,memory_profilerдля Python) не показывают утечек. - Сервер убивается OOM-killer через несколько часов/дней работы, а не сразу.
Не поможет, если:
- У вас реальная утечка в коде (например, не очищается кэш, растут глобальные списки).
- Проблема в видеопамяти (VRAM), а не в оперативной. Тут нужно смотреть на квантование KV-кэша или другие техники.
- Вы работаете на macOS (там другой аллокатор, и проблемы другие). Кстати, если вы маковод, почитайте про отключение сжатия памяти в macOS - это отдельная боль.
Последний совет, который почти никогда не пишут: после настройки этих переменных перезапустите сервер не один, а два раза. Иногда glibc кеширует некоторые структуры при первом запуске, и полный эффект виден только со второго. Это странно, но это работает.
И помните - если вы запускаете LLM на слабом железе, будь то домашний сервер с 10 ГБ VRAM или слабый Android, каждая мегабайта памяти на счету. Эти настройки могут быть разницей между стабильной работой и постоянными крашами.