Замер на машине разработки (Ryzen 7 8845HS, WSL2), не на демо-машине — там make latency надо прогнать трижды, разброс между прогонами до 20%. Распознавание — GigaAM v3 RNNT int8: фраза на 2 с за ~285 мс, WER 0% на 99 словах живой речи Golos. CTC не быстрее ни в одном из двух прогонов и ошибается чаще: время съедает общий энкодер, поэтому ступень деградации «RNNT не успевает → CTC» по скорости ничего не даёт. Синтез — Silero v5: в 10 раз быстрее реального времени, а не в 40, как заложено в STACK.md; первая фраза 100–250 мс в зависимости от длины. Частота синтеза на скорость не влияет, без профилирующего компилятора — на 13% быстрее. Сквозной бюджет: 600 мс endpointing + ~285 STT + ~150 TTS = ~1 с без LLM. Цель ≤ 1.5 с держится, только если LLM отдаёт первое предложение быстрее ~465 мс. Вопрос, считается ли филлер «алло?..» первым звуком, записан в LATENCY.md для людей. make models качает GigaAM и Silero VAD с Hugging Face. Silero TTS лежит на российском хосте, недоступном из-под VPN: скрипт не лезет на зеркала (.pt грузится через pickle — это чужой код) и честно говорит, откуда скачать вручную.
222 lines
10 KiB
Python
222 lines
10 KiB
Python
"""make latency: замер бюджета задержки голосового контура по этапам.
|
||
|
||
Запускать **на той машине, которая поедет на занятие** (docs/arch/BACKEND.md).
|
||
Цифры пишутся в docs/LATENCY.md руками вместе с решениями — скрипт только меряет.
|
||
|
||
Что меряется и что нет:
|
||
* STT — GigaAM v3 RNNT и CTC (int8) на живой речи Golos: задержка и ошибка
|
||
распознавания (WER) против эталонной расшифровки;
|
||
* VAD — Silero VAD на одном окне;
|
||
* TTS — Silero v5 на коротких репликах паникующего звонящего;
|
||
* эмбеддинги — multilingual-e5-small на реплике оператора.
|
||
* LLM не меряется: нужен ключ и сеть до провайдера.
|
||
|
||
Golos — чистая речь со смартфонов, читающих фразы. Паникующий звонящий и оператор
|
||
в стрессе дадут ошибку выше: цифра WER отсюда — нижняя граница.
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
import platform
|
||
import statistics
|
||
import sys
|
||
import time
|
||
import wave
|
||
from pathlib import Path
|
||
|
||
import numpy as np
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
MODELS = ROOT / "models"
|
||
sys.path.insert(0, str(ROOT))
|
||
|
||
WARMUP = 2
|
||
|
||
|
||
def machine() -> str:
|
||
cpu = "?"
|
||
try:
|
||
for line in open("/proc/cpuinfo", encoding="utf-8"):
|
||
if line.startswith("model name"):
|
||
cpu = line.split(":", 1)[1].strip()
|
||
break
|
||
except OSError:
|
||
cpu = platform.processor()
|
||
ram = os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES") / 2**30
|
||
wsl = "WSL2" if "microsoft" in platform.release().lower() else "нативно"
|
||
return f"{cpu}, ядер {os.cpu_count()}, RAM {ram:.0f} ГБ, {platform.system()} {wsl}"
|
||
|
||
|
||
def stats(values_ms: list[float]) -> str:
|
||
ordered = sorted(values_ms)
|
||
p95 = ordered[min(len(ordered) - 1, int(round(0.95 * (len(ordered) - 1))))]
|
||
digits = 2 if p95 < 10 else 0 # VAD укладывается в доли миллисекунды
|
||
return f"медиана {statistics.median(ordered):.{digits}f} мс, p95 {p95:.{digits}f} мс"
|
||
|
||
|
||
def read_wav(path: Path) -> tuple[np.ndarray, int]:
|
||
with wave.open(str(path)) as w:
|
||
rate, channels, width = w.getframerate(), w.getnchannels(), w.getsampwidth()
|
||
raw = w.readframes(w.getnframes())
|
||
assert width == 2, f"{path.name}: ожидался PCM16"
|
||
audio = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768
|
||
if channels > 1:
|
||
audio = audio.reshape(-1, channels).mean(axis=1)
|
||
return audio, rate
|
||
|
||
|
||
def normalize(text: str) -> list[str]:
|
||
text = text.lower().replace("ё", "е")
|
||
return "".join(ch if ch.isalnum() or ch.isspace() else " " for ch in text).split()
|
||
|
||
|
||
def wer(reference: list[str], hypothesis: list[str]) -> tuple[int, int]:
|
||
"""Расстояние Левенштейна по словам. Возвращает (ошибок, слов в эталоне)."""
|
||
prev = list(range(len(hypothesis) + 1))
|
||
for i, ref_word in enumerate(reference, 1):
|
||
cur = [i] + [0] * len(hypothesis)
|
||
for j, hyp_word in enumerate(hypothesis, 1):
|
||
cur[j] = min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ref_word != hyp_word))
|
||
prev = cur
|
||
return prev[-1], len(reference)
|
||
|
||
|
||
def measure_stt() -> None:
|
||
import onnx_asr
|
||
|
||
corpus = MODELS / "samples" / "golos"
|
||
manifest = json.load(open(corpus / "manifest.json", encoding="utf-8-sig"))["items"]
|
||
clips = [(read_wav(corpus / item["file"]), item["text"]) for item in manifest]
|
||
total_audio = sum(len(audio) / rate for (audio, rate), _ in clips)
|
||
print(f"\n## STT — Golos, {len(clips)} фраз, {total_audio:.0f} с речи")
|
||
|
||
for name in ("gigaam-v3-rnnt", "gigaam-v3-ctc"):
|
||
started = time.monotonic()
|
||
model = onnx_asr.load_model(name, MODELS / "gigaam-v3-onnx", quantization="int8")
|
||
load_s = time.monotonic() - started
|
||
|
||
for (audio, rate), _ in clips[:WARMUP]:
|
||
model.recognize(audio, sample_rate=rate)
|
||
|
||
latencies, errors, words, audio_s, compute_s = [], 0, 0, 0.0, 0.0
|
||
worst, durations = [], []
|
||
for (audio, rate), reference in clips:
|
||
started = time.monotonic()
|
||
hypothesis = model.recognize(audio, sample_rate=rate)
|
||
elapsed = time.monotonic() - started
|
||
latencies.append(elapsed * 1000)
|
||
durations.append(len(audio) / rate)
|
||
audio_s += len(audio) / rate
|
||
compute_s += elapsed
|
||
e, n = wer(normalize(reference), normalize(hypothesis))
|
||
errors, words = errors + e, words + n
|
||
if e:
|
||
worst.append(f" «{reference}» → «{hypothesis}»")
|
||
|
||
print(f"- {name} int8: загрузка {load_s:.1f} с; фраза {stats(latencies)}; "
|
||
f"RTF {compute_s / audio_s:.3f}; WER {100 * errors / words:.1f}% ({errors} из {words} слов)")
|
||
# Время растёт с длиной фразы, а у Golos фразы длиннее вопросов оператора:
|
||
# медиана по корпусу завышает задержку. Считаем зависимость от длительности.
|
||
slope, intercept = np.polyfit(durations, latencies, 1)
|
||
span = f"{min(durations):.1f}–{max(durations):.1f} с"
|
||
print(f" зависимость: ≈ {intercept:.0f} мс + {slope:.0f} мс × секунд речи (фразы корпуса {span})")
|
||
print(" " + "; ".join(f"{sec} с → ~{intercept + slope * sec:.0f} мс" for sec in (1.5, 2, 3)))
|
||
for line in worst[:5]:
|
||
print(line)
|
||
|
||
|
||
def measure_vad() -> None:
|
||
import onnxruntime as ort
|
||
|
||
path = MODELS / "silero-vad" / "silero_vad.onnx"
|
||
session = ort.InferenceSession(str(path), providers=["CPUExecutionProvider"])
|
||
inputs = {i.name: i for i in session.get_inputs()}
|
||
window = 512 # окно Silero VAD при 16 кГц — 32 мс
|
||
feed = {"input": np.zeros((1, window), dtype=np.float32), "sr": np.array(16000, dtype=np.int64)}
|
||
if "state" in inputs:
|
||
feed["state"] = np.zeros((2, 1, 128), dtype=np.float32)
|
||
else:
|
||
feed["h"] = np.zeros((2, 1, 64), dtype=np.float32)
|
||
feed["c"] = np.zeros((2, 1, 64), dtype=np.float32)
|
||
for _ in range(50):
|
||
session.run(None, feed)
|
||
runs = []
|
||
for _ in range(500):
|
||
started = time.monotonic()
|
||
session.run(None, feed)
|
||
runs.append((time.monotonic() - started) * 1000)
|
||
print(f"\n## VAD — Silero, окно 32 мс\n- на окно: {stats(runs)} (задержка endpointing задана конфигом: 600 мс)")
|
||
|
||
|
||
def measure_tts() -> None:
|
||
path = MODELS / "silero-tts" / "v5_ru.pt"
|
||
if not path.exists():
|
||
print("\n## TTS\n- не измерено: нет models/silero-tts/v5_ru.pt")
|
||
return
|
||
import torch
|
||
|
||
torch.set_num_threads(4)
|
||
# Профилирующий компилятор TorchScript на новых длинах входа ничего не выигрывает:
|
||
# без него синтез на 13% быстрее на тех же фразах.
|
||
torch._C._jit_set_profiling_executor(False)
|
||
started = time.monotonic()
|
||
importer = torch.package.PackageImporter(str(path))
|
||
model = importer.load_pickle("tts_models", "model")
|
||
model.to(torch.device("cpu"))
|
||
load_s = time.monotonic() - started
|
||
speakers = getattr(model, "speakers", [])
|
||
speaker = "xenia" if "xenia" in speakers else speakers[0]
|
||
|
||
warmup = ["Алло!", "Горит балкон на пятом этаже!", "Скорее приезжайте, пожалуйста, мы задыхаемся!"]
|
||
for text in warmup:
|
||
model.apply_tts(text=text, speaker=speaker, sample_rate=24000)
|
||
|
||
# Реплики паникующего звонящего разной длины: время синтеза растёт с длиной звука,
|
||
# и первая фраза звонящего — самая важная для ощущения «ответил сразу».
|
||
lines = [
|
||
"Алло! Помогите!",
|
||
"Горим!",
|
||
"Дым идёт в подъезд!",
|
||
"Жена с ребёнком в дальней комнате!",
|
||
"Я не знаю, где перекрыть газ!",
|
||
"Быстрее, пожалуйста, дышать нечем!",
|
||
"Муж пытался потушить, но не получилось!",
|
||
"Пятый этаж, подъезд второй!",
|
||
]
|
||
latencies, audio_s, compute_s = [], 0.0, 0.0
|
||
per_line = []
|
||
for text in lines:
|
||
started = time.monotonic()
|
||
audio = model.apply_tts(text=text, speaker=speaker, sample_rate=24000)
|
||
elapsed = time.monotonic() - started
|
||
latencies.append(elapsed * 1000)
|
||
audio_s += len(audio) / 24000
|
||
compute_s += elapsed
|
||
per_line.append(f"«{text}» {len(audio) / 24000:.1f} с звука → {elapsed * 1000:.0f} мс")
|
||
print(f"\n## TTS — Silero v5, голос {speaker}, 24 кГц, 4 потока, без профилирующего компилятора")
|
||
print(f"- загрузка {load_s:.1f} с; реплика {stats(latencies)}; RTF {compute_s / audio_s:.3f} "
|
||
f"(синтез в {audio_s / compute_s:.0f} раз быстрее реального времени)")
|
||
for line in per_line:
|
||
print(f" {line}")
|
||
print(f"- голоса: {', '.join(speakers)}")
|
||
|
||
|
||
def measure_embeddings() -> None:
|
||
from app.dialog.embeddings import E5Embedder
|
||
|
||
embedder = E5Embedder(MODELS / "e5-small")
|
||
embedder.embed(["разогрев"])
|
||
runs = []
|
||
for _ in range(50):
|
||
started = time.monotonic()
|
||
embedder.embed(["На каком этаже пожар?"])
|
||
runs.append((time.monotonic() - started) * 1000)
|
||
print(f"\n## Эмбеддинги — multilingual-e5-small int8\n- реплика: {stats(runs)}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
print(f"# Замер задержки\n\nМашина: {machine()}")
|
||
only = set(sys.argv[1:])
|
||
for name, step in [("stt", measure_stt), ("vad", measure_vad), ("tts", measure_tts), ("emb", measure_embeddings)]:
|
||
if not only or name in only:
|
||
step()
|