
Кому лень все читать
Я переписал 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
Уроки и вывод
Что сработало
-
Модульная архитектура — позволяет тестировать каждый компонент изолированно
-
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](https://github.com/askidmobile/RustTTS)
Буду рад звёздочкам ⭐, issues и PR!
Спасибо за прочтение! Если есть вопросы — пишите в комментариях.
Подписывайтесь на канал для получения информации от ИТ архитектора с более чем 20 летним стажем.
Теги: #rust #tts #machinelearning #qwen #opensource #audio
Автор: askid


