GPU не резиновый: проблема, которая всех достала
У вас есть RTX 4090 или A6000 с 24 ГБ памяти. Или, что более вероятно, RTX 4080 Super с 16 ГБ. Вы скачали Llama 3 8B (5 ГБ), Mistral 7B (4 ГБ) и Qwen 2.5 Coder 7B (еще 4 ГБ). Запускаете одну модель — работает идеально. Пытаетесь запустить вторую параллельно — получаете классическую ошибку CUDA out of memory. Видеокарта кричит о помощи.
Типичное «решение»: запускать модели по очереди, выгружая одну перед загрузкой другой. Это занимает 10-20 секунд на каждое переключение. Для API — вечность. Пользователи не будут ждать. Особенно если вы строите очередь запросов к локальной LLM, которая должна выдерживать нагрузку.
Главное заблуждение: «У меня 16 ГБ VRAM, значит, я могу запустить две модели по 8 ГБ». Нет. Не можете. Память GPU фрагментирована, плюс драйверы резервируют память под системные нужды, плюс сам фреймворк (PyTorch, TensorFlow) съедает накладные расходы. Реальная доступная память — примерно 90% от заявленной.
Что на самом деле происходит в памяти GPU?
Когда модель загружается в GPU, она занимает два типа памяти:
- Веса модели: Основные параметры. Для 7B-модели в FP16 — примерно 14 ГБ. В 4-битном квантовании — 3.5-4 ГБ.
- Память для вычислений (активации): Временные данные, которые создаются во время инференса. Зависит от длины контекста и размера батча. Может быть сравнима с размером весов.
Когда вы пытаетесь загрузить вторую модель, PyTorch выделяет для нее новый регион памяти. Если непрерывного блока нужного размера нет — получаете ошибку. Даже если суммарно свободной памяти хватает.
Архитектура: не грузи все сразу
Секрет не в магии, а в правильной оркестрации. Мы не будем держать все модели в памяти одновременно. Вместо этого создадим менеджера, который:
- Загружает модель в GPU только когда приходит запрос к ней.
- Выгружает модель из памяти, если она не используется дольше заданного таймаута.
- Ограничивает количество одновременно загруженных моделей (например, максимум 2 из 5).
- Использует общий пул памяти для всех моделей.
Звучит просто. Но дьявол в деталях.
1Выбираем инструменты: почему FastAPI, а не Flask?
FastAPI дает асинхронность из коробки. Это критично, потому что пока одна модель генерирует ответ (это может занять 10-30 секунд), сервер должен принимать новые запросы и ставить их в очередь. Flask в таком сценарии блокируется.
Еще важнее — встроенная валидация данных через Pydantic. Когда к вам приходит запрос на генерацию, вы должны проверить параметры (temperature, max_tokens, model_name) до того, как передадите их в модель. Иначе получите падение сервера.
from pydantic import BaseModel, Field
from typing import Optional
class GenerationRequest(BaseModel):
prompt: str
model_name: str = Field(..., description="Название модели из конфига")
temperature: float = Field(0.7, ge=0.0, le=2.0)
max_tokens: int = Field(512, ge=1, le=4096)
stream: bool = FalseFlask заставит вас писать эту валидацию вручную. И вы обязательно где-нибудь ошибетесь.
2Сердце системы: ModelManager
Этот класс будет отслеживать, какая модель загружена, сколько памяти занимает, когда последний раз использовалась. Плюс — выгружать наименее используемые модели при нехватке памяти.
import torch
import asyncio
from typing import Dict, Optional
from dataclasses import dataclass
from datetime import datetime, timedelta
@dataclass
class ModelInstance:
model: any # HuggingFace pipeline или аналогичный объект
loaded_at: datetime
last_used: datetime
memory_usage_mb: int
class ModelManager:
def __init__(self, max_loaded_models: int = 2, idle_timeout_minutes: int = 10):
self.loaded_models: Dict[str, ModelInstance] = {}
self.max_loaded_models = max_loaded_models
self.idle_timeout = timedelta(minutes=idle_timeout_minutes)
self.lock = asyncio.Lock()
async def get_model(self, model_name: str) -> Optional[ModelInstance]:
async with self.lock:
# 1. Если модель уже загружена — обновляем время использования и возвращаем
if model_name in self.loaded_models:
instance = self.loaded_models[model_name]
instance.last_used = datetime.now()
return instance
# 2. Если достигли лимита — выгружаем самую старую неиспользуемую модель
if len(self.loaded_models) >= self.max_loaded_models:
await self._unload_idle_model()
# 3. Загружаем новую модель
model = await self._load_model_to_gpu(model_name)
if not model:
return None
memory_usage = torch.cuda.memory_allocated() / 1024**2
instance = ModelInstance(
model=model,
loaded_at=datetime.now(),
last_used=datetime.now(),
memory_usage_mb=int(memory_usage)
)
self.loaded_models[model_name] = instance
return instance
async def _unload_idle_model(self):
now = datetime.now()
candidates = []
for name, instance in self.loaded_models.items():
if now - instance.last_used > self.idle_timeout:
candidates.append((name, instance.last_used))
if candidates:
# Выгружаем самую старую
candidates.sort(key=lambda x: x[1])
name_to_unload = candidates[0][0]
del self.loaded_models[name_to_unload]
torch.cuda.empty_cache()
# Принудительно вызываем сборщик мусора
import gc
gc.collect()
torch.cuda.empty_cache()Важно: torch.cuda.empty_cache() не освобождает память, занятую загруженными моделями. Он очищает только кеш выделителя памяти CUDA. Чтобы выгрузить модель, нужно удалить все ссылки на объект модели и вызвать сборщик мусора. Только после этого empty_cache() освободит память.
3Интеграция с FastAPI: эндпоинты и middleware
Создаем два основных эндпоинта: /generate для обычной генерации и /generate/stream для потоковой. Плюс служебный /models/status для мониторинга.
from fastapi import FastAPI, HTTPException, BackgroundTasks
from fastapi.responses import StreamingResponse
import json
app = FastAPI(title="Multi-LLM Orchestrator")
model_manager = ModelManager()
# Конфигурация моделей
MODEL_CONFIG = {
"llama3-8b": {
"path": "/models/llama-3-8b-instruct",
"type": "transformers",
"max_context": 8192,
"quantization": "q4_k_m" # Для llama.cpp
},
"mistral-7b": {
"path": "/models/mistral-7b-instruct-v0.3",
"type": "transformers",
"max_context": 32768
}
}
@app.post("/generate")
async def generate(request: GenerationRequest):
# Проверяем, что модель есть в конфиге
if request.model_name not in MODEL_CONFIG:
raise HTTPException(status_code=404, detail=f"Model {request.model_name} not found")
# Получаем экземпляр модели (загружаем если нужно)
instance = await model_manager.get_model(request.model_name)
if not instance:
raise HTTPException(status_code=503, detail="Failed to load model, GPU memory full")
# Выполняем генерацию
try:
result = await generate_text(
instance.model,
request.prompt,
temperature=request.temperature,
max_tokens=request.max_tokens
)
return {"text": result, "model": request.model_name}
except torch.cuda.OutOfMemoryError:
# Экстренная ситуация: память закончилась во время генерации
# Выгружаем все модели и очищаем кеш
model_manager.loaded_models.clear()
torch.cuda.empty_cache()
raise HTTPException(status_code=500, detail="GPU out of memory, models cleared")
@app.get("/models/status")
async def get_status():
status = []
for name, instance in model_manager.loaded_models.items():
status.append({
"name": name,
"loaded_at": instance.loaded_at.isoformat(),
"last_used": instance.last_used.isoformat(),
"memory_mb": instance.memory_usage_mb,
"idle_minutes": (datetime.now() - instance.last_used).total_seconds() / 60
})
# Информация о памяти GPU
gpu_info = {
"total_mb": torch.cuda.get_device_properties(0).total_memory / 1024**2,
"allocated_mb": torch.cuda.memory_allocated() / 1024**2,
"cached_mb": torch.cuda.memory_reserved() / 1024**2
}
return {"loaded_models": status, "gpu": gpu_info}Ошибки, которые сломают вашу систему
| Ошибка | Почему происходит | Как исправить |
|---|---|---|
| CUDA out of memory после выгрузки модели | Ссылки на тензоры остались в Python-объектах (например, в логгере). Сборщик мусора не может очистить память. | Использовать del model и явный gc.collect() перед torch.cuda.empty_cache(). |
| Модель загружается 2 минуты при каждом запросе | Вы установили idle_timeout = 1 минуту. Модель постоянно выгружается и загружается заново. | Увеличить таймаут до 10-30 минут или реализовать предзагрузку популярных моделей. |
| Ошибка "RuntimeError: Cannot re-initialize CUDA in forked subprocess" | Используете Gunicorn/Uvicorn с работниками (workers). CUDA не любит форки после инициализации. | Запускать с одним воркером (--workers 1) или использовать spawn вместо fork. |
| Потоковая генерация работает, но соединение обрывается | Nginx или другой прокси имеет таймаут на стриминг (по умолчанию 30 секунд). | Настроить proxy_read_timeout 300s; в Nginx. |
Оптимизации для 16 ГБ VRAM
Если у вас именно 16 ГБ (RTX 4080 Super, RTX 4060 Ti 16GB), вот конкретные цифры:
- Квантование — ваш лучший друг. Модель 7B в FP16 = 14 ГБ. В INT8 = 7 ГБ. В 4-битном = 3.5-4 ГБ. Используйте GGUF формат для llama.cpp или bitsandbytes для Transformers.
- Одновременно можно держать: Две 7B-модели в 4-битном (8 ГБ) + память для активаций (2-4 ГБ) + запас (1-2 ГБ). Итого 13-15 ГБ.
- Или одна 13B-модель в 4-битном (6.5 ГБ) + одна 7B-модель в 4-битном (3.5 ГБ) = 10 ГБ на веса. Остальное — на активации.
Для больших моделей (например, 70B) на одном GPU придется использовать распределенные вычисления на нескольких видеокартах. Или собирать бюджетную 4-GPU ферму.
Деплой: как не убить сервер
Самый простой способ — Docker + systemd. Но есть нюансы:
FROM pytorch/pytorch:2.2.0-cuda12.1-cudnn8-runtime
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Важно: устанавливаем лимит на использование памяти CUDA
ENV PYTORCH_CUDA_ALLOC_CONF=max_split_size_mb:128
ENV CUDA_VISIBLE_DEVICES=0
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]Почему --workers 1? Потому что несколько воркеров будут пытаться загрузить модели независимо, и каждый займет свою память. CUDA поделит память между процессами, и каждый получит только часть. В результате ни один процесс не сможет загрузить модель полностью.
Лучше использовать один процесс, но с асинхронными вызовами. FastAPI справится.
Что делать, когда придут 1000 пользователей?
Эта архитектура — для умеренной нагрузки (10-50 одновременных запросов). Если вам нужно больше — добавляете балансировщик нагрузки и несколько инстансов сервиса. Каждый инстанс будет обслуживать свой набор моделей.
Но тогда возникает проблема консистентности: если пользователь отправил запрос к модели A на инстанс 1, а следующий запрос попал на инстанс 2, ему придется ждать загрузки модели заново. Решение — sticky sessions или внешний кеш состояний.
Для настоящего масштабирования до 1000 одновременных запросов нужна отдельная статья. Но этот гайд — ваш фундамент.
Итог: что вы получили
- Сервис, который может держать в памяти 2-3 модели на одном 16 ГБ GPU, а в конфиге иметь 5-10 моделей.
- Автоматическую выгрузку неиспользуемых моделей.
- API, совместимый с OpenAI (можно добавить роут
/v1/chat/completions). - Мониторинг через
/models/status. - Защиту от типичных ошибок памяти.
Самое важное — вы теперь понимаете, как работает память GPU при загрузке нескольких моделей. Не как «черный ящик», а как управляемый ресурс. Это знание дороже любого кода.
P.S. Если вы запускаете это на домашнем железе, посмотрите гайд по домашней LLM-инфраструктуре. Там есть про охлаждение, питание и почему ваш сосед снизу стучит по батарее, когда вы запускаете инференс на 4 GPU одновременно.