MAATRIX / Блог / Как видеокарта делит память между процессами и почему второй падает первым

Как видеокарта делит память между процессами и почему второй падает первым

MAATRIX

Вы запускаете обучение модели на сервере с одной видеокартой — процесс благополучно стартует и забирает себе почти всю видеопамять. Через полчаса коллега пробует запустить вторую задачу на той же GPU и мгновенно получает CUDA error: out of memory, даже не успев толком начать вычисления. Для тех, кто привык к тому, как Linux управляет оперативной памятью, это выглядит нелогично: там ведь можно «пообещать» процессам больше памяти, чем есть физически, и разобраться с этим потом. На GPU так не работает. Разберём, почему видеопамять делится между процессами настолько жёстче, чем ОЗУ, и что из этого следует для того, в каком порядке вы запускаете задачи на общем GPU-сервере.

Чем видеопамять принципиально отличается от оперативной

В оперативной памяти Linux у каждого процесса есть виртуальное адресное пространство, а ядро связывает его со страницами физической RAM по мере обращения — лениво, через page fault. Именно поэтому ядро может позволить себе overcommit: разрешить процессам суммарно «попросить» больше памяти, чем есть в системе, в расчёте на то, что не все обещания будут востребованы одновременно. Если спрос всё же дойдёт до предела, в дело вступает OOM killer — механизм ядра, который выбирает жертву среди работающих процессов и убивает её, чтобы освободить память для остальных. А ещё у ядра есть swap: страницы, к которым давно не обращались, можно вытеснить на диск и подгрузить обратно при необходимости. Overcommit и swap вместе создают иллюзию памяти «с запасом», которой в моменте физически может не хватать — и это осознанный компромисс, разобранный подробнее в статье про overcommit памяти в Linux.

У видеокарты ничего этого нет по умолчанию. Драйвер CUDA не откладывает выделение памяти «на потом» и не обещает больше, чем реально свободно на устройстве: когда процесс вызывает cudaMalloc(), драйвер сразу резервирует запрошенный блок физической видеопамяти или сразу же возвращает ошибку, если свободного места не хватает. Никакого виртуального адресного пространства, растянутого поверх меньшего физического объёма, здесь нет — то, что процесс запросил и получил, действительно занято на карте прямо сейчас. Своп видеопамяти на системную RAM в классической модели CUDA тоже не происходит: у NVIDIA есть механизм Unified Memory с автоматической миграцией страниц между RAM и VRAM, но это отдельная модель программирования, которую нужно явно использовать, и она даёт заметный проигрыш в скорости на переносе страниц туда-обратно. Подавляющее большинство фреймворков для обучения и инференса её не используют — они работают с «честной», физически зарезервированной видеопамятью.

Что происходит при выделении памяти на GPU

Когда процесс впервые обращается к видеокарте, драйвер создаёт для него CUDA-контекст — и это уже не бесплатно: контекст сам по себе занимает некоторый объём видеопамяти на служебные структуры. Дальше фреймворк начинает выделять память под веса модели, активации, буферы для промежуточных вычислений — каждый такой вызов идёт через cudaMalloc() или его аналоги и либо успешно резервирует блок, либо возвращает код ошибки нехватки памяти.

Здесь есть важный практический нюанс: PyTorch, TensorFlow и большинство других фреймворков не вызывают cudaMalloc() на каждую мелкую операцию — это было бы слишком медленно. Вместо этого они используют собственный кеширующий аллокатор: один раз забирают у драйвера крупный блок памяти, а дальше сами нарезают его на кусочки под тензоры и переиспользуют освободившиеся куски внутри процесса. Снаружи, через nvidia-smi, это выглядит так, будто процесс «съел» гораздо больше памяти, чем реально нужно его текущим вычислениям — на самом деле часть зарезервированного объёма просто держится про запас, чтобы не гонять cudaMalloc()/cudaFree() на каждой итерации. Для темы этой статьи важно другое следствие: память, которую фреймворк один раз зарезервировал у драйвера, обычно не возвращается системе автоматически до завершения процесса — то есть чужому процессу этот объём недоступен, даже если он в моменте простаивает.

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

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

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

Почему второй процесс получает ошибку сразу, а не встаёт в очередь

Возвращаемся к сценарию из начала статьи. Первый процесс — скажем, обучение одной модели — стартовал раньше и через кеширующий аллокатор фреймворка забрал себе большую часть видеопамяти карты. Второй процесс запускается позже и пытается выделить память сверх того, что реально осталось свободным. Драйвер CUDA не умеет и не пытается «подвинуть» первый процесс, чтобы освободить место второму: у него просто нет для этого механизма, аналогичного OOM killer в ядре Linux. Вызов выделения памяти второго процесса завершается ошибкой немедленно — cudaErrorMemoryAllocation на уровне CUDA, что фреймворки транслируют в свои собственные исключения вроде torch.cuda.OutOfMemoryError или RESOURCE_EXHAUSTED в TensorFlow.

Это принципиально иное поведение по сравнению с тем, что происходит при нехватке RAM. На CPU ядро может: подождать, пока другой процесс освободит память сам; вытеснить неиспользуемые страницы в swap; в крайнем случае — выбрать жертву среди процессов и убить её, чтобы разгрузить систему. GPU-драйвер не делает ничего из этого. Он не ставит запрос в очередь, не ждёт освобождения, не выбирает, чей процесс менее важен, и уж точно не убивает первый процесс, чтобы освободить место второму. Он просто возвращает ошибку тому, кто попросил память и не получил её — и что делать с этой ошибкой дальше, решает уже сама прикладная программа: упасть с трейсбеком, попробовать выделить меньший буфер, подождать и повторить попытку самостоятельно (если это заложено в коде) или деградировать до меньшего batch size. По умолчанию большинство скриптов обучения просто падают.

Похожий сюжет, но с другой стороны — когда оба процесса технически поместились по памяти, но начали делить между собой вычислительные ядра, — разобран в статье о том, как два процесса делили одну видеокарту. Там причиной замедления была не память, а способ, которым GPU исполняет код нескольких процессов — но оба случая объединяет одно: карта не «размазывает» ресурс поровну автоматически, и то, что происходит с одним процессом, напрямую зависит от того, что уже успел сделать другой.

Почему порядок запуска процессов на GPU-сервере важен

Из предыдущих двух разделов следует практический вывод, который легко упустить, если раньше вы работали в основном с CPU и RAM: на общей GPU порядок запуска задач — это не деталь для удобства, а фактор, который реально решает, чья задача получит ресурс, а чья упадёт с ошибкой нехватки памяти.

На CPU относительный порядок запуска процессов почти не имеет значения для того, получит ли процесс память — планировщик и подсистема виртуальной памяти сглаживают конкуренцию: overcommit позволяет обоим процессам получить обещание памяти, а swap и OOM killer разбираются с реальным конфликтом уже потом, когда он действительно случится, и не обязательно ценой того процесса, что стартовал позже. На GPU конкуренция за память разрешается буквально в порядке очереди на аллокацию: кто раньше вызвал cudaMalloc() — тот и получил кусок, независимо от того, насколько эта задача приоритетна или сколько памяти ей нужно на самом деле. Второй процесс не проигрывает по важности или приоритету — он проигрывает исключительно потому, что опоздал с запросом.

Есть и менее очевидный эффект: поскольку кеширующий аллокатор фреймворка обычно не отдаёт память обратно драйверу, пока процесс не завершится, ранний процесс может держать зарезервированным гораздо больше, чем ему реально нужно именно в этот момент вычислений. То есть даже если первая задача сейчас простаивает или считает что-то маленькое, освобождённая ей память может оставаться недоступной для второго процесса просто потому, что фреймворк её не вернул. Отсюда практическое следствие для эксплуатации сервера с несколькими GPU-задачами: нельзя полагаться на то, что «карта сама разберётся» и справедливо поделит память между процессами, которые стартуют в разное время. Планирование — то есть осознанное решение, какая задача когда стартует и сколько памяти ей заранее выделено, — становится частью эксплуатации GPU-сервера, а не опциональной надстройкой.

Как посмотреть, кто и сколько занял видеопамяти

Прежде чем ограничивать потребление памяти, полезно увидеть текущую картину. Базовый инструмент — nvidia-smi:

nvidia-smi

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

nvidia-smi --query-compute-apps=pid,process_name,used_memory --format=csv

А для сводки по самим картам — свободный/занятый объём и загрузку:

nvidia-smi --query-gpu=index,memory.used,memory.free,memory.total,utilization.gpu --format=csv

Если процессов и карт на сервере несколько, читать сырой вывод nvidia-smi неудобно — здесь помогает утилита gpustat (ставится через pip install gpustat), которая даёт компактную построчную сводку по каждой карте с списком процессов и используемой памятью, обновляемую в реальном времени флагом -i.

Важно держать в голове нюанс из второго раздела: цифра в nvidia-smi — это то, что процесс зарезервировал через свой аллокатор, а не то, что ему нужно прямо сейчас для текущих вычислений. Если вы видите, что процесс «занял» 20 из 24 ГБ, но при этом простаивает, это не значит, что 15 ГБ из них можно безопасно отдать другому — фреймворк не отпустит их, пока процесс жив.

Как явно ограничить потребление видеопамяти каждым процессом

Раз карта не подстраивается под несколько процессов автоматически, единственный надёжный способ безопасно делить её между задачами — заранее и явно ограничить, сколько видеопамяти может забрать себе каждый процесс, вместо того чтобы полагаться на поведение по умолчанию «бери столько, сколько получится».

В PyTorch есть прямой способ ограничить долю памяти карты для процесса:

import torch
torch.cuda.set_per_process_memory_fraction(0.5, device=0)

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

В TensorFlow похожий эффект даёт ограничение виртуального устройства:

import tensorflow as tf
gpus = tf.config.list_physical_devices('GPU')
tf.config.set_logical_device_configuration(
    gpus[0],
    [tf.config.LogicalDeviceConfiguration(memory_limit=4096)]
)

либо set_memory_growth(gpu, True), который заставляет TensorFlow резервировать память постепенно, по мере необходимости, а не забирать всю карту сразу при старте — это снижает риск, что один процесс займёт весь объём просто потому, что запустился первым.

Для инференс-серверов вроде vLLM есть параметр, напрямую управляющий долей видеопамяти под кеш модели — --gpu-memory-utilization (значение от 0 до 1). Он не панацея: если выставить его слишком высоко, а рядом уже работает другой процесс, вы получите ту самую ошибку нехватки памяти при старте вместо аккуратной деградации, что подробно разобрано в статье о том, почему vLLM не запускается из-за памяти.

На уровне самого сервера, если карту делят несколько независимых сервисов, стоит смотреть в сторону аппаратного разделения — на поддерживающих его картах (архитектуры вроде Ampere и новее) NVIDIA MIG (Multi-Instance GPU) позволяет физически нарезать одну карту на несколько изолированных инстансов с собственным, гарантированным объёмом памяти каждому — тогда один процесс просто не может занять чужую долю, даже если попытается. Это более жёсткое и предсказуемое решение, чем программные лимиты фреймворков, но оно статично: разбиение задаётся заранее и не меняется на лету под нагрузку. NVIDIA MPS (Multi-Process Service), в отличие от MIG, решает другую задачу — эффективнее делит вычислительные ядра между процессами, но сам по себе не ограничивает потребление видеопамяти каждым из них, поэтому его стоит рассматривать отдельно от вопроса о разделении памяти. Обзор общих подходов к разделению одной карты между несколькими сервисами — в статье как раздать одну GPU нескольким сервисам.

Практический ориентир простой: если на сервере предполагается больше одного GPU-процесса одновременно, для каждого из них стоит заранее решить, сколько видеопамяти он получит, и явно это ограничение задать — а не надеяться, что фреймворки сами договорятся между собой о честном разделе.

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

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

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

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

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

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

Может ли драйвер вытеснить видеопамять первого процесса в оперативную, чтобы освободить место второму?

В классической модели CUDA — нет, cudaMalloc() резервирует физическую видеопамять напрямую, без вытеснения в RAM. Технически существует NVIDIA Unified Memory с автоматической миграцией страниц между RAM и VRAM, но это отдельная модель программирования, которую нужно явно использовать в коде, и она не является поведением по умолчанию для большинства фреймворков обучения и инференса — к тому же миграция страниц ощутимо дороже обращения к памяти, физически расположенной на самой карте.

Если оба процесса запускаются буквально одновременно, кто получит ошибку?

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

Как заранее понять, хватит ли памяти для второй задачи, не запуская её вслепую?

Посмотреть nvidia-smi --query-gpu=memory.free --format=csv перед стартом и сравнить с ориентировочными требованиями второй задачи. Точную цифру потребления заранее часто не знаешь без пробного запуска — поэтому надёжнее не гадать, а сразу закладывать явные лимиты памяти на каждый процесс, как описано в разделе про ограничение потребления.

MIG или MPS полностью решают проблему конкуренции за видеопамять?

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

Что делать, если приложение всё же должно пережить нехватку памяти, а не упасть?

Обрабатывать исключение на уровне кода (torch.cuda.OutOfMemoryError и аналоги), уменьшать batch size или освобождать неиспользуемые буферы через torch.cuda.empty_cache() при повторной попытке. Но это работа с последствиями, а не замена явного лимита — надёжнее не допускать конфликта заранее, чем ловить его в рантайме.

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

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

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