Gestion des fuites de mémoire et de la latence lors du déploiement de Supertonic 3 sur un serveur à 2 vCPU par un développeur solo
Lorsqu'on gère un SaaS solo avec un budget inférieur à 500 000 wons par mois, la facture d'une API TTS payante représente un poids lourd chaque mois. La latence du réseau pose également problème. Si l'utilisateur clique sur un bouton à l'écran et doit attendre 1 à 2 secondes pour que le son sorte, il ferme immédiatement l'onglet.
Supertonic 3 (Supertonic 3), un modèle open source doté de 99 millions de paramètres, constitue une alternative intéressante. L'exécuter directement sur un serveur local permet de réduire à zéro les coûts d'API.
Cependant, exécuter quelques lignes de code d'exemple en Python et effectuer un déploiement réel en production sont deux choses totalement différentes. Voici la méthode que j'ai appliquée pour résoudre directement les problèmes de goulot d'étranglement de la mémoire et de traitement asynchrone rencontrés dès le lancement de ce modèle sur un serveur à faibles ressources.
1. Pourquoi le processus s'arrête sur un serveur avec 1 Go de RAM et optimisation de la session ONNX
La taille du fichier de poids ONNX de Supertonic 3 est d'environ 305 Mo. Lorsque le modèle est chargé pour la première fois en mémoire, la mémoire résidente (RSS) se maintient autour de 350 Mo.
Le problème survient lorsqu'un utilisateur envoie une requête et que le calcul des tenseurs audio à 44,1 kHz commence. Le pic de mémoire instantané dépasse 900 Mo. Si vous utilisez l'instance la moins chère avec 1 vCPU / 1 Go de RAM, le mécanisme OOM Killer de Linux se déclenche et met immédiatement fin de force au processus Python.
Le seuil minimum pour un fonctionnement stable est une instance à 2 vCPU / 2 Go de RAM.
| Spécifications de l'instance du serveur |
RAM à l'état inactif |
Pic de RAM en calcul |
Utilisation moyenne du CPU |
Facteur de temps réel (RTF) |
Économie de coûts mensuels estimée |
| 1 vCPU / 1 Go de RAM |
280 Mo |
890 Mo (Risque d'arrêt forcé) |
98% |
0,85 (0,85s pour générer 1s de voix) |
130 $ (vs API commerciale) |
| 2 vCPU / 2 Go de RAM |
320 Mo |
920 Mo (Zone stable) |
48% (avec limitation des threads) |
0,28 (0,28s pour générer 1s de voix) |
120 $(vs instance GPU) vert{} |
| vert{} 4 vCPU / 4 Go de RAM vert{} 350 Mo vert{} 950 Mo vert{} 25% vert{} 0,15 vert{} 90$ (Ajustement d'instance surdimensionnée) |
|
|
|
|
|
Pour empêcher l'utilisation du processeur de monter en flèche à 100 % lors de l'arrivée de plusieurs requêtes sur un serveur CPU à faibles ressources, il est nécessaire de contrôler manuellement le pool de threads du runtime ONNX.
- Définissez
intra_op_num_threads en fonction du nombre de cœurs physiques du serveur (2).
- Définissez
execution_mode sur ORT_SEQUENTIAL et attribuez la valeur 1 à inter_op_num_threads pour éviter les changements de contexte inutiles.
- Activez
enable_cpu_mem_arena pour prévenir les réallocations fréquentes de mémoire tas, et réglez allow_spinning sur 0 pour éliminer les attentes en boucle vide du CPU.
`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
`
En appliquant ces options et en lançant les workers, vous pouvez maintenir l'utilisation moyenne du CPU en dessous de 50 % dans un environnement à 2 vCPU. Même sans utiliser de serveur GPU coûteux, vous économisez environ 120 dollars de coûts d'infrastructure par mois.
2. Conflits de bibliothèques C++ et gestion des exceptions de prétraitement du texte
Des conflits de bibliothèques dynamiques C++ se produisent fréquemment lors du chargement du SDK dans un environnement local ou un conteneur de déploiement.
- Environnement Windows : si l'erreur
ImportError: DLL load failed apparaît, installez le package redistribuable Microsoft Visual C++ 2015-2022 et assurez-vous que Python s'exécute dans un environnement virtuel 64 bits.
- Environnement Mac : une erreur
libomp.dylib survient en raison de l'absence d'OpenMP dans le compilateur Clang. Exécutez brew install libomp dans le terminal et ajoutez le chemin de la bibliothèque aux variables d'environnement (export DYLD_LIBRARY_PATH="$(brew --prefix libomp)/lib:$DYLD_LIBRARY_PATH").
- Environnement Docker : lors de l'utilisation de l'image
python:3.10-slim, installez préalablement les paquets build-essential et libgomp1 via apt.
Une fois l'environnement configuré, vous devez ajouter un nettoyeur de texte d'entrée. En effet, si des abréviations anglaises, des chiffres ou des symboles s'entremêlent, le modèle peut marmonner la prononciation ou émettre des sons mécaniques étranges.
`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()
`
Pour empêcher le module d'inférence C++ de se bloquer dans une attente infinie sur certains motifs de texte, configurez un délai d'attente avec asyncio.wait_for et prévoyez un code de défense qui renvoie un message vocal d'erreur préparé en cas d'échec.
`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()
`
Ce pipeline de prétraitement réduit considérablement les erreurs de lecture audio dues à une mauvaise prononciation. Vous pouvez économiser cinq à six heures par semaine de vérification et de correction manuelle des problèmes de prononciation lors de la phase d'assurance qualité (QA).
3. Pool de processus et streaming en mémoire sans bloquer la boucle d'événements FastAPI
Si vous exécutez directement l'inférence synchrone de Supertonic 3 dans un routeur asynchrone FastAPI, un problème survient. L'ensemble de la boucle d'événements unique se fige jusqu'à ce que le calcul C++ soit terminé, bloquant même les requêtes API légères des autres utilisateurs.
Les tâches d'inférence gourmandes en calcul doivent être déléguées à un ProcessPoolExecutor séparé.
`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))
`
Écrire l'audio généré dans un fichier sur le disque pour le relire et le renvoyer détériore les E/S disque d'un serveur à faibles ressources. Il est beaucoup plus rapide de diffuser en streaming directement depuis la mémoire à l'aide de io.BytesIO sans passer par l'enregistrement de fichiers.
Si le trafic s'accumule et allonge la file d'attente, ou si vous devez traiter de longs textes, séparez les requêtes à l'aide d'une file d'attente Redis et de workers Celery.
- Lorsqu'un utilisateur envoie du texte, le serveur attribue immédiatement un
task_id et renvoie une réponse HTTP 202.
- Un worker Celery exécute le modèle dans un processus en arrière-plan pour générer la voix.
- Une fois la génération terminée, les données binaires sont transmises au client via un WebSocket en passant par Redis Pub/Sub.
Si une structure de cache de fichiers temporaires doit obligatoirement être conservée sur le disque, exécutez une tâche de nettoyage en arrière-plan pour prévenir les pannes par saturation du disque (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
`
Grâce à l'isolation des processus et au streaming en mémoire, vous pouvez maintenir la latence p95 autour de 200 millisecondes lors du traitement de requêtes simultanées, même sur une instance à 2 vCPU. Vous pouvez ainsi intégrer de manière stable un service vocal sur appareil indépendant, sans craindre les factures exorbitantes des API externes.