CUDA out of memory: причины и что менять в конфиге

Четыре причины нехватки VRAM, разбор чисел из текста ошибки и параметры vLLM, Transformers и Diffusers, которые снижают пик, — с ценой каждого изменения.

YouGPU Team 12 мин

TL;DR — Кратко:

  • В тексте ошибки четыре числа. Разрыв между reserved и allocated отделяет фрагментацию от реальной нехватки памяти, и лечатся эти два случая по-разному.
  • Обучение в смешанной точности с AdamW требует около 18 байт на параметр против 2 байт на весах при инференсе — по документации Hugging Face.
  • В vLLM память под KV-кеш резервируется на старте долей gpu_memory_utilization (по умолчанию 0.92), поэтому OOM там лечится max-model-len и max-num-seqs, а не размером батча.
  • Если та же команда падала не каждый раз, причина чаще во фрагментации. Переменная PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True собирает сегменты в одно виртуальное пространство.

torch.OutOfMemoryError: CUDA out of memory означает ровно одно: аллокатор PyTorch не смог выдать блок запрошенного размера. Это не всегда «модель не влезла». Свободная память может быть занята чужим процессом, разбита на куски, которые по отдельности малы, или удерживаться тензорами, которые не были отпущены.

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

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

Что означает сообщение об ошибке

Что значит каждое число в тексте ошибки

Сообщение содержит четыре величины: сколько запросили, сколько свободно, сколько занято процессом и сколько из занятого удерживает аллокатор PyTorch. Структура текста фиксированная, числа у вас будут свои:

torch.OutOfMemoryError: CUDA out of memory. Tried to allocate 2.00 GiB.
GPU 0 has a total capacity of 23.64 GiB of which 512.00 MiB is free.
Process 3121 has 23.14 GiB memory in use. Of the allocated memory
20.90 GiB is allocated by PyTorch, and 1.63 GiB is reserved by PyTorch
but unallocated. If reserved but unallocated memory is large try setting
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True to avoid fragmentation.
СтрокаЧто означаетО чём говорит
Tried to allocateразмер одного блока, который не удалось выдатьесли он сопоставим с ёмкостью карты — виноват один тензор
total capacityсколько памяти видит PyTorch, а не число из спецификациичасть ёмкости уходит на служебные буферы
allocated by PyTorchсумма живых тензоровреальное потребление вашей задачи
reserved by PyTorch but unallocatedкеш аллокатора, свободный, но не отданный драйверубольшой разрыв — признак фрагментации

Практический признак читается за секунду. Если allocated почти равен total capacity, памяти действительно не хватает и нужно менять конфигурацию. Если allocated заметно меньше, а reserved but unallocated велик, память есть, но не одним куском.

Чем reserved отличается от allocated

allocated — это сумма живых тензоров, reserved — сколько памяти PyTorch забрал у драйвера в свой кеш. Кеширующий аллокатор PyTorch не возвращает освободившиеся блоки драйверу сразу, а переиспользует их для следующих выделений, чтобы не платить за вызов cudaMalloc на каждом шаге (PyTorch, CUDA semantics).

Отсюда следствие, которое сбивает с толку при диагностике: nvidia-smi показывает reserved, а не allocated. Неиспользуемая память, которую держит аллокатор, в выводе nvidia-smi выглядит как занятая.

Почему nvidia-smi показывает занятую память, когда ничего не считается

Инициализация контекста CUDA занимает часть памяти ещё до создания первого тензора, и эта часть не возвращается до завершения процесса. К ней добавляются рабочие буферы cuDNN и cuBLAS, а также кеш аллокатора. Поэтому «пустой» процесс PyTorch на карте — это нормальное состояние, а не утечка.

Проверять занятость карты по nvidia-smi имеет смысл только для поиска чужих процессов. Для своей задачи смотрите на числа из PyTorch — они разделяют живые тензоры и кеш.

Куда уходит видеопамять

Почему обучение падает там, где инференс проходит

Обучение в смешанной точности с оптимизатором AdamW требует около 18 байт на параметр модели плюс память под активации, тогда как инференсу нужно 2 байта на параметр в fp16 или bf16. Разница девятикратная по статике, а с активациями она больше, и это объясняет, почему модель, которая отвечает на запросы на карте с 24 ГБ, не дообучается на ней же.

КомпонентБайт на параметр
Веса: копия fp32 плюс копия fp166
Градиенты (fp32)4
Состояния Adam / AdamW: momentum и variance в fp328
Итого статики18
Статическое потребление памяти: обучение против инференсаГоризонтальная диаграмма. Обучение в смешанной точности с AdamW — 18 байт на параметр: веса 6, градиенты 4, состояния оптимизатора 8. Инференс в fp16 — 2 байта на параметр.Обучение, смешанная точность + AdamW — 18 байт на параметрвеса 6градиенты 4состояния оптимизатора 8Инференс, fp16 или bf16 — 2 байта на параметрвеса 2Шкала общая: 1 байт на параметр = 38 px. Источник: Hugging Face, Model memory anatomy.
Статика обучения против статики инференса. Активации в диаграмму не входят: они зависят от батча и длины последовательности.

Источник разбивки — страница Model memory anatomy в документации Hugging Face Transformers. Там же указано, что квантованная версия Adam из bitsandbytes сжимает состояния оптимизатора с 8 до 2 байт на параметр. Подробный разбор того, куда уходит каждый байт при обучении, у нас есть в отдельном справочнике по VRAM.

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

Веса, градиенты и состояния оптимизатора — константа. От батча и длины последовательности зависят только активации, и именно они делают потребление переменным. Модель, которая загрузилась и отработала на батче 1, падает на батче 8 не потому, что стала больше.

У классической реализации внимания есть дополнительный множитель: матрица QK^T материализуется в памяти, и её размер квадратичен по длине последовательности. FlashAttention эту матрицу в HBM не записывает, что снижает требование к памяти с квадратичного до линейного по длине последовательности (Dao et al., arXiv:2205.14135). Поэтому переключение на flash_attention_2 или на встроенный sdpa в Transformers часто снимает OOM на длинном контексте без изменения батча.

На инференсе LLM переменная часть — это KV-кеш, он линеен по числу токенов и числу параллельных запросов. Формула для прикидки и цифры по конкретным моделям разобраны в статье про VRAM для инференса LLM.

Почему на карте доступно меньше памяти, чем указано в спецификации

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

Если задача упирается в потолок карты вплотную, разница между 24 и 48 ГБ решает больше, чем любые параметры. Сравнение конкретных карт на 48 ГБ мы разбирали в статье A6000 против L40S.

Что менять при инференсе

vLLM: почему OOM возникает на старте, а не под нагрузкой

vLLM резервирует память под KV-кеш заранее, при запуске движка, а не по мере поступления запросов. Доля памяти карты, которую займёт исполнитель модели, задаётся параметром gpu_memory_utilization и по умолчанию равна 0.92 (vLLM, Engine Arguments). Поэтому падение происходит на старте, и уменьшать размер батча в клиенте бесполезно.

ПараметрЧто делаетЦена изменения
--max-model-lenограничивает контекст, под который резервируется KV-кешдлинные запросы будут отклоняться
--max-num-seqsограничивает число последовательностей в одной итерацииниже пропускная способность
--enforce-eagerотключает захват CUDA-графов, которые занимают отдельную памятьмедленнее декодирование
--gpu-memory-utilizationдоля памяти карты под исполнителя моделивыше — больше KV-кеша, меньше запас на пики
--max-num-batched-tokensограничивает число токенов в шаге планировщиканиже пропускная способность на длинных промптах

Первые три параметра описаны на странице Conserving Memory в документации vLLM, два последних — на странице Engine Arguments там же. Рабочая отправная точка для карты, на которой модель помещается впритык:

vllm serve meta-llama/Llama-3.1-8B-Instruct 
  --max-model-len 8192 
  --max-num-seqs 32 
  --gpu-memory-utilization 0.90 
  --enforce-eager

Значение 0.90 здесь ниже стандартных 0.92 намеренно: оно оставляет запас под чужой процесс на той же карте и под аллокации вне PyTorch. Если карта отдана одному движку целиком и посторонних процессов на ней нет, значение можно вернуть к 0.92 или поднять выше — KV-кеша станет больше, а запаса на пики меньше.

Порядок подбора такой. Сначала снижаете --max-model-len до реальной длины ваших запросов: контекст 128k резервирует память, которая в большинстве сценариев не используется. Затем уменьшаете --max-num-seqs. --enforce-eager включаете последним, потому что он снижает скорость декодирования в установившемся режиме. Практический разбор запуска через vLLM и Ollama с конкретными размерами моделей — в гайде по DeepSeek-R1.

Диффузионные пайплайны: офлоад и нарезка

Диффузионный пайплайн держит на карте три модели сразу: денойзер, текстовый энкодер и VAE. Diffusers умеет держать на GPU только тот компонент, который сейчас считает, а остальные оставлять в оперативной памяти.

pipeline.enable_model_cpu_offload()   # компоненты по очереди
pipeline.vae.enable_slicing()          # декодирование батча по одному изображению
pipeline.vae.enable_tiling()           # декодирование по тайлам для больших разрешений

enable_model_cpu_offload перемещает на GPU один главный компонент за раз. Более агрессивный enable_sequential_cpu_offload выгружает веса на уровне подмодулей и экономит больше, но работает заметно медленнее. Важная деталь из документации Diffusers: перед enable_sequential_cpu_offload нельзя вызывать .to("cuda") на пайплайне, иначе экономия почти исчезает.

Разбор того, сколько весит каждая часть пайплайна у SDXL, FLUX.1 и FLUX.2, есть в справочнике VRAM для Stable Diffusion, SDXL и Flux. Если OOM возникает в графическом интерфейсе, а не в скрипте, посмотрите также разбор типичных отказов ComfyUI на арендованном GPU.

Что менять при обучении

Порядок здесь такой же: сначала параметры без потери качества, потом с потерей скорости, потом с изменением метода обучения.

ПриёмЧто экономитЦена изменения
per_device_train_batch_size вниз, gradient_accumulation_steps вверхактивациибольше шагов при том же эффективном батче
gradient_checkpointing=Trueактивацииоколо 20% к времени обучения
bf16=Trueвеса и активациитребует Ampere или новее
optim="adamw_8bit"состояния оптимизатора: 2 байта на параметр вместо 8численная точность оптимизатора
Внимание через sdpa или flash_attention_2матрицу внимания на длинном контекстеподдерживается не всеми архитектурами
LoRA или QLoRAградиенты и состояния оптимизатора замороженных весовниже потолок качества на сложных задачах

Первые четыре приёма из таблицы — это аргументы TrainingArguments, они описаны в справочнике Trainer в документации Transformers. Оттуда же оценка платы за gradient_checkpointing — около 20% к времени обучения. Конфигурация, которая собирает эти четыре приёма вместе:

from transformers import TrainingArguments

args = TrainingArguments(
    output_dir="out",
    per_device_train_batch_size=4,
    gradient_accumulation_steps=16,
    gradient_checkpointing=True,
    bf16=True,
    optim="adamw_8bit",
)

Имя adamw_8bit — то, под которым 8-битный оптимизатор bitsandbytes указан в справочнике; синоним adamw_bnb_8bit принимается тоже. Накопление градиента здесь не уменьшает эффективный батч: 4 × 16 даёт те же 64 примера на шаг оптимизатора, что и батч 64 напрямую, но пик активаций считается по четырём примерам. Это первое, что стоит попробовать, потому что результат обучения не меняется.

Если после всех перечисленных приёмов модель по-прежнему не помещается, следующий шаг — не карта побольше, а смена метода: QLoRA загружает базовые веса в 4 битах и обучает только адаптеры. Рабочий пример с конкретными цифрами по памяти — в гайде по файн-тюнингу Llama 3 8B.

Отказы, которые не лечатся уменьшением батча

Фрагментация: память есть, но не одним куском

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

Механика описана в техническом разборе кеширующего аллокатора в документации PyTorch от 1 июня 2026 года. Аллокатор работает не отдельными тензорами, а сегментами, которые запрашивает у драйвера через cudaMalloc. Без расширяемых сегментов выделение размером от 1 до 10 МиБ получает сегмент на 20 МиБ, а выделение от 10 МиБ округляется вверх до кратного 2 МиБ. Сегменты между собой независимы, поэтому свободные хвосты в разных сегментах не складываются в один блок.

Сегменты памяти до и после expandable_segmentsСхема из двух рядов. Сверху четыре независимых сегмента по 20 МиБ: занято 14, 12, 16 и 8 МиБ, свободные хвосты 6, 8, 4 и 12 МиБ не объединяются, запрос на 16 МиБ не проходит. Снизу одно виртуальное пространство на 80 МиБ: те же 50 МиБ заняты подряд, свободный хвост 30 МиБ идёт одним куском, запрос на 16 МиБ проходит.Обычный режим: четыре независимых сегмента по 20 МиБсвободно 6свободно 8свободно 4свободно 12Свободно 30 МиБ, наибольший непрерывный кусок — 12 МиБ: запрос на 16 МиБ падает с OOM.expandable_segments:True: одно виртуальное пространство на 80 МиБзанято 50свободно 30 одним кускомСоседние блоки сливаются, поэтому тот же запрос на 16 МиБ проходит.
Схема условная: размеры взяты для иллюстрации, единица шкалы — 7,2 px на МиБ.

Режим расширяемых сегментов меняет схему: PyTorch резервирует одно большое виртуальное адресное пространство и подкладывает в него физические страницы по мере надобности. Блоки внутри такого сегмента всегда могут слиться с соседями, поэтому свободное место не остаётся запертым в отдельных выделениях.

export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True

Переменную нужно выставить до старта процесса Python, а не внутри скрипта после импорта torch. Устаревший вариант той же настройки — max_split_size_mb; он работает только с нативным бэкендом аллокатора и игнорируется при backend:cudaMallocAsync.

Накопление тензоров, которые держат граф вычислений

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

total_loss += loss           # держит граф всей итерации
total_loss += loss.detach()  # хранит только число

Второй источник — валидация без отключения автоградиента. Проверьте, что цикл проверки обёрнут явно:

model.eval()
with torch.inference_mode():
    for batch in val_loader:
        preds = model(batch)

Чужой процесс на той же карте

Прерванный обучающий скрипт, вторая копия сервиса, чей-то jupyter — карта общая, и память они держат. Список процессов с их потреблением:

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

Если процесс ваш и не нужен, снимите его. Если на машине несколько карт, привяжите задачу к свободной через CUDA_VISIBLE_DEVICES.

Почему empty_cache помогает редко

torch.cuda.empty_cache() освобождает неиспользуемые блоки из кеша аллокатора, чтобы их увидели другие приложения на той же карте и nvidia-smi. Количество памяти, доступной самому PyTorch, эта функция не увеличивает — так прямо сказано в её документации.

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

Как найти, что именно заняло память

Первый инструмент — счётчики пика. Они показывают, сколько памяти реально понадобилось, и отделяют живые тензоры от кеша:

import torch

torch.cuda.reset_peak_memory_stats()
# ... шаг обучения или один запрос инференса ...
print(f"пик allocated: {torch.cuda.max_memory_allocated() / 2**30:.2f} ГиБ")
print(f"пик reserved:  {torch.cuda.max_memory_reserved() / 2**30:.2f} ГиБ")

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

import torch

torch.cuda.memory._record_memory_history(max_entries=100_000)
# ... воспроизвести падение ...
torch.cuda.memory._dump_snapshot("oom_snapshot.pickle")
torch.cuda.memory._record_memory_history(enabled=None)

Полученный файл перетаскивается в интерактивный просмотрщик pytorch.org/memory_viz. Он рисует все живые тензоры во времени и показывает стек вызовов для каждого блока, то есть отвечает на вопрос, какая строка кода выделила память. Просмотрщик работает локально в браузере и никуда снимок не отправляет. Порядок работы описан на странице Understanding CUDA Memory Usage.

Порядок изменений: от дешёвых к дорогим

Собранный вместе порядок действий выглядит так:

  1. Прочитать числа из ошибки и сравнить allocated с reserved.
  2. Проверить nvidia-smi на чужие процессы.
  3. Выставить PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True, если разрыв между reserved и allocated заметный.
  4. Проверить код на удержание графа и отсутствие inference_mode в валидации.
  5. Снизить переменную часть: батч и длину контекста, а в vLLM — --max-model-len и --max-num-seqs.
  6. Включить то, что стоит скорости: gradient_checkpointing, офлоад в Diffusers, --enforce-eager в vLLM.
  7. Включить то, что стоит точности: 8-битный оптимизатор, квантование весов, QLoRA вместо полного дообучения.

Первые четыре пункта ничего не стоят и часто закрывают вопрос. Пункты 5 и 6 обменивают скорость на память. Пункт 7 меняет результат, поэтому идёт последним.

Когда конфигурация уже не поможет

Есть граница, за которой параметры бесполезны. Если веса модели в выбранной точности плюс минимальный KV-кеш на один запрос уже превышают ёмкость карты, менять нужно карту, а не конфигурацию. Ни gradient checkpointing, ни офлоад не помогут: checkpointing экономит активации, а не веса, офлоад же превращает инференс в перекачку весов через PCIe на каждом шаге.

Признак этой границы простой: OOM происходит на этапе загрузки весов, до первого прямого прохода. Дальше решение выбирается по задаче — квантование, вторая карта или карта с большей памятью. Разбор того, какой параметр карты решает в каждой из трёх типовых задач, у нас есть в статье как выбрать GPU под задачу, а пример подбора compute_type под доступную память — в гайде по Whisper на GPU.

Уперлись в потолок памяти карты?

Возьмите карту с большим объёмом VRAM на час и проверьте, снимает ли это проблему, до того как переписывать конфигурацию. Драйверы, CUDA и Docker уже настроены.

Выбрать GPU по объёму памяти