
Кому лень все читать
Я переписал Qwen3-TTS (600M параметров) с Python/PyTorch на чистый Rust.
Результат:
бинарник 12 МБ вместо 2 ГБ venv
холодный старт 1.9 сек вместо 7.7 сек
RTF на CPU до 1.37x
Введение
Привет, Хабр! Сегодня я расскажу историю о том, как модель синтеза речи Qwen3-TTS от Alibaba обрела новую жизнь на Rust.
Почему Rust?
Python-экосистема ML прекрасна для прототипирования, но когда дело доходит до продакшена, начинаются проблемы:
~2 ГБ зависимостей (PyTorch, transformers, и т.д.)
7-10 секунд холодного старта (импорт модулей, JIT-компиляция)
GC-паузы — непредсказуемые задержки
Сложность развёртывания — virtualenv, версии Python, CUDA
Rust решает все эти проблемы: один статически слинкованный бинарник, мгновенный запуск, предсказуемые latency.
Архитектура Qwen3-TTS
Перед тем как писать код, нужно понять, что мы реализуем. Qwen3-TTS — это end-to-end модель синтеза речи, состоящая из нескольких компонентов:
Pipeline | |||
Text Normalizer | Tokenizer | Acoustic Model | Decoder |
Компоненты
Text Normalizer — преобразует "100 рублей" → "сто рублей"
Tokenizer — BPE-токенизация текста + специальные аудио-токены
Acoustic Model — Transformer с 600M параметров, генерирует акустические токены
Audio Codec (HiFi-GAN) — декодирует токены в PCM-аудио
Особенность Qwen3-TTS — использование 16 codebook'ов (RVQ — Residual Vector Quantization), что даёт высокое качество звука при низком битрейте.
Структура проекта
Мы разбили проект на 8 независимых crate'ов:
Crate | Строк кода | Описание |
| ~500 | Базовые типы, трейты, ошибки |
| ~1200 | Нормализация (числа, даты, валюты) |
| ~800 | BPE-токенизация |
| ~4000 | Transformer + KV cache |
| ~3300 | HiFi-GAN декодер |
| ~1700 | Pipeline, streaming |
| ~400 | CLI интерфейс |
| ~600 | gRPC + HTTP сервер |
Общий объём: ~12 500 строк Rust кода.
Путь разработки: хронология коммитов
Анализируя git log, можно проследить эволюцию проекта:
Фаза 1: Базовая инфраструктура
22eea02 init 800e67e feat: добавить начальную структуру workspace 837dee0 feat: добавить правила нормализации текста 7fbfa3d feat: расширить токенизатор аудио-токенами 283dd7f feat: реализовать acoustic model с transformer блоками a8279f2 feat: реализовать нейронный декодер audio-codec
На этом этапе я создал скелет проекта и базовые компоненты.
Основные решения:
Candle как ML-фреймворк (vs tch-rs) — нативный Rust, без биндингов к libtorch
Модульная архитектура — каждый компонент изолирован
Фаза 2: Интеграция с реальными весами
Самая сложная часть — загрузка и использование реальных весов модели.
78eb669 feat(text-tokenizer): добавить поддержку Qwen3-TTS токенов 65cb4ab feat(acoustic-model): добавить загрузку конфигурации из JSON 65d895a feat: добавить поддержку CustomVoice формата
Здесь я столкнулся с первыми серьёзными проблемами...
Проблемы и их решения
Проблема 1: Формат codebook'ов
Симптом: Модель загружается, но генерирует белый шум.
Причина: Qwen3-TTS хранит codebook'и в EMA-формате (Exponential Moving Average):
// НЕПРАВИЛЬНО: брать embedding_sum напрямую let codebook = vb.get("embedding_sum")?; // ПРАВИЛЬНО: нормализовать по cluster_usage let embed_sum = vb.get("embedding_sum")?; let cluster_usage = vb.get("cluster_usage")?; let codebook = embed_sum / (cluster_usage + 1e-7);
Этот баг занял 2 дня отладки с layer-by-layer сравнением тензоров между Python и Rust.
Проблема 2: Multimodal RoPE (M-RoPE)
Симптом: Модель генерирует бессмысленные токены.
Причина: Qwen3-TTS использует модифицированный RoPE с тремя типами позиционных эмбеддингов:
// M-RoPE: разные позиции для текста, временной разметки и 3D-позиций pub struct MultimodalRoPE { text_positions: Tensor, // Позиции текстовых токенов temporal_positions: Tensor, // Временные метки spatial_positions: Tensor, // 3D-позиции (для мультимодальности) } impl MultimodalRoPE { pub fn apply(&self, q: &Tensor, k: &Tensor) -> Result<(Tensor, Tensor)> { // Разделяем hidden dimension на 3 секции let section_size = q.dim(D::Minus1)? / 3; let q_text = q.narrow(D::Minus1, 0, section_size)?; let q_temp = q.narrow(D::Minus1, section_size, section_size)?; let q_spatial = q.narrow(D::Minus1, section_size * 2, section_size)?; // Применяем RoPE к каждой секции с разными позициями let q_text = apply_rope(&q_text, &self.text_positions)?; let q_temp = apply_rope(&q_temp, &self.temporal_positions)?; let q_spatial = apply_rope(&q_spatial, &self.spatial_positions)?; Tensor::cat(&[q_text, q_temp, q_spatial], D::Minus1) } }
Проблема 3: Causal Padding в HiFi-GAN
Симптом: Щелчки и артефакты на границах фреймов.
Причина: Декодер использует causal (причинную) свёртку, но мы применяли обычный same-padding.
// Causal Conv1d: padding только слева pub struct CausalConv1d { conv: Conv1d, padding: usize, } impl CausalConv1d { pub fn forward(&self, x: &Tensor) -> Result<Tensor> { // Pad только слева (causal = не заглядываем в будущее) let padded = x.pad_with_zeros(D::Minus1, self.padding, 0)?; // Для transposed conv — обрезаем справа let out = self.conv.forward(&padded)?; let seq_len = x.dim(D::Minus1)?; out.narrow(D::Minus1, 0, seq_len) } }
После исправления корреляция с Python SDK достигла 0.99+.
Проблема 4: Snake Activation
Симптом: Приглушённый, неестественный звук.
HiFi-GAN использует Snake activation — нестандартную функцию активации:
/// Snake activation: x + sin²(αx) / α pub struct Snake { alpha: Tensor, // Learnable parameter } impl Snake { pub fn forward(&self, x: &Tensor) -> Result<Tensor> { // snake(x) = x + sin²(αx) / α let ax = (x * &self.alpha)?; let sin_ax = ax.sin()?; let sin_sq = (&sin_ax * &sin_ax)?; x + sin_sq.broadcast_div(&self.alpha) } }
Ключевой момент: alpha — это learnable параметр, который нужно загрузить из весов, а не инициализировать константой.
Проблема 5: Early EOS (преждевременное завершение)
Симптом: Модель генерирует только первые несколько слов.
Workaround: Устанавливаем минимальное количество токенов на основе длины текста:
/ Оценка минимальной длины аудио // ~12 токенов/сек при 12Hz, ~0.1 сек на текстовый токен let estimated_duration_s = (text_tokens.len() as f32 * 0.1).max(0.5); let min_tokens = (estimated_duration_s * 12.0) as usize; // Игнорируем EOS до достижения min_tokens if token == eos_id && generated.len() < min_tokens { continue; // Продолжаем генерацию }
Это workaround, а не полное решение. Root cause требует дальнейшего исследования.
Оптимизация
KV-Cache с блочным хранением
Для эффективной автогрессивной генерации критически важен KV-cache:
pub struct BlockKVCache { // Кольцевой буфер для экономии памяти key_cache: Tensor, // [batch, num_layers, max_seq, head_dim] value_cache: Tensor, position: usize, max_seq_len: usize, } impl BlockKVCache { pub fn append(&mut self, key: &Tensor, value: &Tensor) -> Result<()> { // Записываем в кольцевой буфер let pos = self.position % self.max_seq_len; self.key_cache.slice_scatter(key, D::Minus2, pos)?; self.value_cache.slice_scatter(value, D::Minus2, pos)?; self.position += 1; Ok(()) } }
Поддержка GGUF-квантизации
Для снижения потребления памяти добавили поддержку GGUF (Q8/Q4):
// Автоматический выбор формата весов fn find_weights(model_dir: &Path) -> Option<PathBuf> { // Приоритет: GGUF → Q8 → Q4 → safetensors for pattern in &["model.gguf", "model-q8_0.gguf", "model-q4_0.gguf", "model.safetensors"] { let path = model_dir.join(pattern); if path.exists() { return Some(path); } } None }
Бенчмарки
Сравнение с официальным Python SDK на Apple Silicon (M-серия):
Метрика | Python SDK (MPS) | RustTTS (CPU, Q8) |
Cold start | 7.7 сек | 1.9 сек |
Размер | ~2 ГБ | ~12 МБ |
RAM | ~2 ГБ | ~1.5 ГБ |
RTF (short, 7 симв.) | 2.59x | 3.24x |
RTF (medium, 73 симв.) | 2.29x | 1.43x |
RTF (long, 163 симв.) | 1.95x | 1.37x |
RTF (Real-Time Factor) — отношение времени синтеза к длительности аудио. Меньше = лучше.
Вывод: Python быстрее на коротких запросах (GPU ускорение), Rust выигрывает на средних и длинных текстах на CPU.
Примеры использования
CLI
# Синтез текста в WAV cargo run -p tts-cli --release -- synth \ --input "Привет, Хабр!" \ --model-dir models/qwen3-tts-0.6b-customvoice \ -o output.wav # Streaming режим cargo run -p tts-cli --release -- synth \ --input "Длинный текст для стриминга..." \ --streaming \ -o output.wav
gRPC Server
# Запуск сервера cargo run -p tts-server --release # Синтез через gRPC grpcurl -plaintext -d '{"text": "Привет мир", "language": 1}' \ localhost:50051 tts.v1.TtsService/Synthesize
Desktop App (Tauri)
cd crates/tts-app cargo tauri dev
Примеры
Rust
# Тест 1 - квантированная модель 0.6b_Q8 ## v1 Input: 34 chars Language: ru Output: output_rust_0.6b-customvoice-gguf.wav Audio: Duration: 2.86 sec Samples: 68565 Sample rate: 24000 Hz Performance: Synthesis: 3395 ms Total: 6700 ms RTF: 1.189x Status: Slower than real-time ## v2 Input: 34 chars Language: ru Output: output_rust_0.6b-customvoice-gguf_v2.wav Audio: Duration: 2.38 sec Samples: 57045 Sample rate: 24000 Hz Performance: Synthesis: 2955 ms Total: 5289 ms RTF: 1.243x Status: Slower than real-time # Тест 3 - квантированная модель 1.7b_Q4 cargo run -p tts-cli --release -- synth \ --input "Привет, Хабровчане" \ --model-dir models/qwen3-tts-1.7b-customvoice-gguf \ --codec-dir models/qwen3-tts-tokenizer \ -o output_rust_1.7b-customvoice-gguf.wav ## v1 Input: 34 chars Language: ru Output: output_rust_1.7b-customvoice-gguf.wav Audio: Duration: 1.18 sec Samples: 28245 Sample rate: 24000 Hz Performance: Synthesis: 1877 ms Total: 2687 ms RTF: 1.595x Status: Slower than real-time ## v2 Input: 34 chars Language: ru Output: output_rust_1.7b-customvoice-gguf_v2.wav Audio: Duration: 1.34 sec Samples: 32085 Sample rate: 24000 Hz Performance: Synthesis: 2083 ms Total: 4892 ms RTF: 1.558x Status: Slower than real-time # Тест 4 - не квантированная модель 1.7b cargo run -p tts-cli --release -- synth \ --input "Привет, Хабровчане" \ --model-dir models/qwen3-tts-1.7b-customvoice \ --codec-dir models/qwen3-tts-tokenizer \ -o output_rust_1.7b-customvoice.wav Input: 34 chars Language: ru Output: o utput_rust_1.7b-customvoice.wav Audio: Duration: 1.58 sec Samples: 37845 Sample rate: 24000 Hz Performance: Synthesis: 10941 ms Total: 25138 ms RTF: 6.939x Status: Slower than real-time
Уроки и вывод
Что сработало
Модульная архитектура — позволяет тестировать каждый компонент изолированно
Golden tests — сравнение тензоров с Python SDK выявляет баги на ранней стадии
Candle — достаточно зрелый для production ML на Rust
Что было сложно
Недокументированные особенности — формат codebook'ов, M-RoPE, causal padding
Отладка численных расхождений — layer-by-layer сравнение занимает много времени
Metal (Apple GPU) — текущая реализация в Candle уступает CPU
Советы для тех, кто хочет повторить
Начинайте с mock-компонентов — убедитесь, что pipeline работает сквозь
Добавляйте debug logging на каждом этапе
Пишите golden tests ДО реализации логики
Используйте профилирование с самого начала
Исходный код
Проект полностью открыт под MIT/Apache-2.0:
🔗 GitHub: https://github.com/askidmobile/RustTTS
Буду рад звёздочкам ⭐, issues и PR!
Спасибо за прочтение! Если есть вопросы — пишите в комментариях.
Подписывайтесь на канал для получения информации от ИТ архитектора с более чем 20 летним стажем.
