Устранение утечек памяти и задержек при развертывании Supertonic 3 инди-разработчиком на сервере с 2 vCPU
При запуске инди-SaaS с бюджетом менее 500 000 вон в месяц счета за платный TTS API становятся тяжелым бременем. Сетевые задержки также представляют собой проблему. Если после нажатия кнопки на экране до появления звука проходит 1–2 секунды, пользователь сразу же закрывает вкладку.
Open-source модель Supertonic 3, имеющая 99 миллионов параметров, является привлекательной альтернативой. Запуск модели напрямую на локальном сервере позволяет сделать затраты на API равными нулю.
Однако запуск нескольких строчек кода примеров на Python и реальный деплой в продакшн — это совершенно разные вещи. Здесь описан подход, с помощью которого удалось напрямую решить проблемы с пропускной способностью памяти и асинхронной обработкой, возникающие при запуске этой модели на сервере низкой конфигурации.
1. Почему процесс падает на сервере с 1 ГБ оперативной памяти и настройка сессии ONNX
Размер файла весов ONNX для Supertonic 3 составляет около 305 МБ. При первой загрузке модели в память резидентная память (RSS) держится на уровне около 350 МБ.
Проблема возникает тогда, когда пользователь отправляет запрос и начинается вычисление тензоров аудио с частотой 44.1 кГц. Пиковое потребление памяти моментально превышает 900 МБ. При использовании минимального инстанса с конфигурацией 1 vCPU / 1 ГБ ОЗУ срабатывает OOM Killer в Linux, который сразу же принудительно завершает процесс Python.
Нижний порог для стабильной работы — инстанс с 2 vCPU / 2 ГБ ОЗУ.
| Характеристики сервера |
ОЗУ в режиме простоя |
Пиковое ОЗУ при расчетах |
Средняя загрузка CPU |
Фактор реального времени (RTF) |
Ожидаемая экономия в месяц |
| 1 vCPU / 1 ГБ ОЗУ |
280 МБ |
890 МБ (риск OOM Kill) |
98% |
0.85 (0,85 сек. на генерацию 1 сек. аудио) |
$130 (по сравнению с коммерческим API) |
| 2 vCPU / 2 ГБ ОЗУ |
320 МБ |
920 МБ (безопасная зона) |
48% (при ограничении потоков) |
0.28 (0,28 сек. на генерацию 1 сек. аудио) |
$120 (по сравнению с GPU-инстансом) |
| 4 vCPU / 4 ГБ ОЗУ |
350 МБ |
950 МБ |
25% |
0.15 |
$90 (корректировка избыточного выделения) |
Чтобы предотвратить скачки загрузки CPU до 100% при поступлении множества запросов на сервере с маломощным процессором, необходимо вручную контролировать пул потоков среды выполнения ONNX (ONNX Runtime).
- Установите параметр
intra_op_num_threads равным количеству физических ядер сервера (2).
- Задайте для
execution_mode значение ORT_SEQUENTIAL, а для inter_op_num_threads установите значение 1, чтобы предотвратить ненужное переключение контекста.
- Включите
enable_cpu_mem_arena, чтобы избежать частых повторных выделений памяти в куче (heap), и установите allow_spinning в 0, чтобы предотвратить ожидание процессора в пустом цикле.
`python
import onnxruntime as ort
def get_optimized_session_options(cpu_cores: int = 2) -> ort.SessionOptions:
options = ort.SessionOptions()
options.intra_op_num_threads = cpu_cores
options.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
options.inter_op_num_threads = 1
options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
options.enable_cpu_mem_arena = True
options.add_session_config_entry("session.dynamic_block_base", "4")
options.add_session_config_entry("session.intra_op.allow_spinning", "0")
return options
`
Применив эти параметры и запустив воркеры, вы сможете удерживать среднюю загрузку CPU в среде с 2 vCPU ниже 50%. Без использования дорогих GPU-серверов удается сэкономить на инфраструктуре около 120 долларов в месяц.
2. Конфликты C++ библиотек и обработка исключений при предварительной обработке текста
При загрузке SDK в локальной среде или в контейнере для деплоя часто возникают конфликты динамических библиотек C++.
- Среда Windows: Если появляется ошибка
ImportError: DLL load failed, установите распространяемый пакет Microsoft Visual C++ 2015-2022 и убедитесь, что Python запущен в 64-битной виртуальной среде.
- Среда macOS: Из-за отсутствия OpenMP в компиляторе Clang возникает ошибка
libomp.dylib. Выполните команду brew install libomp в терминале и добавьте путь к библиотеке в переменные окружения (export DYLD_LIBRARY_PATH="$(brew --prefix libomp)/lib:$DYLD_LIBRARY_PATH").
- Среда Docker: При использовании образа
python:3.10-slim заранее установите пакеты build-essential и libgomp1 через apt.
После настройки окружения необходимо добавить инструмент очистки входного текста. Если в текст попадают английские аббревиатуры, цифры и символы, модель начинает искажать произношение или выдавать странные механические звуки.
`python
import re
from typing import Dict
class SupertonicTextNormalizer:
def init(self):
self.lexicon_map: Dict[str, str] = {
"FastAPI": "패스트 에이피아이",
"SaaS": "새스",
"TTS": "티티에스",
"ONNX": "온닉스",
"Python": "파이썬",
"SDK": "เอสดี케이",
"API": "에이피아이",
}
self.currency_pattern = re.compile(r'(\d+)\s원')
self.date_pattern = re.compile(r'(\d{4})년\s(\d{1,2})월\s*(\d{1,2})일')
self.time_pattern = re.compile(r'(\d{1,2}):(\d{2})')
self.special_char_pattern = re.compile(r'[^\w\s.,!?~<>]')
def normalize(self, text: str) -> str:
if not text or not text.strip():
raise ValueError("입력 텍스트가 비어 있습니다.")
for word, pronunciation in self.lexicon_map.items():
text = re.sub(rf'\b{re.escape(word)}\b', pronunciation, text, flags=re.IGNORECASE)
text = self.date_pattern.sub(r'\1년 \2월 \3일', text)
text = self.time_pattern.sub(r'\1시 \2분', text)
tags = re.findall(r'<[^>]+>', text)
text_placeholder = re.sub(r'<[^>]+>', ' ___TAG___ ', text)
text_cleaned = self.special_char_pattern.sub('', text_placeholder)
for tag in tags:
text_cleaned = text_cleaned.replace('___TAG___', tag, 1)
return re.sub(r'\s+', ' ', text_cleaned).strip()
`
Чтобы предотвратить ситуации, когда модуль вывода C++ зависает в бесконечном ожидании при определенных текстовых паттернах, добавьте защитный код с таймаутом с помощью asyncio.wait_for, возвращающий заранее подготовленное голосовое сообщение об ошибке в случае сбоя.
`python
import asyncio
import logging
logger = logging.getLogger("TTSPipeline")
async def synthesize_with_fallback(tts_engine, text: str, voice_style, timeout_sec: float = 3.0) -> bytes:
try:
normalizer = SupertonicTextNormalizer()
cleaned_text = normalizer.normalize(text)
loop = asyncio.get_running_loop()
wav_data = await asyncio.wait_for(
loop.run_in_executor(
None,
lambda: tts_engine.synthesize(text=cleaned_text, lang="ko", voice_style=voice_style)
),
timeout=timeout_sec
)
return wav_data
except Exception as err:
logger.error(f"TTS 추론 실패 또는 타임아웃: {err}")
with open("static/audio/fallback_system_error.wav", "rb") as f:
return f.read()
`
Прохождение через этот конвейер предварительной обработки значительно сокращает количество ошибок синтеза речи из-за неправильного произношения. Каждую неделю можно экономить по пять-шесть часов, которые раньше уходили на ручную проверку и исправление проблем с произношением на этапе контроля качества (QA).
3. Пул процессов, не блокирующий цикл событий FastAPI, и ин-мемори стриминг
Если запускать синхронную функцию инференса Supertonic 3 напрямую внутри асинхронного роутера FastAPI, возникнут проблемы. Весь цикл единого событийного цикла зависнет до завершения вычислений C++, из-за чего в режим ожидания перейдут даже легкие API-запросы от других пользователей.
Вычислительно сложные задачи инференса необходимо выносить в отдельный ProcessPoolExecutor.
`python
from fastapi import FastAPI, HTTPException
from fastapi.responses import StreamingResponse
from concurrent.futures import ProcessPoolExecutor
import asyncio
import io
import os
app = FastAPI()
process_pool = ProcessPoolExecutor(max_workers=min(4, os.cpu_count() or 1))
def sync_tts_inference(text: str, voice_style_name: str):
from supertonic import TTS
tts = TTS(auto_download=False)
style = tts.get_voice_style(voice_style_name)
wav, _ = tts.synthesize(text=text, lang="ko", voice_style=style)
return wav.tobytes()
@app.post("/api/v1/tts/realtime")
async def generate_speech_realtime(text: str, voice: str = "M1"):
loop = asyncio.get_running_loop()
try:
audio_bytes = await loop.run_in_executor(process_pool, sync_tts_inference, text, voice)
return StreamingResponse(io.BytesIO(audio_bytes), media_type="audio/wav")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
`
Архитектура, при которой сгенерированное аудио записывается файлом на диск, а затем считывается обратно для возврата клиенту, истощает дисковый ввод-вывод (I/O) сервера с низкой производительностью. Гораздо быстрее выполнять стриминг напрямую в памяти с помощью io.BytesIO, минуя сохранение файлов.
Если из-за наплыва трафика очередь увеличивается или требуется обрабатывать длинные предложения, запросы следует разделять с помощью очереди Redis и воркеров Celery.
- Когда пользователь отправляет текст, сервер мгновенно выдает
task_id и завершает ответ с кодом HTTP 202.
- Воркер Celery запускает модель в фоновом процессе для генерации речи.
- По завершении генерации бинарные данные передаются клиенту через WebSocket по протоколу Redis Pub/Sub.
Если архитектура вынужденно оставляет временный кэш файлов на диске, аварийных ситуаций из-за переполнения диска (Disk Full) можно избежать с помощью фоновой задачи очистки.
`python
import os
import time
import glob
AUDIO_CACHE_DIR = "/tmp/supertonic_audio_cache"
MAX_FILE_AGE_SECONDS = 600
def cleanup_ephemeral_audio_files():
now = time.time()
if not os.path.exists(AUDIO_CACHE_DIR):
return
for filepath in glob.glob(os.path.join(AUDIO_CACHE_DIR, "*.wav")):
try:
if now - os.path.getmtime(filepath) > MAX_FILE_AGE_SECONDS:
os.remove(filepath)
except Exception:
pass
`
Обеспечив изоляцию процессов и стриминг в памяти, можно удерживать показатель задержки p95 в пределах 200 миллисекунд даже при обработке одновременных запросов на инстансе с 2 vCPU. Это позволяет стабильно внедрить независимый речевой сервис on-device без опасений получить огромный счет за внешний API.