DEV Community

Cover image for Как я впихивал три модели в 6 ГБ видеопамяти
Alexey
Alexey

Posted on

Как я впихивал три модели в 6 ГБ видеопамяти

Пару дней назад мы с другом участвовали в хакатоне. Кейс — веб-сервис для видеолекций: загружаешь запись или ссылку на YouTube, получаешь расшифровку, субтитры, конспект с оглавлением и поиск по смыслу. Чтобы студент написал «где он доказывал теорему Пифагора» и попал на нужную секунду видео.

На всё было три дня. Заняли пятое место.

Проект называется Lectio, код на GitHub. Но пост не столько про хакатон. Он про одну проблему, которая съела у меня неожиданно много времени: заставить всё это работать на моём компьютере.

Железо

  • Ryzen 5 5600
  • 32 ГБ DDR4
  • GTX 1660 Super на 6 ГБ

1660 Super вышла в 2019 году. Это Turing без тензорных ядер. Для игр нормально. Для нейросетей — скажем так, скромно.

И всё должно было работать офлайн. Никаких облачных API, всё локально.

Три модели, одна маленькая карта

Обработке лекции нужны три модели:

  1. Whisper large-v3-turbo (через faster-whisper) превращает речь в текст с таймкодом каждого слова.
  2. BGE-M3 превращает куски расшифровки в векторы для поиска по смыслу.
  3. Qwen3 4B (через Ollama) пишет конспект, оглавление и тест.

По отдельности каждая влезает в 6 ГБ. Все три вместе — нет.

А 6 ГБ — это на самом деле не 6 ГБ. Рабочий стол забирает 1–1,5 ГБ ещё до того, как я что-то запустил. Дискорд забирает ещё полгига. Это я узнал, когда Ollama вдруг начала гонять 8–17% модели на процессоре, а причиной оказался Дискорд в трее.

Первый заход

Самый простой вариант — загрузить все модели один раз, держать в памяти и обрабатывать лекции. В 6 ГБ это не влезает, и первым по памяти упал Whisper: Ollama после вопроса в чате ещё держала модель, и места не осталось.

Дальше было интереснее самого падения. Модели очень по-разному ведут себя, когда памяти не хватает.

Ollama подстраивается. Если карта занята, она тихо выносит часть слоёв на процессор и продолжает работать. Медленнее, но работает.

Whisper и эмбеддер не подстраиваются. Они пытаются выделить память, не могут и роняют всю задачу.

Получалось, что вежливой модели доставалось всё, а строгие падали. Ровно наоборот. Значит, память надо отбирать у Ollama до того, как грузятся мои модели, а не наоборот.

По этапам

Теперь воркер обрабатывает одну лекцию за раз, и на каждом этапе карта целиком принадлежит одной модели:

  1. Попросить Ollama выгрузить модель.
  2. Загрузить Whisper, расшифровать, выгрузить Whisper.
  3. Ещё раз попросить Ollama выгрузиться — вдруг чат успел её загрузить.
  4. Загрузить BGE-M3, посчитать векторы, выгрузить BGE-M3.
  5. Теперь вся карта у Ollama, она пишет конспект.

Всё это висит на одной настройке, RELEASE_MODELS_BETWEEN_STAGES=true. На карте побольше её выключаешь, и ничего не выгружается.

Ollama не отпускает

По умолчанию Ollama держит модель в памяти пять минут после последнего запроса. То есть если кто-то спросил что-то в чате по лекции, а через минуту в обработку пришла новая лекция, Whisper стартовал при занятой карте.

У Ollama для этого есть ручка: запрос с keep_alive: 0, и модель выгружается сразу. В OpenAI-совместимом API её нет, поэтому зову родной:

async def unload(self) -> None:
    native_url = self._base_url.removesuffix("/v1") + "/api/generate"

    try:
        async with httpx.AsyncClient(timeout=10.0) as client:
            await client.post(
                native_url,
                json={"model": self._model, "keep_alive": 0},
            )
    except httpx.HTTPError:
        # Other OpenAI-compatible servers don't have this endpoint.
        return
Enter fullscreen mode Exit fullscreen mode

Если LLM не Ollama, запрос просто тихо не проходит, и обработка идёт дальше.

Выгрузить модель сложнее, чем кажется

Модели создаются один раз и кешируются:

@cache
def get_transcriber() -> Transcriber:
    return FasterWhisperTranscriber()


def release_transcriber() -> None:
    get_transcriber.cache_clear()
    gc.collect()
    torch.cuda.empty_cache()
Enter fullscreen mode Exit fullscreen mode

Сбросили кеш, запустили сборщик мусора, попросили PyTorch отдать память. Вроде всё.

Не освобождалось ничего.

Проблема была в коде, который модель использовал:

model = get_transcriber()
try:
    transcript = model.transcribe(audio_path)
finally:
    release_transcriber()
Enter fullscreen mode Exit fullscreen mode

В момент вызова release_transcriber() локальная переменная model всё ещё указывает на модель. Кеш сброшен, но объект жив, сборщик мусора его не трогает, и память остаётся занятой.

Исправление выглядит странно, но работает. Не держать модель в переменной вообще:

# No local reference to the model: release_* can only free memory
# once nothing else points at it.
try:
    transcript = await asyncio.to_thread(
        lambda: get_transcriber().transcribe(audio_path, progress),
    )
finally:
    release_transcriber()
Enter fullscreen mode Exit fullscreen mode

Комментарий в коде нужен, чтобы я потом это «не почистил».

fp16, но правильно

BGE-M3 в fp32 для этой карты жирновата, поэтому она работает в половинной точности. Первая версия была очевидной:

model = SentenceTransformer("BAAI/bge-m3", device="cuda").half()
Enter fullscreen mode Exit fullscreen mode

Памяти освободилось меньше, чем ожидал. Модель сначала грузилась в fp32 и только потом конвертировалась, а fp32-копия оставалась висеть в кеше CUDA-аллокатора PyTorch.

Грузить надо сразу в fp16:

self._model = SentenceTransformer(
    settings.embedding_model,
    device=device,
    model_kwargs={"dtype": torch.float16} if device != "cpu" else None,
)
Enter fullscreen mode Exit fullscreen mode

Размен, которого я не ожидал

API нужна своя копия BGE-M3, чтобы превращать поисковые запросы в векторы. Ещё одна модель на карте.

Я перенёс её на процессор, и цифры получились такие:

  • поиск стал медленнее примерно на 40 мс на запрос, этого никто не заметит;
  • Qwen3 теперь целиком помещается на видеокарту и выдаёт 68 токенов в секунду вместо 39.

Поиск на 40 мс медленнее за почти вдвое более быструю LLM. Одна строчка в .env:

SEARCH_DEVICE=cpu
Enter fullscreen mode Exit fullscreen mode

Мелочи

Ещё несколько настроек дали заметную разницу:

WHISPER_COMPUTE_TYPE=int8_float16
WHISPER_BATCH_SIZE=4
LLM_MODEL=qwen3:4b-instruct
Enter fullscreen mode Exit fullscreen mode

Веса Whisper в int8 с вычислениями в fp16, маленький батч и модель на 4B вместо 8B. Для Ollama ещё включил OLLAMA_FLASH_ATTENTION=1 и OLLAMA_KV_CACHE_TYPE=q8_0 — кеш контекста становится вдвое меньше.

И одна штука, в которой я так и не успел разобраться. faster-whisper работает на CTranslate2, а не на PyTorch. Но CTranslate2 находил cuBLAS и cuDNN, только если перед ним был импортирован torch. Поэтому транскрайбер начинается так:

import torch  # noqa: F401
from faster_whisper import BatchedInferencePipeline, WhisperModel
Enter fullscreen mode Exit fullscreen mode

Импорт нигде не используется. Уберёшь его — Whisper перестаёт видеть CUDA.

Цифры

Вечером перед защитой я прогнал через конвейер лекцию на 1 час 25 минут. От загрузки до «готово» ушло 14 минут 16 секунд:

Этап Время
Извлечение аудио (ffmpeg) 7 с
Расшифровка (Whisper) 5 мин 2 с
Индексация (BGE-M3) 39 с
Конспект (Qwen3 4B) 8 мин 27 с

Сам Whisper расшифровал 85 минут звука за 4 минуты 56 секунд. Это примерно в 17 раз быстрее реального времени, на карте без тензорных ядер.

Поиск работает уже через шесть минут. Всё, что дальше, — это 4B-модель пишет конспект. Это 60% всего времени, и именно это сильнее всего ускорит карта побольше или модель посильнее.

Что я из этого вынес

Я думал, сложными будут сами модели. Нет. Модели хорошие, библиотеки хорошие, и каждая нормально работает, пока она одна на карте.

Сложно было делить. Какая модель вежливо отдаёт память, а какая падает. Кто держит ссылку, про которую ты забыл. Какая библиотека что кеширует и сколько держит.

В документации к моделям этого нет, потому что каждая описана так, будто она единственная на твоей видеокарте.

В 6 ГБ так не бывает никогда.

Top comments (1)

Collapse
 
hitler_homeless_0255061af profile image
Hitler Homeless •

Невероятная работа! нация может тобой гордиться