MAATRIX / Блог / Инференс падал каждые 40 минут: фрагментация видеопамяти

Инференс падал каждые 40 минут: фрагментация видеопамяти

MAATRIX

Сервис инференса падает с CUDA out of memory, хотя nvidia-smi клянётся, что свободной памяти полно. Хуже того — падает не хаотично, а с пугающей регулярностью, будто по таймеру. Если вы уже проверили утечку памяти и она не подтвердилась, велика вероятность, что дело не в объёме, а в том, как этот объём раскрошен на мелкие несовместимые куски. Разбираем инцидент по шагам: что видели, какие версии отбросили и что в итоге решило проблему на продакшене.

Что сломалось

Сервис отдавал ответы модели через собственный API поверх PyTorch — обычный HTTP-сервис с очередью запросов, батчингом на лету и одной видеокартой на 24 ГБ под весь трафик. Работал стабильно часами, потом резко падал с трейсбеком CUDA error: out of memory, systemd поднимал процесс заново за несколько секунд, и всё повторялось.

Первое, что бросилось в глаза дежурному инженеру: интервал между падениями держался около 40 минут — не «примерно раз в час плюс-минус», а именно кучно вокруг одного числа, с разбросом в несколько минут. Такая регулярность — это почти всегда подсказка: где-то в системе копится состояние, а не просто «иногда прилетает слишком большой запрос».

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

Что показывали логи и метрики

В логах процесса — стандартная для PyTorch форма ошибки: CUDA out of memory. Tried to allocate N MiB. GPU has X GiB total capacity, Y GiB already allocated, Z GiB reserved but unallocated by PyTorch. Формулировка «reserved but unallocated» — важная деталь, к которой пришлось вернуться позже: PyTorch прямым текстом говорит, что память зарезервирована, но не может её использовать под конкретный запрос.

Параллельно с этим в Grafana (метрики шли через DCGM exporter и nvidia-smi --query-gpu в связке с Prometheus) занятость видеопамяти на графике вела себя обычно: колебалась в диапазоне заметно ниже лимита карты, без выраженного восходящего тренда к моменту падения. Пила вверх-вниз в такт батчам — и всё, никакого монотонного роста к отметке 100%. Это сразу снимало самую очевидную версию.

Второй важный факт с графиков: падения не совпадали с пиками входящего трафика. Часть инцидентов случилась в относительно спокойные окна, часть — под нагрузкой. Если бы дело было в том, что «слишком много одновременных запросов не влезает в память», крах привязывался бы к всплескам RPS. Здесь такой корреляции не было.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Гипотеза первая: утечка памяти

Первым делом заподозрили классическую утечку — где-то в цикле обработки запроса тензоры не освобождаются и накапливаются от запроса к запросу. Добавили периодическое логирование torch.cuda.memory_allocated() и torch.cuda.max_memory_allocated() прямо в код обработчика, раз в несколько десятков запросов.

Картина не подтвердила версию: после каждого батча memory_allocated возвращался примерно к одному и тому же базовому уровню, без накопления от итерации к итерации. Если утечка и была, то не в объёме реально используемых тензоров. Отдельно проверили и то, что советуют в первую очередь при подозрении на утечку — не держит ли код лишние ссылки на выходные тензоры, не забыт ли .detach() при накоплении статистики, — с тем же результатом. Подход к диагностике такого рода утечек подробно описан в материале «утечка памяти: как поймать за неделю до падения», но в данном случае сам паттинг поведения не соответствовал утечке: там рост монотонный и упирается в лимит один раз, здесь — циклические падения без видимого роста базовой линии. Версию закрыли.

Гипотеза вторая: несколько процессов делят одну карту

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

Проверили nvidia-smi в разрезе процессов (nvidia-smi --query-compute-apps=pid,used_memory --format=csv) в момент, максимально близкий к падению, и подняли историю через логи systemd-юнита. Лишних процессов не нашли: PID стабильно один и тот же на протяжении всего аптайма, никаких зависших воркеров от прошлых рестартов. Гипотезу отбросили.

Гипотеза третья: просто не хватает памяти под пиковый батч

Третья версия была самой скучной и самой ожидаемой: возможно, батчер иногда честно собирает слишком крупный батч — длинные промпты, длинная генерация с большим KV-кэшем — и легитимно упирается в лимит карты. Тогда решение было бы простым: снизить максимальный размер батча или максимальную длину контекста.

Сопоставили тайминги падений с логами очереди запросов: размер батча и суммарная длина последовательностей в момент краха не выделялись на общем фоне — были батчи заметно крупнее, которые проходили без проблем, и в то же время падение случалось на батче среднего размера. Как устроено выделение памяти под контекст и почему диалог «ест» VRAM, разобрано в статье про KV-кэш — но здесь сам факт большого КВ-кэша объяснял бы стабильно высокое потребление, а не падение именно на сорок первой минуте. Версию тоже сняли.

Реальная причина: фрагментация кэширующего аллокатора

Ключевым оказался именно тот нюанс из текста ошибки, который сначала пропустили мимо ушей — «reserved but unallocated by PyTorch». PyTorch не запрашивает память у CUDA под каждый тензор заново — это слишком медленно. Вместо этого он держит собственный кэширующий аллокатор: один раз резервирует у драйвера крупный блок, а дальше нарезает и переиспользует его сам, без похода к драйверу.

Проблема в том, что при переменной длине входных последовательностей (разные промпты, разная длина генерации, динамический батчинг на лету) аллокатор запрашивает и освобождает блоки самых разных размеров. Освобождённый блок под тензор одной формы часто не подходит под тензор другой формы — и аллокатор не может склеить соседние маленькие свободные фрагменты в один большой кусок так же эффективно, как это делает обычный malloc для CPU-памяти. В результате суммарно свободной памяти в резерве может быть много, но самого крупного непрерывного куска не хватает под очередную аллокацию — отсюда и «reserved but unallocated» в тексте ошибки, и нормальный на вид график в nvidia-smi, который считает всю зарезервированную PyTorch-ом память как «занятую», не разбираясь во внутренней фрагментации.

Чтобы это подтвердить, вместо агрегированных чисел стали логировать torch.cuda.memory_stats() целиком — там, в отличие от memory_allocated(), видно отдельно allocated_bytes.all.current (реально используемая память) и reserved_bytes.all.current (сколько занято у драйвера), а также счётчики num_alloc_retries и num_ooms. Именно reserved медленно, но заметно рос в течение цикла и обнулялся только после рестарта процесса — при том что allocated оставался стабильным. Регулярность в 40 минут объяснялась просто: это было среднее время, за которое при данном профиле трафика (конкретное распределение длин запросов у конкретного сервиса) накапливалось достаточно «дырявых» освобождённых блоков, чтобы очередной запрос на аллокацию не нашёл подходящего непрерывного куска — и получил отказ, хотя формально свободная память в сумме была.

Отдельно стоит сказать: это не то же самое, что нехватка VRAM в принципе. Если бы карты не хватало под рабочую нагрузку, помогло бы только уменьшение батча, контекста или переход на карту с большим объёмом. Здесь же физически памяти хватало — не хватало её *непрерывности*, и это принципиально другой класс проблемы с другим набором решений.

Что изменили после инцидента

Разбор дал три уровня решения — от быстрого костыля до архитектурного исправления:

  • Быстрый стопгэп. Включили PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True в переменных окружения сервиса. Этот режим аллокатора умеет расширять уже занятые сегменты вместо того, чтобы держать много несвязанных мелких блоков, и заметно снизил частоту падений на время, пока готовили основное исправление. Отдельно проверили режим с max_split_size_mb, ограничивающий, насколько мелко можно дробить блоки при разбиении — тоже снижает фрагментацию ценой чуть менее эффективного переиспользования памяти под мелкие тензоры.
  • Правильное решение для инференса. Постепенно перевели сервис на движок инференса с постраничным (paged) управлением KV-кэшем — в частности, попробовали vLLM, где KV-кэш выделяется не сплошным куском под каждый диалог, а блоками фиксированного размера, как страницы памяти в операционной системе. Это убирает саму причину фрагментации: аллокатору больше не нужно резать и склеивать блоки произвольной формы под каждую новую длину последовательности. Частые проблемы с памятью именно в vLLM и их диагностика разобраны отдельно в статье «vLLM не запускается из-за памяти» — стоит свериться с ней перед миграцией, там же и обзор типичных ошибок эксплуатации движка.
  • Что осознанно не стали делать. Периодический принудительный вызов torch.cuda.empty_cache() по таймеру рассматривали, но отказались использовать его как решение: он не устраняет фрагментацию, а лишь возвращает свободные (но не используемые) сегменты драйверу, после чего PyTorch их снова запрашивает — это трата времени на аллокации и риск замаскировать проблему, а не исправить её. Такой вызов оставили только как диагностический инструмент, а не как часть боевого кода.
  • Мониторинг на будущее. Добавили в дашборд отдельный алерт не по абсолютному значению занятой памяти, а по разнице между reserved_bytes и allocated_bytes из memory_stats() — рост этого зазора без соответствующего роста реально используемой памяти теперь сигнализирует о накоплении фрагментации раньше, чем сервис успевает упасть.
  • Временный предохранитель. Пока миграция на постраничный движок не завершилась на всех воркерах, оставили плановый мягкий рестарт процесса раз в несколько часов вне пиковых окон — это не решение проблемы, а осознанный компромисс, чтобы падения происходили по расписанию и без потери запросов, а не случайно посреди генерации у пользователя.

Если вы разворачиваете что-то похожее с нуля, разумнее сразу закладывать движок с постраничным KV-кэшем, а не кэширующий аллокатор общего назначения под сильно переменную длину последовательностей — особенно на арендованном GPU-сервере в Великобритании для инференса LLM, где карта одна и переустановить движок на лету без простоя сложнее, чем на кластере с резервом.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Как быстро отличить фрагментацию от утечки памяти?

По поведению torch.cuda.memory_allocated() во времени. При утечке эта метрика растёт монотонно от запроса к запросу и не возвращается к базовому уровню. При фрагментации allocated стабилен, а растёт reserved (разница между ними) — само используемое количество памяти не увеличивается, увеличивается доля недоступного для новых аллокаций резерва.

Помогает ли просто увеличить объём видеопамяти карты?

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

Обязательно ли переходить на vLLM, чтобы решить проблему?

Нет — expandable_segments:True и разумные лимиты батча снимают проблему для многих нагрузок без смены движка. Переход на постраничный KV-кэш стоит делать, когда трафик уже ощутимо переменный по длине запросов и стопгэп заметно не справляется или требует слишком частых рестартов.

Почему nvidia-smi вообще не показывает фрагментацию напрямую?

Потому что nvidia-smi видит только то, что PyTorch зарезервировал у драйвера в целом, а не то, как эта резервация организована внутри собственного аллокатора PyTorch. Для внутренней картины нужен именно torch.cuda.memory_stats() или torch.cuda.memory_summary(), а не системные утилиты уровня драйвера.

Регулярность падений — это всегда признак фрагментации?

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

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →