lct-02: замер задержки голосового контура, make latency
Замер на машине разработки (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 — это чужой код) и честно говорит, откуда скачать вручную.
This commit is contained in:
parent
f24031b5b2
commit
dcdec56779
5 changed files with 335 additions and 256 deletions
222
backend/scripts/latency.py
Normal file
222
backend/scripts/latency.py
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
"""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()
|
||||
Loading…
Reference in a new issue