
Кому лень все читать
Я переписал 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 летним стажем.
Комментарии (14)

dyadyaSerezha
28.01.2026 18:16Пробежал статью, но не увидел результат, который можно сравнить ухом - звуковые файлы с результатами Питона и Раста. Нельзя ли добавить?

askid Автор
28.01.2026 18:16Добавил

dyadyaSerezha
28.01.2026 18:161) не, это ни о чем вообще. Нужен довольно большой кусок из нескольких параграфов, как в какой-то статье тут был, например, из "волшебника изумрудного города". А так совершенно непонятно, какие паузы, ударения, интонации и прочее.
2) в статье две ссылки на вид совершенно одинаковые. Нужны комментарии.
3) нет примера на Питоне.

MaximKiselev
28.01.2026 18:16Рекомендовал бы писать описание проекта на англ или хотя бы в двух версиях на гитхабе плюс добавить сравнение по скоростям с другими tts. В принципе этого будет достаточно. А так вы молодец что сказать ещё :)

ExternalWayfarer
28.01.2026 18:16Нейронка молодец.

askid Автор
28.01.2026 18:16Думаю вам стоит попробовать самому, с нейронкой, собрать нечто подобное. Результаты вас сильно удивят.

askid Автор
28.01.2026 18:16Спасибо за совет, обязательно сделаю перевод как дойдут руки. А вот сравнение с другими TTS это точно не ко мне, пусть авторы моделей этим занимают. Полагаю не нужно отбирать у них хлеб, да и изощренных способов показать что их TTS лучше они точно знают побольше моего. Одно могу сказать, на текущий момент из OpenSource TTS Qwen3-TTS прям очень хороша.
arakabar
Какой моделью и инструментом пользовались для разработки, если не секрет? Сколько заняло по времени?
askid Автор
Для анализа и сравнения тензоров - Opus 4.5, работал часа 4 без остановки.