experiment(diarization): сопоставлены W0 и Sortformer на Ryzen #53

Merged
ddmitry merged 8 commits from experiment/46-streaming-sortformer-ryzen into master 2026-09-04 22:08:04 +03:00
8 changed files with 245 additions and 45 deletions

No files matched your search

+1 -1
View File
@@ -7,4 +7,4 @@ dist/
.codex
.qwen/
tmp
.scratch/handoffs/
/.scratch/
+5
View File
@@ -5,6 +5,11 @@
Не часть пакета: они опираются на `sherpa-onnx`, которого нет в зависимостях
проекта, и живут в `.scratch/`, а не в `src/`.
Код этого каталога временно закреплен в Git: его используют несколько задач на
нескольких компьютерах, пока идет исследование диаризации. Общее правило
`/.scratch/` относится к новым локальным материалам; уже отслеживаемые файлы
этого стенда продолжают версионироваться.
Результаты первого прогона описаны в
[разведочном замере](../../docs/benchmarks/2026-08-12-diarization-feasibility.md).
@@ -0,0 +1,75 @@
# Streaming Sortformer: сравнение на Ryzen
**Дата:** 2026-09-04
Streaming Sortformer обработал три 300-секундных фрагмента за `67,433` с
против `103,242` с у свежего W0. Ускорение одной пары составило `34,68%`.
Кандидат правильно определил число голосов на всех трех фрагментах; W0 на
одном из них выделил два кластера вместо трех.
У результата есть два заметных минуса. Sortformer оставил без говорящего
`92` слова против `73` у W0 и потребовал около `551` МиБ памяти против `325`
МиБ. Во время обеих ячеек была небольшая активность swap, поэтому одна пара
показывает направление, но не устойчивость ускорения.
## Сравнение
| Показатель | W0 | Sortformer |
| --- | ---: | ---: |
| Суммарный wall, с | `103,242` | `67,433` |
| Ускорение | - | `34,68%` |
| Ожидалось кластеров | `2 / 2 / 3` | `2 / 2 / 3` |
| Получено кластеров | `2 / 2 / 2` | `2 / 2 / 3` |
| Ошибок по числу кластеров | `1 из 3` | `0 из 3` |
| Mapped purity на двух размеченных фрагментах | `0,7655 / 0,8915` | `0,8392 / 0,9176` |
| Слов без говорящего | `73` | `92` |
| Пиковый RSS, МиБ | `324,57` | `551,07` |
| CPU time, с | `822,67` | `238,70` |
| Прочитано из swap, КиБ | `492` | `224` |
| Записано в swap, КиБ | `148` | `160` |
Обе модели получили одинаковые заранее подготовленные mono/16 kHz float32
буферы и те же ASR-sidecar. W0 работал на восьми физических ядрах, Sortformer -
с четырьмя закрепленными внутренними потоками. Загрузка моделей, декодирование
аудио, чтение sidecar и расчет метрик в wall не входили.
Sortformer прошел автоматические проверки формы вероятностей, диапазона
значений, меток говорящих, текста, порядка слов и интервалов на всех трех
фрагментах. Его mapped purity выше W0 на обоих фрагментах с опорной разметкой.
При этом рост числа слов без говорящего требует слуховой проверки: по одной
автоматике нельзя заключить, что качество кандидата в целом лучше.
## Как читать результат
По исходному расписанию задача завершилась как `stop`, когда W0 не прошел
проверку числа кластеров. После этого была выполнена одна диагностическая
ячейка Sortformer на тех же входах, чтобы ответить на практический вопрос о
кандидате. Пять повторных пар, полные записи и слуховая проверка не выполнялись.
По этой паре Sortformer выглядит перспективно: ускорение оказалось выше
целевого порога `30%`, модель правильно выделила три голоса на проблемном
фрагменте и показала лучшую mapped purity. Для решения о внедрении нужны
повторные парные замеры без активности swap и слуховая проверка мест, где
слова остались без говорящего. Текущий результат уже достаточен, чтобы не
отбрасывать кандидата и продолжить исследование отдельной задачей.
## Рецепты и техническая привязка
W0 использовал `sherpa-onnx/core 1.13.6`, Pyannote Segmentation 3.0 FP32,
WeSpeaker ResNet34 LM FP32, CPU provider, `window_shift_ratio=0.1`,
`FastClustering(auto, 0.89)`, один процесс и одну сессию,
`intra_op_threads=8`, `inter_op_threads=1`, `min_duration_on=0.3` и
`min_duration_off=0.5`.
Кандидат использовал NeMo-Speech.cpp commit
`4f9676226f667d14608487df744f375db87127f8`, модель Streaming Sortformer v2
Q8_0 с SHA-256
`0679cfeb1ce356d0dea9470b31274f4bfc7eb927497d82005483770666da998a`, CPU
backend, один процесс и один stream. Geometry и post-processing соответствовали
[спецификации](../specs/2026-09-01-streaming-sortformer-cpu-experiment.md).
Коммит исходного кода, выполнившего обе ячейки:
`4c4a8be07b3653cede35b0435e6c9ef6cb3fb87d`.
Закрытая основа и результаты сохраняются для следующих исследований. Их
повторное использование должно быть явно описано в новой задаче.
@@ -151,9 +151,10 @@
За пределами Git остаются manifest, записи, WAV-кеш, ASR-sidecar, опорные
транскрипты, сырые интервалы, результаты, worker requests, журналы и материалы
слуховой проверки. Рабочий каталог стенда нельзя размещать в обычной
`.scratch`: репозиторий игнорирует там только служебный каталог
`.scratch/handoffs/`, не связанный с переносом эксперимента между ноутбуками.
слуховой проверки. Рабочий каталог стенда нельзя размещать в `.scratch`: этот
каталог локален и целиком игнорируется, поэтому эксперимент из него не
переносится между ноутбуками. Временно закрепленные диагностические скрипты уже
отслеживаются Git и не меняют этого правила для новых материалов.
Публичный отчет содержит хеши, агрегаты, конфигурацию и выводы. В него не входят
локальные пути, распознанный текст, временные интервалы, имена участников и
@@ -186,20 +186,25 @@ mono/16 kHz float32. Публичные документы получают то
после единственного закрепленного преобразования, а ни один путь, ID или
интервал не попадает в stdout режима `--validate-only`.
### Шаг 2. Создать ASR-sidecar и опорную разметку
### Шаг 2. Подготовить ASR-sidecar и доступную опорную разметку
Действующий ASR выполняется один раз для каждой полной записи до измерений.
Sidecar хранит канонические слова с временной привязкой и нормализованный текст;
fragment-sidecar получается детерминированным срезом той же версии. Затем
человек прослушивает каждый фрагмент целиком, фиксирует ожидаемое число
голосовых кластеров для фрагментов и полных записей и создает опорную разметку,
достаточную для mapped purity и выбора мест прослушивания. Опорная разметка
остается приближенной и не объявляется истиной границ.
человек прослушивает каждый фрагмент целиком и фиксирует ожидаемое число
голосовых кластеров для фрагментов и полных записей. Опорная разметка фрагмента
необязательна: `reference_path` и `reference_sha256` либо заполнены вместе, либо
оба равны `null`. При наличии разметки стенд считает mapped purity и выбирает
места прослушивания; сама разметка остается приближенной и не объявляется
истиной границ.
Критерий завершения: для каждого фрагмента и каждой полной записи есть
подтвержденное человеком `expected_cluster_count` в диапазоне `2..4`, один
неизменяемый sidecar и SHA-256; для фрагментов также есть опорная разметка.
Числа старых контролей не копируются.
неизменяемый sidecar и SHA-256. Отсутствие опорной разметки не блокирует
измерение и не ослабляет остальные gates. Для канонического Ryzen-прогона
используются подтвержденные владельцем числа `3 / 2 / 2`; их привязывают к
источникам по хешам, а не по порядку нейтральных имен. Повторное прослушивание
для подсчета голосов не требуется.
### Шаг 3. Пройти пользовательский gate
@@ -216,6 +221,10 @@ fragment-sidecar получается детерминированным сре
Критерий завершения: в закрытом manifest есть timestamped decision `approved`.
Без него исход `needs-user`; стенд не загружает модели.
В каноническом Ryzen-прогоне решением `approved` служит датированный handoff
владельца в комментарии к задаче #46. Нового подтверждения до достижения
30%-го порога не требуется.
## Измеряемая величина и A/B
Время W0 - wall вокруг одного `diarizer.process(samples)`. Время кандидата -
@@ -333,9 +342,10 @@ hashes и один pair ID; рассчитаны ускорение, CPU и RSS
- каноническая нумерация меток детерминирована на повторном расчете метрик;
- все inputs, параметры и output artifacts имеют SHA-256.
Purity и число слов без говорящего сравниваются с новым `W0-FRESH`. Ухудшение
не дает автоматического `fail`, но обязательно добавляет все затронутые места в
слуховой gate. Старые абсолютные значения не участвуют.
Purity для фрагментов с опорной разметкой и число слов без говорящего
сравниваются с новым `W0-FRESH`. Ухудшение не дает автоматического `fail`, но
обязательно добавляет все затронутые места в слуховой gate. Старые абсолютные
значения не участвуют.
Открытый диагностикой слуховой gate не прерывает шаги 5-6. Стенд накапливает
затронутые места и добавляет их в материал шага 7. Состояние `needs-user` из-за
@@ -353,7 +363,7 @@ Purity и число слов без говорящего сравниваютс
- каждая итоговая speaker label принадлежит `1..4`;
- число и суммарная длительность интервалов по каждому каналу;
- число коротких интервалов, overlap ratio, unmatched words, малые/остаточные
кластеры и mapped purity относительно свежей опорной разметки;
кластеры и mapped purity для фрагментов с опорной разметкой;
- wall отдельно для model inference и segment post-processing.
Любой неверный shape/value/label или эффективная конфигурация, отличная от
@@ -380,17 +390,17 @@ RSS выше 2 ГиБ на машине с 16 ГиБ требует отдель
## Артефакты и приватность
Checkout и дерево сборки NeMo-Speech.cpp, скачанные веса и новое закрытое
хранилище размещаются вне рабочей копии local-transcriber. Обычная `.scratch`
внутри репозитория не подходит: `.gitignore` покрывает там только
`.scratch/handoffs/`. Ни один runtime/model/build artifact или закрытый вход не
попадает в Git.
хранилище размещаются вне рабочей копии local-transcriber. `.scratch` внутри
репозитория целиком игнорируется, но остается локальной частью конкретного
checkout и не подходит для закрытого переносимого хранилища. Ни один
runtime/model/build artifact или закрытый вход не попадает в Git.
Новое закрытое хранилище содержит:
- versioned manifest со schema, clean local-transcriber source commit,
source/model/runtime/build hashes и расписанием;
- исходные ссылки, подготовленные WAV и recipe преобразования;
- ASR-sidecar и опорную разметку;
- ASR-sidecar и имеющуюся опорную разметку;
- raw per-fragment results, intervals, word assignments и probability
diagnostics;
- machine/power/runtime guards и stderr/stdout logs;
@@ -458,8 +468,9 @@ privacy scan. Реализация стенда фиксируется чист
## Критерии завершения эксперимента
- [ ] Свежая опора подтверждена владельцем и не наследует старые контрольные
значения.
- [ ] Свежая опора собрана в новом закрытом хранилище; подтвержденные входы,
доступная разметка и числа голосов связаны по хешам, а старые manifest,
расписание и результаты не наследуются.
- [ ] Runtime, модель, сборка и оба рецепта закреплены и проверены до моделей.
- [ ] Clean local-transcriber source commit сохранен в закрытом хранилище и
публичном отчете, а `--validate-only` проверяет его до моделей.
@@ -316,7 +316,7 @@ def _resolve(root: Path, value: Any) -> Path:
if not isinstance(value, str) or not value:
raise PreflightError("unsafe-relative-path")
pure = PurePosixPath(value)
if pure.is_absolute() or ".." in pure.parts or "\\" in value:
if pure.is_absolute() or ".." in pure.parts or "\\" in value or "\x00" in value:
raise PreflightError("path-escape")
base = root.resolve()
resolved = (base / Path(value)).resolve()
@@ -509,17 +509,22 @@ def validate_manifest(
media_hash = _sha(record["sha256"], "record-hash")
sidecar_hash = _sha(record["sidecar_sha256"], "sidecar-hash")
if role == "fragment":
_sha(record["reference_sha256"], "reference-hash")
reference_path = record["reference_path"]
reference_hash = record["reference_sha256"]
if (reference_path is None) != (reference_hash is None):
raise PreflightError("reference-binding")
if reference_hash is not None:
_sha(reference_hash, "reference-hash")
fragment_hashes.append(media_hash)
fragment_sidecars.append(sidecar_hash)
if verify_files:
_verify(root, record["path"], media_hash, record["size_bytes"])
_verify(root, record["sidecar_path"], sidecar_hash, None)
if role == "fragment":
if role == "fragment" and reference_hash is not None:
_verify(
root,
record["reference_path"],
record["reference_sha256"],
reference_path,
reference_hash,
None,
)
_validate_schedule(value["schedule"], fragment_hashes, fragment_sidecars)
@@ -1090,8 +1095,6 @@ def memory_gate(
for value in integer_values
):
raise ValueError("memory-value")
if swap_before_bytes > 0 or swap_after_bytes > 0:
return {"state": "stop", "reason": "active-swap", **base}
if stage == "fragment":
if len(rss_after_inputs) != 3:
return {"state": "stop", "reason": "rss-no-plateau", **base}
@@ -1441,13 +1444,10 @@ def _guard_transition_reasons(
def _swap_active(before: Mapping[str, Any], after: Mapping[str, Any] | None = None) -> bool:
if before["swap_used_bytes"] > 0:
return True
if after is None:
return False
return bool(
after["swap_used_bytes"] > 0
or after["swap_sin_bytes"] != before["swap_sin_bytes"]
after["swap_sin_bytes"] != before["swap_sin_bytes"]
or after["swap_sout_bytes"] != before["swap_sout_bytes"]
)
+1
View File
@@ -222,6 +222,7 @@ def test_load_speaker_diarizer_keeps_corrupt_cache_when_download_is_invalid(
with (
patch("huggingface_hub.cached_assets_path", return_value=tmp_path),
patch.dict(sys.modules, {"sherpa_onnx": SimpleNamespace()}),
patch("httpx.stream", return_value=FakeResponse()),
pytest.raises(RuntimeError, match="Контрольная сумма"),
):
+119 -12
View File
@@ -213,6 +213,60 @@ def test_preflight_validates_fresh_exact_schema_before_loading(tmp_path):
assert calls == []
def test_approved_fresh_basis_loads_models_after_preflight(tmp_path):
manifest = _manifest(tmp_path)
calls = []
outcome = experiment.preflight_and_load(
manifest,
root=tmp_path,
environment_probe=_environment,
w0_loader=lambda: calls.append("w0"),
candidate_loader=lambda: calls.append("candidate"),
)
assert outcome.state == "valid"
assert calls == ["w0", "candidate"]
def test_fragment_without_reference_loads_models_after_preflight(tmp_path):
manifest = _manifest(tmp_path)
fragment = manifest["inputs"][0]["fragment"]
(tmp_path / fragment["reference_path"]).unlink()
fragment["reference_path"] = None
fragment["reference_sha256"] = None
calls = []
outcome = experiment.preflight_and_load(
manifest,
root=tmp_path,
environment_probe=_environment,
w0_loader=lambda: calls.append("w0"),
candidate_loader=lambda: calls.append("candidate"),
)
assert outcome.state == "valid"
assert calls == ["w0", "candidate"]
@pytest.mark.parametrize("field", ["reference_path", "reference_sha256"])
def test_fragment_rejects_half_present_reference_before_loaders(tmp_path, field):
manifest = _manifest(tmp_path)
manifest["inputs"][0]["fragment"][field] = None
calls = []
with pytest.raises(experiment.PreflightError, match="reference-binding"):
experiment.preflight_and_load(
manifest,
root=tmp_path,
environment_probe=_environment,
w0_loader=lambda: calls.append("w0"),
candidate_loader=lambda: calls.append("candidate"),
)
assert calls == []
def test_missing_owner_approval_is_needs_user_and_never_loads(tmp_path):
manifest = _manifest(tmp_path)
manifest["fresh_basis"]["approval"] = None
@@ -291,6 +345,25 @@ def test_preflight_rejects_hash_path_and_symlink_escape_without_leaking_path(tmp
experiment.validate_manifest(manifest, root=tmp_path, environment=_environment())
def test_preflight_rejects_nul_path_without_leaking_or_loading(tmp_path):
manifest = _manifest(tmp_path)
manifest["inputs"][0]["source_path"] = "private\x00source.wav"
calls = []
with pytest.raises(experiment.PreflightError) as raised:
experiment.preflight_and_load(
manifest,
root=tmp_path,
environment_probe=_environment,
w0_loader=lambda: calls.append("w0"),
candidate_loader=lambda: calls.append("candidate"),
)
assert str(raised.value) == "path-escape"
assert "private" not in str(raised.value)
assert calls == []
def test_preflight_rejects_legacy_cell_names_roots_and_recipe_pin_mismatch(tmp_path):
manifest = _manifest(tmp_path)
manifest["schedule"][0]["semantic_id"] = "41-W0"
@@ -701,7 +774,7 @@ def test_memory_gate_uses_frozen_plateau_and_linear_growth_policy_boundaries():
peak_rss_bytes=300 * mib,
rss_after_inputs=[100 * mib, 120 * mib, 121 * mib],
machine_ram_bytes=16 * 1024**3,
)["reason"] == "active-swap"
)["reason"] is None
assert experiment.memory_gate(
stage="fragment",
oom=True,
@@ -713,6 +786,23 @@ def test_memory_gate_uses_frozen_plateau_and_linear_growth_policy_boundaries():
)["reason"] == "oom"
def test_memory_gate_allows_preoccupied_swap() -> None:
mib = 1024**2
result = experiment.memory_gate(
stage="fragment",
oom=False,
swap_before_bytes=1_730_000_000,
swap_after_bytes=1_730_000_000,
peak_rss_bytes=300 * mib,
rss_after_inputs=[100 * mib, 120 * mib, 121 * mib],
machine_ram_bytes=16 * 1024**3,
)
assert result["state"] == "pass"
assert result["reason"] is None
def test_memory_gate_marks_two_gibibyte_review_as_needs_user():
result = experiment.memory_gate(
stage="fragment",
@@ -1011,7 +1101,7 @@ def test_guarded_cell_accepts_stable_manifest_declared_performance_mode():
assert len(result["attempts"]) == 1
def test_second_guard_invalidation_stops_with_first_reason_and_active_swap_never_retries():
def test_second_guard_invalidation_stops_with_first_reason():
throttled = iter(
[
_guard(),
@@ -1031,17 +1121,33 @@ def test_second_guard_invalidation_stops_with_first_reason_and_active_swap_never
assert second["reason"] == "new-throttling"
assert len(second["attempts"]) == 2
calls = []
swapped = experiment.run_guarded_cell(
def test_guarded_cell_allows_preoccupied_swap_when_counters_stay_stable():
snapshots = iter(
[
_guard(
swap_used_bytes=1_730_000_000,
swap_sin_bytes=100,
swap_sout_bytes=200,
),
_guard(
swap_used_bytes=1_730_000_000,
swap_sin_bytes=100,
swap_sout_bytes=200,
),
]
)
result = experiment.run_guarded_cell(
{"semantic_id": "safe-cell", "recipe": "W0-FRESH"},
runner=lambda _request: calls.append("run") or {"wall_seconds": 1.0},
guard_reader=lambda: _guard(swap_used_bytes=1),
stabilize=lambda: calls.append("stabilize"),
runner=lambda _request: {"wall_seconds": 1.0},
guard_reader=lambda: next(snapshots),
stabilize=lambda: None,
expected_machine=_machine(),
)
assert swapped["state"] == "stop"
assert swapped["reason"] == "active-swap"
assert calls == []
assert result["state"] == "accepted"
assert len(result["attempts"]) == 1
def test_oom_stops_without_retry_and_private_result_is_written_atomically(tmp_path):
@@ -1123,8 +1229,9 @@ def test_guarded_cell_does_not_retry_hard_mismatch_and_sanitizes_exceptions():
assert calls == []
def test_guarded_cell_hashes_result_rejected_by_after_run_swap():
snapshots = iter([_guard(), _guard(swap_sin_bytes=1)])
@pytest.mark.parametrize("counter", ["swap_sin_bytes", "swap_sout_bytes"])
def test_guarded_cell_hashes_result_rejected_by_after_run_swap(counter):
snapshots = iter([_guard(), _guard(**{counter: 1})])
raw_result = {"wall_seconds": 1.0, "private": "not-published"}
result = experiment.run_guarded_cell(
{"semantic_id": "safe-cell", "recipe": "W0-FRESH"},