MAATRIX / Блог / Whisper медленно распознаёт речь: причины и решение

Whisper медленно распознаёт речь: причины и решение

Whisper медленно распознаёт речь: причины и решение

MAATRIX

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

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

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

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

Сначала измерение: RTF вместо ощущения «долго»

Единственная осмысленная величина здесь — RTF (real-time factor): время обработки, делённое на длительность аудио. RTF 0,25 — «в четыре раза быстрее реального времени», RTF 2,0 — «два часа счёта на час записи». Пока этого числа нет на постоянном эталонном файле, правки конфигурации — гадание.

Длительность берём не на глаз:

ffprobe -v error -show_entries format=duration -of default=nw=1:nk=1 call.m4a

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

import sys, time
from faster_whisper import WhisperModel

t0 = time.perf_counter()
model = WhisperModel("large-v3-turbo", device="cpu", compute_type="int8", cpu_threads=4)
t1 = time.perf_counter()

segments, info = model.transcribe(sys.argv[1], language="ru", beam_size=5)
segs = list(segments)                      # работа происходит именно здесь
t2 = time.perf_counter()

print(f"загрузка модели : {t1 - t0:6.1f} c")
print(f"расшифровка     : {t2 - t1:6.1f} c, сегментов: {len(segs)}")
print(f"длительность    : {info.duration:6.1f} c (после VAD {info.duration_after_vad:.1f})")
print(f"RTF             : {(t2 - t1) / info.duration:6.2f}")

Ловушка, из-за которой половина замеров в интернете бессмысленна: segments — генератор, и обёрнутый в time вызов transcribe() вернётся за доли секунды — модель ещё ничего не считала. Считает она на итерации, отсюда list(segments) в замере.

Что смотреть в выводе:

  • Загрузка занимает минуты при каждом старте. Веса скачиваются заново или читаются с медленного диска. При первом запуске это нормально — репозиторий large-v3 тянет около трёх гигабайт; при каждом — нет.
  • RTF стабилен от файла к файлу. Узкое место — железо и режим вычислений.
  • RTF скачет в разы на одинаковых по длине записях. Откаты по температуре или зацикливание декодера.
  • duration_after_vad заметно меньше duration. Хорошо: до модели дошло меньше аудио. Если числа совпадают, VAD выключен и вы платите временем за тишину.

Запускайте замер дважды: первый прогон ещё тянет веса и греет кэш.

Реализация и режим вычислений: где теряется кратность

Самая частая причина «стало медленно» — не настройка, а то, что именно установлено:

/opt/whisper/venv/bin/pip list | grep -iE 'whisper|ctranslate2|torch'

Есть openai-whisper и torch — это референсная PyTorch-реализация: на процессоре она считает в float32 и предупреждает об этом при каждом запуске:

UserWarning: FP16 is not supported on CPU; using FP32 instead
  warnings.warn("FP16 is not supported on CPU; using FP32 instead")

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

Вторая половина проблемы — compute_type. Значение по умолчанию auto выбирает лучшее из поддерживаемого, и на процессоре это не всегда int8. Спросите библиотеку:

python3 -c "import ctranslate2; print(ctranslate2.get_supported_compute_types('cpu'))"

Типичный ответ на современном x86-сервере:

{'int8', 'int8_float32', 'float32', 'int16'}

Есть int8 в списке — задавайте его явно, compute_type="int8": разница с float32 почти двукратная и по времени, и по памяти. Скопированный из примеров для видеокарты float16 на процессоре не заработает вовсе:

ValueError: Requested float16 compute type, but the target device or backend
do not support efficient float16 computation.

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

Третья ловушка — torch со сборкой CUDA «на всякий случай»: без видеокарты он не ускоряет ничего, а попытка указать устройство даёт RuntimeError: Found no NVIDIA driver on your system. Проверка: python3 -c "import torch; print(torch.cuda.is_available())".

Развернуть за пару минут

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

Развернуть Whisper

Параметры декодирования: beam_size, откаты по температуре и петли

Здесь причина самого странного симптома: одинаковые по длине файлы, а время отличается в пять-шесть раз.

Откаты по температуре. Whisper режет аудио на окна по 30 секунд и после каждого проверяет результат по двум порогам: compression_ratio_threshold=2.4 и log_prob_threshold=-1.0. Не прошёл — окно декодируется заново со следующей температурой из набора (0.0, 0.2, 0.4, 0.6, 0.8, 1.0), то есть одно неудачное окно считается до шести раз. На чистой речи это редкость, а на музыке, уличном шуме, длинной тишине или плохом телефонном канале — почти на каждом окне. Отсюда и разброс.

Лечится одной строкой, но с честной оговоркой:

segments, info = model.transcribe(
    "call.wav",
    language="ru",
    beam_size=1,
    temperature=0,                       # один проход вместо шести
    condition_on_previous_text=False,
    vad_filter=True,
)

temperature=0 отключает откаты и делает время предсказуемым, но именно откаты вытаскивают модель из плохих участков — на сложном аудио вы получите быстрее и хуже. Компромисс: оставить два значения, temperature=(0.0, 0.2).

beam_size. По умолчанию 5 — декодер ведёт пять гипотез параллельно. Значение 1 — жадный поиск, на большинстве записей заметно быстрее при почти неотличимом тексте. Снижаете что-то ради скорости — начинайте отсюда, а не с размера модели: качество страдает меньше.

Зацикливание. По умолчанию condition_on_previous_text=True — модель подаёт себе на вход собственный предыдущий вывод. На однообразном аудио это сваливается в петлю, и декодер выдаёт максимум контекста (448 токенов) на каждое окно вместо десятка. Симптом характерный: одна фраза повторяется десятки раз, время взлетает.

word_timestamps=True запускает дополнительное выравнивание по матрицам внимания — нужны субтитры с точностью до слова, платите временем.

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

МодельПараметровСкорость (README)VRAM (документация)
tiny39M~10x~1 ГБ
base74M~7x~1 ГБ
small244M~4x~2 ГБ
medium769M~2x~5 ГБ
large-v31550M1x~10 ГБ
large-v3-turbo809M~8x~6 ГБ

Вывод: с large-v3 переход на large-v3-turbo — почти бесплатный выигрыш. Turbo — тот же large-v3 с урезанным декодером (четыре слоя вместо тридцати двух); качество близкое, проседает в основном на переводе речи на английский.

Ядра и потоки: почему больше vCPU не всегда быстрее

Самая обидная ошибка — четыре потока на двух ядрах. По умолчанию cpu_threads=0, и CTranslate2 берёт своё значение — четыре потока, независимо от того, сколько ядер у виртуалки. На 2 vCPU это гарантированная толкотня: потоки вытесняют друг друга, время растёт вместо того, чтобы падать.

nproc                                   # сколько ядер реально доступно
lscpu | grep -oE 'avx512[a-z]*|avx2|avx|f16c' | sort -u

Ставьте cpu_threads в коде явно, равным числу ядер, а не через OMP_NUM_THREADS — под systemd переменная может не долететь до процесса; проверка: sudo tr '\0' '\n' < /proc/$(systemctl show -p MainPID --value whisper)/environ | grep -i omp.

Набор инструкций. Вторая команда выше — не украшение: быстрые int8-ядра CTranslate2 на x86 опираются на AVX2, и на старом Xeon без него движок откатывается на общие реализации — int8 перестаёт быть быстрее float32. Видите в выводе только avx — упёрлись в поколение процессора, а не в настройки.

Steal time — тихий убийца на дешёвых виртуалках. Гипервизор не даёт машине ядро, когда его занял сосед:

vmstat 1 5

Последний столбец блока cpust: устойчивые значения выше 5–10 % во время расшифровки означают очередь за процессором, и никакой cpu_threads этого не исправит. Тот случай, когда «Whisper медленно работает» — диагноз хостингу, а не софту.

Искусственный лимит. Если сервер вроде мощный, а счёт вялый:

systemctl show whisper -p CPUQuotaPerSecUSec -p AllowedCPUs
docker inspect whisper --format '{{.HostConfig.NanoCpus}} {{.HostConfig.CpusetCpus}}'

CPUQuotaPerSecUSec=infinity — лимита нет; любое конечное значение, оставшееся с отладки, режет скорость пропорционально себе.

Почему масштабирование не линейное. Декодер авторегрессивный — токены генерируются по одному, распараллелить нельзя. Хорошо параллелится только энкодер, разбирающий 30-секундное окно целиком: переход с 2 на 4 ядра даёт заметный выигрыш, с 8 на 16 — куда скромнее. num_workers ускоряет не один файл, а параллельные вызовы transcribe(), и каждый воркер ест свою память.

Память и диск: когда «медленно» — это своп и OOM

Отдельный класс случаев: узкое место не в процессоре.

Своп. Модель в int8, интерпретатор с зависимостями и декодированное аудио — если это не помещается в RAM, ядро выгружает страницы на диск, и производительность падает не на проценты, а на порядок.

free -h
vmstat 1 10        # столбцы si/so — страниц влетает/вылетает в своп

Ненулевые si/so во время расшифровки — приговор: добавляйте память или уменьшайте модель. Крутить параметры декодирования на такой системе бессмысленно.

OOM-killer. Если процесс не тормозит, а «зависает и начинает сначала», смотрите не в лог приложения, а в ядро:

sudo dmesg -T | grep -iE 'killed process|oom'
journalctl -u whisper -n 100 --no-pager

Характерные строки — Out of memory: Killed process 2417 (python3) total-vm:8402112kB, anon-rss:3801024kB и следом от systemd whisper.service: A process of this unit has been killed by the OOM killer. Сервис перезапускается, задача считается заново, снаружи это выглядит как бесконечная обработка.

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

Диск. large-v3 — около трёх гигабайт, плюс промежуточные файлы и входящее аудио. На переполненном диске всё заканчивается не замедлением, а [Errno 28] No space left on device посреди работы; df -h и du -sh ~/.cache/huggingface держите в мониторинге.

Пакетный режим и память. BatchedInferencePipeline ускоряет длинные файлы кратно, но память растёт вместе с batch_size — на 4 ГБ батчи часто кончаются тем же OOM-killer. Инструмент для сервера от 8 ГБ, а не способ выжать слабую виртуалку.

Пайплайн вокруг модели: аудио, VAD, очередь и таймауты

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

Декодирование аудио. Whisper внутри приводит звук к 16 кГц моно — трёхчасовой стерео-AAC с видеодорожкой перекодируется заново при каждом прогоне. Для повторных запусков конвертируйте один раз:

ffmpeg -i meeting.mp4 -vn -ac 1 -ar 16000 -c:a pcm_s16le meeting.wav

Флаг -vn тут не формальность: без него ffmpeg разбирает и видеопоток.

VAD. vad_filter=True подключает Silero VAD и выкидывает участки без речи — буквально меньше аудио на входе модели. На планёрках и звонках поддержки, где половина хронометража — паузы, это самый большой выигрыш из всех; насколько именно, покажет разница info.duration и info.duration_after_vad. Порог — через vad_parameters=dict(min_silence_duration_ms=500).

Очередь вместо параллелизма. Два одновременных распознавания на четырёх ядрах не ускорят ничего — поделят те же ядра и удвоят память. Правильная схема — один воркер модели и очередь перед ним, клиенту сразу 202 Accepted с идентификатором задачи.

Таймауты, которые выглядят как «зависло». При синхронной обработке вы упрётесь в дефолты, а не в Whisper: Nginx ждёт ответ 60 секунд и отдаёт 504 Gateway Time-out, оставляя в error.log:

upstream timed out (110: Connection timed out) while reading response header from upstream

Gunicorn при синхронных воркерах убивает обработчик через 30 секунд:

[CRITICAL] WORKER TIMEOUT (pid:2417)
[ERROR] Worker (pid:2417) was sent SIGKILL! Perhaps out of memory?

Минимальный набор правок, пока очередь не сделана:

location /transcribe {
    proxy_pass http://127.0.0.1:8000;
    proxy_read_timeout   1800s;
    proxy_send_timeout   1800s;
    proxy_request_buffering off;
    client_max_body_size 512m;
}

И --timeout 1800 для gunicorn. Но это подпорка: сорокаминутный HTTP-запрос всё равно оборвётся на слабом мобильном соединении. Асинхронная схема с идентификатором задачи — единственное надёжное решение.

Какой сервер под Whisper брать в MAATRIX

Приложение whisper есть в каталоге apps.maatrix.io и разворачивается автоматически при заказе сервера — работает на Ubuntu и Debian, руками ставить не нужно. Адрес сервиса и ключи доступа — в личном кабинете, в разделе «Доступ».

Честно: у Whisper на процессоре нет параметра, который заменит ядра. Настройки из статьи дают кратный выигрыш один раз — переход с float32 на int8, с large-v3 на turbo, с четырёх потоков на двух ядрах к нормальной конфигурации. Дальше — упор в железо.

Честный минимум: 4 vCPU, 8 ГБ RAM, 60 ГБ NVMe. large-v3-turbo в int8 работает с запасом по памяти, помещаются кэш моделей, очередь и Nginx, cpu_threads=4 соответствует реальным ядрам. Ограничение прямо: это конфигурация под поток файлов — планёрки, звонки, — а не под реальное время. Вариант 2 vCPU / 4 ГБ тоже рабочий, но именно с него чаще всего приходят с жалобой «медленно»: там нет места ни под батчи, ни под второй процесс.

Комфортный вариант: 8 vCPU, 16 ГБ RAM, 100–160 ГБ NVMe. Есть смысл в BatchedInferencePipeline с batch_size=8, в двух воркерах на разные очереди и в паре моделей рядом — turbo для потока, large-v3 для спорных записей. Кэш Hugging Face на пару моделей — 10–20 ГБ, отсюда и диск.

Видеокарта — когда счёт идёт на часы аудио в день. Документация даёт ориентир по видеопамяти: около 6 ГБ для turbo и 10 ГБ для large-v3 — карта на 8–12 ГБ закрывает задачу целиком. Выигрыш относительно CPU кратный, точную цифру снимайте своим же скриптом из первого раздела.

Локация — Великобритания, Лондон. Две причины. Веса моделей тянутся с huggingface.co, и с лондонской площадки — напрямую, без прокси и зависаний на скачивании трёх гигабайт. А загрузка аудио — половина времени в глазах пользователя: пинг из Европы и европейской части России до Лондона — десятки миллисекунд, плюс GDPR-соседство снимает половину вопросов о том, куда уезжают записи разговоров. Если по 152-ФЗ материалы обязаны оставаться в России — берите RU: после скачивания весов Whisper работает офлайн, локация на скорость счёта не влияет.

Оплата — картой российского банка, по СБП, криптовалютой или токеном MAAT: зарубежная карта для сервера в Лондоне не нужна.

Развернуть за пару минут

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

Развернуть Whisper

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

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

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

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

Почему один файл считается минуту, а другой такой же длины — десять?

Почти всегда откаты по температуре: окна, не прошедшие пороги compression_ratio_threshold=2.4 и log_prob_threshold=-1.0, декодируются заново до шести раз. Прогоните файл с temperature=0 — время выровнялось, причина найдена. Второй кандидат — зацикливание при condition_on_previous_text=True.

Даст ли ускорение переход с 4 ядер на 16?

Меньше, чем хочется. Энкодер параллелится хорошо, а декодер генерирует токены по одному и от числа ядер почти не зависит. Заметный выигрыш даёт переход с 2 на 4–8 ядер; дальше эффективнее вкладываться в batch_size, VAD и размер модели, а при часах аудио в день — сразу в видеокарту.

Стоит ли уходить с large-v3 на small ради скорости?

Сначала попробуйте large-v3-turbo: по README он примерно в восемь раз быстрее large при близком качестве — четыре слоя декодера вместо тридцати двух. Если мало, снижайте beam_size до 1 и только потом берите small: на русском разница между turbo и small уже заметна на слух.

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

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