From 38c99855e2fbb863fd56d2a02f104075aa00fc8c Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Tue, 1 Sep 2026 00:58:19 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(diarization):=20=D0=B7=D0=B0=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D1=88=D0=B5=D0=BD=20Ryzen-=D1=8D=D1=82=D0=B0=D0=BF?= =?UTF-8?q?=20CPU-=D1=8D=D0=BA=D1=81=D0=BF=D0=B5=D1=80=D0=B8=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Зачем: - Проверить переносимость Intel-handoff на Ryzen и закрыть выбор CPU-профиля. Что: - Усилена проверка связи Ryzen-manifest с капсулой и переносимости рецептов. - Добавлены регрессионные тесты и публичный отчет с исходом new map. Проверка: - uv run --no-sync pytest -q (389 passed, 2 skipped). - uv run --no-sync ruff check src/local_transcriber/benchmark_experiment.py tests/test_benchmark_experiment.py. --- .../2026-08-31-cpu-diarization-ryzen.md | 166 ++++++++++ src/local_transcriber/benchmark_experiment.py | 189 +++++++++-- tests/test_benchmark_experiment.py | 294 ++++++++++++++++++ 3 files changed, 630 insertions(+), 19 deletions(-) create mode 100644 docs/benchmarks/2026-08-31-cpu-diarization-ryzen.md diff --git a/docs/benchmarks/2026-08-31-cpu-diarization-ryzen.md b/docs/benchmarks/2026-08-31-cpu-diarization-ryzen.md new file mode 100644 index 0000000..a30a53e --- /dev/null +++ b/docs/benchmarks/2026-08-31-cpu-diarization-ryzen.md @@ -0,0 +1,166 @@ +# CPU-диаризация: Ryzen-этап эксперимента + +**Дата:** 2026-08-31 +**Статус:** `new map`; C2 прошла проверки качества и памяти, но не достигла +итогового порога ускорения 30%, а более быстрые ветви Intel уперлись в качество. +Production-профиль не менялся. + +## Итог + +На AMD Ryzen 7 8845H выполнен этап 5 эксперимента из +[спецификации](../specs/2026-08-30-cpu-diarization-experiment.md) по единственному +кандидату C2 из [Intel-этапа](2026-08-31-cpu-diarization-intel.md). Этап 6 +остановлен до полных прогонов: победителя нет. C2 сохраняет baseline-рецепт +диаризации, но выполняет embedding-вызовы через два outer +worker и одну общую ONNX Runtime session с общим intra-op пулом из восьми +потоков; `inter_op_threads=1`. + +Изолированный A/B механизма shared-session дал ускорение 24,05%. Первичный A/B +C2 показал 22,90%, поэтому кандидат прошел порог продолжения 10%, и для него +запустили пять чередующихся циклов. Они дали медиану 23,57% при MAD 0,62 +процентного пункта, то есть ниже итогового порога 30%; по одному Ryzen-кандидату +C2 итог был бы `stop`. +Полная воронка, однако, соответствует отдельному исходу `new map`: все шаги +окна W1-W4 потеряли кластер, а C1 с CAMPPlus + NME ускорилась на Intel на 33,65%, +но добавила 2 слова без говорящего и осталась без требуемой слуховой проверки. +C1 на Ryzen не передавали, победителем она не считается; возвращать ее в матрицу +без подготовленной приватной слуховой проверки нельзя, и автоматической оценкой +эта проверка не подменялась. + +Все принятые ячейки прошли обязательное качество: `3 / 2 / 2` кластера, +неизменный текст, `51 / 13 / 9` слов без говорящего, те же значения +`mapped speaker purity` `0,76655 / 0,78742`, `291 / 291 / 291` segmentation-окон +и `531 / 414 / 440` embedding-заданий. Потерь или дублей prepared-строк не было. +Пиковый RSS C2 оставался в диапазоне 410,3-418,1 МиБ; OOM, активности swap и +линейного роста памяти не наблюдалось. + +Полные записи и пятикатегорийная слуховая приемка по правилам этапа 6 не +запускались. + +## Handoff и воспроизводимость + +| Объект | Значение | +|---|---| +| Merge commit в начале Ryzen-этапа | `68fda7ee893265acfa00ac78b8d4c9697f9916d9` | +| Handoff commit | `42f094ccb9464742683eec9d695e5aa9a69dd4ec` | +| Source commit эксперимента | `6345cb6449d906767b6ed33e695f2ca4eeaf3008` | +| SHA-256 Intel-отчета | `8d4183be4c3ab35f2ea043f8d87ca20e16f523cb60f9c5414d974c1bc4d6b7de` | +| SHA-256 входной Intel-капсулы | `a3a50d34783d24fe6cd13b154ca9813f9842fdcb08d99eab33a6a54efea1e3ae` | +| ID закрытого Ryzen-хранилища | `ryzen-42-20260831` | +| Upstream commit sherpa-onnx 1.13.6 | `1cb484af9b1ab05cc36f89e03dd7d76322f2e911` | +| SHA-256 патча sherpa-onnx 1.13.6 | `5f12df858cd525c8a0cfe8ff95b647c3b0e915a13d45621acc4ac42660014c49` | +| SHA-256 Intel-wheel | `306499d8668ee138357f1de43a5c1c88f4dc46c845e8a92e6ad945327bcaa050` | +| SHA-256 Ryzen-wheel | `baaaa2532606f1e1411e5c84655afa70107ad4b1676420953f5e67233c46a484` | + +Входная Intel-капсула прошла полную проверку: schema v3, 199 файлов, +QDQ-артефакт доступен, предупреждений нет. Ryzen-manifest без изменений закрепил +из Intel-капсулы handoff, артефакты, контрольные записи, calibration, QDQ, патч, +версии runtime и интервал RSS. Baseline и candidate каждой запускаемой +combination-пары совпали с переносимыми Intel-рецептами. + +Ryzen-manifest, сырые результаты, рабочие артефакты стенда, guard-свидетельства +и журналы сохранены в закрытой папке Яндекс Диска. Чтобы восстановить +Ryzen-этап, нужны это хранилище и входная Intel-капсула; публичный отчет связывает +приведенные ниже восемь результатов с закрытым хранилищем только по SHA-256. + +Переданный wheel не загрузился в Ryzen WSL: он требовал GLIBC 2.43, а стенд +работает с GLIBC 2.39. Для измерений wheel пересобран из того же upstream commit +и патча под текущую систему; его отдельный SHA-256 приведен в +таблице. Production-зависимости и lock-файл не менялись. + +Стенд потребовал два исправления, не меняющих пользовательский контракт. Ранее +Ryzen-запуск сравнивал свой manifest побайтово с Intel `manifest.json` внутри +капсулы, хотя обязательные `stage=ryzen`, Ryzen-машина и расписание такое +совпадение исключают. Теперь Intel-manifest проверяется по индексу капсулы, а +Ryzen-manifest отдельно связывается с handoff и входами капсулы. Второе: +`temperature_celsius` допускает `null`, когда WSL и Windows не показывают +датчик температуры; отсутствие измерения больше не подменяется числом. + +## Стенд Ryzen + +- AMD Ryzen 7 8845H with Radeon 780M, 8 физических ядер / 16 логических + процессоров; +- 15 584 276 480 байт RAM доступны WSL, 31 944 794 112 байт установлены в + Windows; swap WSL - 4 294 967 296 байт, активность во время принятых ячеек - + нулевая; +- x86_64; AVX, AVX2, AVX-512, AVX512 VNNI, FMA, SSE4.1, SSE4.2 и AES; +- WSL2 Linux 5.15.167.4 с GLIBC 2.39, Windows 11 Enterprise 10.0.26200, + Python 3.13.13; +- `sherpa-onnx` / `sherpa-onnx-core` 1.13.6, ONNX Runtime 1.28.0, + NumPy 2.4.3, ONNX 1.22.0, kaldi-native-fbank 1.22.3, psutil 7.2.2; +- питание от сети, стабильный Windows-профиль `balanced`, EPP AC 25%, + максимальное состояние процессора 100%, boost mode `aggressive`; +- WSL не предоставляет governor, а доступные интерфейсы не показывают + температурный датчик или аппаратные лимиты мощности. Температура + записана как недоступная; это ограничивает тепловой guard; +- нативные WSL-интерфейсы не отдали supply, platform profile и throttle count, + поэтому для принятых ячеек стенд взял override + `LOCAL_TRANSCRIBER_RUNTIME_GUARD_JSON`, заполненный проверенными Windows-фактами: + AC, `balanced`, turbo, power policy, нулевая подкачка, отсутствие throttling. + До и после принятых пар Windows-проверка подтверждала AC, неизменный `balanced` + и отсутствие событий throttling от `Microsoft-Windows-Kernel-Processor-Power`. + Статический override сам по себе не обнаруживает изменение внутри ячейки, + поэтому внешняя проверка остается ограничением воспроизводимости; +- baseline: одна session, один outer worker, `intra_op_threads=8`, + `inter_op_threads=1`, batch 1; C2: одна shared session, два outer worker, + общий `intra_op_threads=8`, `inter_op_threads=1`, batch 1. + +## Ячейки этапа 5 + +Время ячейки - сумма трех вызовов `diarizer.process()`, по одному на каждый +пятиминутный фрагмент. ASR и загрузка моделей в измерение не входят. + +| Проверка | Порядок | Baseline, с | Кандидат, с | Ускорение | RSS baseline / кандидат, МиБ | Качество | +|---|---|---:|---:|---:|---:|:---:| +| Совпадающий baseline | W0 | 110,998 | - | - | 370,4 / - | да | +| Изолированный E1 | W0 -> E1 | 111,557 | 84,723 | 24,05% | 367,3 / 412,2 | да | +| Первичный C2 | C2 -> W0 | 111,423 | 85,909 | 22,90% | 369,6 / 410,3 | да | + +E1 и C2 сохранили все обязательные и диагностические показатели baseline. +Первичный C2 прошел порог продолжения и уложился в правило памяти. + +## Пять финальных A/B-циклов + +| Цикл | Порядок | Baseline, с | C2, с | Парное ускорение | RSS baseline / C2, МиБ | Качество | +|---:|---|---:|---:|---:|---:|:---:| +| 1 | W0 -> C2 | 111,354 | 83,529 | 24,99% | 369,2 / 413,4 | да | +| 2 | C2 -> W0 | 108,899 | 83,907 | 22,95% | 373,0 / 418,1 | да | +| 3 | W0 -> C2 | 110,928 | 84,968 | 23,40% | 373,8 / 410,9 | да | +| 4 | C2 -> W0 | 114,985 | 83,342 | 27,52% | 376,6 / 411,1 | да | +| 5 | W0 -> C2 | 113,233 | 86,548 | 23,57% | 358,6 / 413,4 | да | +| **Итог** | - | - | - | **медиана 23,57%; MAD 0,62 п. п.** | - | **да** | + +Чередование не изменило вывод: даже лучший отдельный цикл остался ниже 30%, а +медиана недобрала 6,43 процентного пункта. На Intel тот же механизм давал только +4,32%; Ryzen ускоряет shared-session заметнее, но недостаточно для `go`. + +## Индекс публичных доказательств + +Хеши связывают обезличенные агрегаты с приватными JSON-результатами и не +раскрывают пути, текст, интервалы и идентификаторы записей. + +| Результат | SHA-256 | +|---|---| +| Совпадающий baseline | `f50684784392cacc2d99c8aee51d01f0a67dfee3da6ea3ba5f3db749a3d5287a` | +| Изолированный E1 A/B | `24d5edae117e0619e432ef3f3019e8c66cf3516b87b80b64b8591390db25bf48` | +| Первичный C2 A/B | `142db6d06efc8d558b38e18f47f47e3e35e7df00751da2583339f99add8d75c4` | +| Финальный цикл 1 | `4b5095c9e64ff0ef75bbc58516c662bedbbdb0bedf131f66cc1031c9a0ad8c7f` | +| Финальный цикл 2 | `256a17b2d08510aad03d43f5c626c1a4269c9a25271ce492dc3305c4afca1888` | +| Финальный цикл 3 | `b5d37c7a8b05b065c61bc9d5f1c0ab639a80a821730e505179388d90c4239caa` | +| Финальный цикл 4 | `639cc9f13511a59c7240c6fb6ec6bf1c31ec26350330e3ca0c64fb721b768736` | +| Финальный цикл 5 | `4ff2863a93fc22a037de5c292391b6b46de1b539cf397026b349ea5134cf1051` | + +## Решение + +Итог - `new map`: + +1. C2 прошла обязательное качество и память. +2. Медианное ускорение 23,57% не достигло порога 30%. +3. W1-W4 потеряли кластер, а C1 преодолела 30% на Intel только с незакрытым + диагностическим слуховым gate: сокращение окон и цены эмбеддинга уперлось в + качество раньше итогового порога. +4. По правилу спецификации следующий шаг - отдельная карта end-to-end + диаризации; этот отчет ее не открывает и новых замеров не назначает. +5. Полные записи и Ryzen-слуховая приемка не требуются без победителя. +6. Production-профиль, CLI, конфигурационный контракт и модель по умолчанию + остаются без изменений. diff --git a/src/local_transcriber/benchmark_experiment.py b/src/local_transcriber/benchmark_experiment.py index 84bc07e..6d549bb 100644 --- a/src/local_transcriber/benchmark_experiment.py +++ b/src/local_transcriber/benchmark_experiment.py @@ -370,11 +370,13 @@ def validate_manifest( if machine["cpu_flags"] != sorted(set(machine["cpu_flags"])): raise ValueError("machine.cpu_flags: список должен быть сортирован без дублей") _exact_keys(machine["power"], POWER_KEYS, "machine.power") - _require_positive( - machine["power"]["temperature_celsius"], - "machine.power.temperature_celsius", - allow_zero=True, - ) + temperature = machine["power"]["temperature_celsius"] + if temperature is not None: + _require_positive( + temperature, + "machine.power.temperature_celsius", + allow_zero=True, + ) _require_positive( machine["power"]["swap_total_bytes"], "machine.power.swap_total_bytes", @@ -714,14 +716,16 @@ def _validate_handoff( ) name = _require_id(candidate["name"], f"handoff.candidate_recipes:{index}.name") candidate_names.append(name) - cell = combination_cells.get(name) - if cell is None: - raise ValueError( - "handoff: кандидат не описан включенной combination-ячейкой" - ) - expected_cell_id = make_cell_id(manifest, cell) - if candidate["cell_id"] != expected_cell_id: - raise ValueError(f"handoff:{name}: cell_id не совпадает с manifest") + _require_sha256(candidate["cell_id"], f"handoff:{name}.cell_id") + if experiment["stage"] == "intel": + cell = combination_cells.get(name) + if cell is None: + raise ValueError( + "handoff: кандидат не описан включенной combination-ячейкой" + ) + expected_cell_id = make_cell_id(manifest, cell) + if candidate["cell_id"] != expected_cell_id: + raise ValueError(f"handoff:{name}: cell_id не совпадает с manifest") _require_sha256(candidate["result_sha256"], f"handoff:{name}.result_sha256") _require_relative_path(candidate["result_file"], f"handoff:{name}.result_file") for gate in ("mandatory_passed", "memory_passed", "diagnostic_approved"): @@ -1673,16 +1677,156 @@ def _manifest_file_index(manifest: Mapping[str, Any]) -> dict[str, str]: return required +def _portable_inference_recipe( + manifest: Mapping[str, Any], inference: Mapping[str, Any] +) -> dict[str, Any]: + """Переносит только заданные спецификацией конфигурации с бюджетом P.""" + result = dict(inference) + physical_cores = manifest["machine"]["physical_cores"] + mode = result["mode"] + uses_physical_intra = result["intra_op_threads"] == physical_cores and ( + ( + mode in {"sequential", "titanet-batch"} + and result["outer_workers"] == 1 + and result["session_count"] == 1 + ) + or ( + mode == "shared-session" + and result["outer_workers"] == 2 + and result["session_count"] == 1 + ) + ) + if uses_physical_intra: + result["intra_op_threads"] = "physical-cores" + elif ( + mode == "shared-session" + and result["outer_workers"] == physical_cores + and result["session_count"] == 1 + and result["intra_op_threads"] == 1 + ): + result["outer_workers"] = "physical-cores" + elif ( + mode == "separate-session" + and result["outer_workers"] == physical_cores + and result["session_count"] == physical_cores + and result["intra_op_threads"] == 1 + ): + result["outer_workers"] = "physical-cores" + result["session_count"] = "physical-cores" + return result + + +def _portable_cell_recipe( + manifest: Mapping[str, Any], cell: Mapping[str, Any] +) -> dict[str, Any]: + """Нормализует переносимый между машинами рецепт ячейки.""" + return { + "window_shift_ratio": cell["window_shift_ratio"], + "segmentation_artifact_id": cell["segmentation_artifact_id"], + "embedding_artifact_id": cell["embedding_artifact_id"], + "counter": cell["counter"], + "clustering": cell["clustering"], + "inference": _portable_inference_recipe(manifest, cell["inference"]), + } + + +def _validate_ryzen_capsule_manifest( + ryzen_manifest: Mapping[str, Any], intel_manifest: Mapping[str, Any] +) -> None: + """Связывает Ryzen-план с проверенным Intel manifest из капсулы.""" + if intel_manifest.get("experiment", {}).get("stage") != "intel": + raise ValueError("Капсула Ryzen должна содержать Intel manifest") + if ryzen_manifest["experiment"]["stage"] != "ryzen": + raise ValueError("Переносимый manifest должен относиться к Ryzen") + if ryzen_manifest["handoff"] != intel_manifest.get("handoff"): + raise ValueError("Ryzen manifest не совпал с handoff из капсулы") + ryzen_experiment = ryzen_manifest["experiment"] + intel_experiment = intel_manifest["experiment"] + for key in ("sherpa_patch_sha256", "dependencies"): + if ryzen_experiment[key] != intel_experiment.get(key): + raise ValueError( + "Ryzen manifest изменил закрепленный runtime Intel-капсулы" + ) + if ryzen_manifest["rss_sample_interval_ms"] != intel_manifest.get( + "rss_sample_interval_ms" + ): + raise ValueError("Ryzen manifest изменил интервал измерения RSS") + for key in ("artifacts", "recordings", "calibration", "qdq"): + if ryzen_manifest[key] != intel_manifest.get(key): + raise ValueError( + "Ryzen manifest изменил закрепленные входы Intel-капсулы" + ) + intel_cells_by_id = { + make_cell_id(intel_manifest, cell): cell + for cell in intel_manifest["cells"] + if cell["phase"] == "combination" and cell["enabled"] + } + handed_candidate_recipes = [] + handed_baseline_recipes = [] + handed_mechanism_recipes = [] + for candidate in intel_manifest["handoff"]["candidate_recipes"]: + cell = intel_cells_by_id.get(candidate["cell_id"]) + if cell is None: + raise ValueError( + "Handoff-кандидат не связан с Intel combination-ячейкой" + ) + baselines = [ + baseline + for baseline in intel_manifest["cells"] + if baseline["enabled"] + and baseline["pair_id"] == cell["pair_id"] + and baseline["pair_role"] == "baseline" + ] + if len(baselines) != 1: + raise ValueError( + "Handoff-кандидат не связан с Intel baseline-ячейкой" + ) + candidate_recipe = _portable_cell_recipe(intel_manifest, cell) + baseline_recipe = _portable_cell_recipe(intel_manifest, baselines[0]) + handed_candidate_recipes.append(candidate_recipe) + handed_baseline_recipes.append(baseline_recipe) + for key in ("embedding_artifact_id", "inference"): + if candidate_recipe[key] == baseline_recipe[key]: + continue + mechanism_recipe = copy.deepcopy(baseline_recipe) + mechanism_recipe[key] = candidate_recipe[key] + handed_mechanism_recipes.append(mechanism_recipe) + for cell in ryzen_manifest["cells"]: + if not cell["enabled"]: + continue + if cell["phase"] not in {"baseline", "mechanism", "combination"}: + raise ValueError("Ryzen manifest содержит ячейку вне этапов 5-6") + recipe = _portable_cell_recipe(ryzen_manifest, cell) + if cell["phase"] == "baseline" and cell["pair_role"] == "candidate": + raise ValueError("Ryzen baseline-ячейка не может быть кандидатом") + if cell["pair_role"] == "baseline" or cell["phase"] == "baseline": + if recipe not in handed_baseline_recipes: + raise ValueError( + "Ryzen baseline-ячейка не совпала с Intel baseline" + ) + elif cell["phase"] == "mechanism" and recipe not in handed_mechanism_recipes: + raise ValueError( + "Ryzen mechanism-ячейка не совпала с механизмом handoff" + ) + elif cell["phase"] == "combination" and recipe not in handed_candidate_recipes: + raise ValueError( + "Ryzen combination-ячейка не совпала с рецептом handoff" + ) + + def verify_capsule( archive_path: Path, expected_sha256: str, *, - expected_manifest_path: Path, + expected_manifest_path: Path | None, + expected_ryzen_manifest: Mapping[str, Any] | None = None, expected_source_commit: str | None = None, expected_report_sha256: str | None = None, expected_capsule_id: str | None = None, ) -> dict[str, Any]: """Сверяет внешний и внутренние хеши приватной ZIP-капсулы.""" + if (expected_manifest_path is None) == (expected_ryzen_manifest is None): + raise ValueError("Нужно выбрать ровно один Intel или Ryzen manifest") if file_sha256(archive_path) != expected_sha256: raise ValueError("SHA-256 ZIP-капсулы не совпал") with zipfile.ZipFile(archive_path) as archive: @@ -1733,14 +1877,19 @@ def verify_capsule( actual = hashlib.sha256(archive.read(name)).hexdigest() if actual != expected: raise ValueError("Внутренний SHA-256 капсулы не совпал") - manifest_bytes = expected_manifest_path.read_bytes() archived_manifest = archive.read("manifest.json") - if archived_manifest != manifest_bytes: - raise ValueError("manifest аргумента не совпал с manifest.json капсулы") - manifest_sha256 = hashlib.sha256(manifest_bytes).hexdigest() + if expected_manifest_path is not None: + manifest_bytes = expected_manifest_path.read_bytes() + if archived_manifest != manifest_bytes: + raise ValueError("manifest аргумента не совпал с manifest.json капсулы") + manifest_sha256 = hashlib.sha256(archived_manifest).hexdigest() if declared["manifest.json"] != manifest_sha256: raise ValueError("Хеш manifest.json не совпал с индексом капсулы") archived_manifest_value = json.loads(archived_manifest) + if expected_ryzen_manifest is not None: + _validate_ryzen_capsule_manifest( + expected_ryzen_manifest, archived_manifest_value + ) for name, expected in _manifest_file_index(archived_manifest_value).items(): if declared.get(name) != expected: raise ValueError( @@ -1783,10 +1932,12 @@ def run_cli(args: Any, raw_manifest: Mapping[str, Any]) -> None: raise ValueError("Итоговый handoff требует --capsule и --capsule-sha256") capsule_result = None if capsule_path is not None and capsule_sha256 is not None: + ryzen_stage = manifest["experiment"]["stage"] == "ryzen" capsule_result = verify_capsule( capsule_path, capsule_sha256, - expected_manifest_path=args.manifest, + expected_manifest_path=None if ryzen_stage else args.manifest, + expected_ryzen_manifest=raw_manifest if ryzen_stage else None, expected_source_commit=manifest["handoff"]["source_commit"], expected_report_sha256=manifest["handoff"]["public_report_sha256"], expected_capsule_id=manifest["handoff"]["capsule_id"], diff --git a/tests/test_benchmark_experiment.py b/tests/test_benchmark_experiment.py index aa7b12f..10c7d01 100644 --- a/tests/test_benchmark_experiment.py +++ b/tests/test_benchmark_experiment.py @@ -4,6 +4,7 @@ import json import os import zipfile from pathlib import Path +from types import SimpleNamespace import pytest @@ -206,6 +207,119 @@ def _environment() -> dict[str, object]: } +def _finalized_handoff_manifest(tmp_path: Path) -> dict[str, object]: + manifest = _manifest(tmp_path) + baseline = manifest["cells"][0] + baseline.update( + name="W0", + phase="combination", + pair_id="combination-c2", + pair_role="baseline", + schedule_position=10, + ) + candidate = copy.deepcopy(baseline) + candidate.update( + name="C2", + pair_role="candidate", + schedule_position=11, + ) + candidate["inference"].update( + mode="shared-session", + outer_workers=2, + session_count=1, + intra_op_threads=4, + ) + manifest["cells"] = [baseline, candidate] + cell_id = experiment.make_cell_id(manifest, candidate) + manifest["handoff"] = { + "outcome": "handoff", + "source_commit": manifest["experiment"]["source_commit"], + "candidate_recipes": [ + { + "name": "C2", + "cell_id": cell_id, + "result_file": "results/w0.json", + "result_sha256": "3" * 64, + "mandatory_passed": True, + "memory_passed": True, + "diagnostic_approved": True, + } + ], + "public_report_sha256": "4" * 64, + "capsule_id": "opaque-41", + "capsule_sha256": "5" * 64, + } + return manifest + + +def _ryzen_capsule_fixture(tmp_path: Path): + intel = _finalized_handoff_manifest(tmp_path) + payloads = {} + + def make_relative(item, archive_path): + content = Path(item["path"]).read_bytes() + item["path"] = archive_path + payloads[archive_path] = content + local_path = tmp_path / archive_path + local_path.parent.mkdir(parents=True, exist_ok=True) + local_path.write_bytes(content) + + for artifact in intel["artifacts"]: + make_relative(artifact, f"artifacts/{artifact['id']}.bin") + for recording in intel["recordings"]: + make_relative(recording, f"recordings/{recording['id']}.wav") + make_relative( + recording["asr_words"], f"sidecars/{recording['id']}.words.json" + ) + if recording["reference"] is not None: + make_relative( + recording["reference"], f"references/{recording['id']}.md" + ) + for source in intel["calibration"]: + make_relative(source, f"calibration/{source['id']}.wav") + + result_bytes = b'{"aggregate":{"quality_passed":true}}' + result_path = "results/c2.json" + payloads[result_path] = result_bytes + candidate = intel["handoff"]["candidate_recipes"][0] + candidate["result_file"] = result_path + candidate["result_sha256"] = hashlib.sha256(result_bytes).hexdigest() + intel_bytes = json.dumps(intel, separators=(",", ":")).encode() + files = { + "manifest.json": hashlib.sha256(intel_bytes).hexdigest(), + **{name: hashlib.sha256(value).hexdigest() for name, value in payloads.items()}, + } + capsule = { + "capsule_id": intel["handoff"]["capsule_id"], + "source_commit": intel["handoff"]["source_commit"], + "public_report_sha256": intel["handoff"]["public_report_sha256"], + "files": files, + } + archive_path = tmp_path / "capsule.zip" + with zipfile.ZipFile(archive_path, "w") as archive: + archive.writestr("capsule.json", json.dumps(capsule)) + archive.writestr("manifest.json", intel_bytes) + for name, content in payloads.items(): + archive.writestr(name, content) + + ryzen = copy.deepcopy(intel) + ryzen["experiment"].update( + id="cpu-diarization-ryzen", + stage="ryzen", + handoff_commit="6" * 40, + ) + ryzen["machine"].update( + id="ryzen-8845h", + sku="AMD Ryzen 7 8845H", + physical_cores=8, + logical_cores=16, + ) + for cell in ryzen["cells"]: + cell["stage"] = "ryzen" + cell["inference"]["intra_op_threads"] = 8 + return archive_path, ryzen, files["manifest.json"] + + def _guard(**changes) -> dict[str, object]: result = { "supply": "ac", @@ -322,6 +436,134 @@ def test_portable_handoff_check_skips_only_machine_identity(tmp_path): ) +def test_ryzen_manifest_preserves_intel_capsule_inputs_and_evidence(tmp_path): + intel = _finalized_handoff_manifest(tmp_path) + experiment.validate_manifest(intel, verify_files=False) + ryzen = copy.deepcopy(intel) + ryzen["experiment"].update( + id="cpu-diarization-ryzen", + stage="ryzen", + handoff_commit="6" * 40, + ) + ryzen["machine"].update( + id="ryzen-8845h", + sku="AMD Ryzen 7 8845H", + physical_cores=8, + logical_cores=16, + ) + for cell in ryzen["cells"]: + cell["stage"] = "ryzen" + cell["inference"]["intra_op_threads"] = 8 + ryzen["cells"][0]["name"] = "R-W0-primary" + ryzen["cells"][1]["name"] = "R-C2-primary" + + experiment.validate_manifest(ryzen, verify_files=False) + experiment._validate_ryzen_capsule_manifest(ryzen, intel) + + changed_input = copy.deepcopy(ryzen) + changed_input["artifacts"][0]["sha256"] = "7" * 64 + with pytest.raises(ValueError, match="закрепленные входы"): + experiment._validate_ryzen_capsule_manifest(changed_input, intel) + + changed_handoff = copy.deepcopy(ryzen) + changed_handoff["handoff"]["public_report_sha256"] = "8" * 64 + with pytest.raises(ValueError, match="handoff"): + experiment._validate_ryzen_capsule_manifest(changed_handoff, intel) + + changed_recipe = copy.deepcopy(ryzen) + changed_recipe["cells"][1]["window_shift_ratio"] = 0.2 + with pytest.raises(ValueError, match="рецептом handoff"): + experiment._validate_ryzen_capsule_manifest(changed_recipe, intel) + + changed_candidate = copy.deepcopy(intel) + changed_candidate["handoff"]["candidate_recipes"][0]["cell_id"] = "8" * 64 + changed_candidate_ryzen = copy.deepcopy(ryzen) + changed_candidate_ryzen["handoff"]["candidate_recipes"][0]["cell_id"] = ( + "8" * 64 + ) + with pytest.raises(ValueError, match="Intel combination"): + experiment._validate_ryzen_capsule_manifest( + changed_candidate_ryzen, changed_candidate + ) + + changed_baseline = copy.deepcopy(ryzen) + changed_baseline["cells"][0]["window_shift_ratio"] = 0.5 + with pytest.raises(ValueError, match="baseline"): + experiment._validate_ryzen_capsule_manifest(changed_baseline, intel) + + changed_phase = copy.deepcopy(ryzen) + changed_phase["cells"][1]["phase"] = "mechanism" + changed_phase["cells"][1]["window_shift_ratio"] = 0.5 + with pytest.raises(ValueError, match="механизмом handoff"): + experiment._validate_ryzen_capsule_manifest(changed_phase, intel) + + changed_patch = copy.deepcopy(ryzen) + changed_patch["experiment"]["sherpa_patch_sha256"] = "7" * 64 + with pytest.raises(ValueError, match="runtime"): + experiment._validate_ryzen_capsule_manifest(changed_patch, intel) + + changed_dependencies = copy.deepcopy(ryzen) + changed_dependencies["experiment"]["dependencies"]["onnxruntime"] = "1.99.0" + with pytest.raises(ValueError, match="runtime"): + experiment._validate_ryzen_capsule_manifest(changed_dependencies, intel) + + changed_rss_interval = copy.deepcopy(ryzen) + changed_rss_interval["rss_sample_interval_ms"] = 5000 + with pytest.raises(ValueError, match="RSS"): + experiment._validate_ryzen_capsule_manifest(changed_rss_interval, intel) + + literal_workers_intel = copy.deepcopy(intel) + literal_workers_intel["cells"][1]["inference"]["outer_workers"] = 4 + literal_workers_intel["handoff"]["candidate_recipes"][0]["cell_id"] = ( + experiment.make_cell_id( + literal_workers_intel, literal_workers_intel["cells"][1] + ) + ) + literal_workers_ryzen = copy.deepcopy(ryzen) + literal_workers_ryzen["handoff"] = copy.deepcopy( + literal_workers_intel["handoff"] + ) + literal_workers_ryzen["cells"][1]["inference"].update( + outer_workers=4, + intra_op_threads=4, + ) + experiment._validate_ryzen_capsule_manifest( + literal_workers_ryzen, literal_workers_intel + ) + scaled_workers_ryzen = copy.deepcopy(literal_workers_ryzen) + scaled_workers_ryzen["cells"][1]["inference"].update( + outer_workers=8, + intra_op_threads=8, + ) + with pytest.raises(ValueError, match="рецептом handoff"): + experiment._validate_ryzen_capsule_manifest( + scaled_workers_ryzen, literal_workers_intel + ) + + +@pytest.mark.parametrize("cell_id", [None, "not-a-sha256"]) +def test_ryzen_manifest_rejects_invalid_handoff_cell_id(tmp_path, cell_id): + manifest = _finalized_handoff_manifest(tmp_path) + manifest["experiment"].update(stage="ryzen", handoff_commit="6" * 40) + for cell in manifest["cells"]: + cell["stage"] = "ryzen" + manifest["handoff"]["candidate_recipes"][0]["cell_id"] = cell_id + + with pytest.raises(ValueError, match="cell_id"): + experiment.validate_manifest(manifest, verify_files=False) + + +def test_manifest_accepts_unavailable_temperature_sensor(tmp_path): + manifest = _manifest(tmp_path) + manifest["machine"]["power"]["temperature_celsius"] = None + + experiment.validate_manifest(manifest, verify_files=False) + + manifest["machine"]["power"]["temperature_celsius"] = -1.0 + with pytest.raises(ValueError, match="temperature_celsius"): + experiment.validate_manifest(manifest, verify_files=False) + + def test_manifest_rejects_stage_boundary_thread_budget_and_non_alternating_ab(tmp_path): manifest = _manifest(tmp_path) manifest["cells"][0]["stage"] = "ryzen" @@ -808,6 +1050,20 @@ def test_capsule_verification_checks_hashes_commits_and_safe_paths(tmp_path): "manifest_sha256": hashlib.sha256(manifest_bytes).hexdigest(), } + with pytest.raises(ValueError, match="ровно один"): + experiment.verify_capsule( + archive_path, + archive_hash, + expected_manifest_path=None, + ) + with pytest.raises(ValueError, match="ровно один"): + experiment.verify_capsule( + archive_path, + archive_hash, + expected_manifest_path=manifest_path, + expected_ryzen_manifest=manifest_value, + ) + incomplete_capsule = copy.deepcopy(capsule) incomplete_capsule["files"].pop("data/payload.bin") incomplete_path = tmp_path / "incomplete.zip" @@ -843,6 +1099,44 @@ def test_capsule_verification_checks_hashes_commits_and_safe_paths(tmp_path): ) +def test_run_cli_accepts_verified_ryzen_capsule_manifest( + tmp_path, capsys, monkeypatch +): + archive_path, ryzen, intel_manifest_sha256 = _ryzen_capsule_fixture(tmp_path) + manifest_path = tmp_path / "ryzen.json" + manifest_path.write_text(json.dumps(ryzen), encoding="utf-8") + args = SimpleNamespace( + manifest=manifest_path, + output=None, + work_dir=tmp_path / "work", + capsule=archive_path, + capsule_sha256=experiment.file_sha256(archive_path), + validate_only=True, + prepare_asr=None, + ) + monkeypatch.setattr(experiment, "environment_snapshot", _environment) + + experiment.run_cli(args, ryzen) + + plan = json.loads(capsys.readouterr().out) + assert plan["stage"] == "ryzen" + assert plan["capsule"]["manifest_sha256"] == intel_manifest_sha256 + assert plan["capsule"]["file_count"] == 14 + + +def test_verify_capsule_rejects_unlinked_ryzen_manifest(tmp_path): + archive_path, ryzen, _manifest_sha256 = _ryzen_capsule_fixture(tmp_path) + ryzen["artifacts"][0]["sha256"] = "7" * 64 + + with pytest.raises(ValueError, match="закрепленные входы"): + experiment.verify_capsule( + archive_path, + experiment.file_sha256(archive_path), + expected_manifest_path=None, + expected_ryzen_manifest=ryzen, + ) + + def test_capsule_binds_candidate_result_evidence_to_file_index(tmp_path): input_bytes = b"input" input_hash = hashlib.sha256(input_bytes).hexdigest() -- 2.54.0 From 249b970cc953edb7ab89041759560eb5a18e2a3c Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Tue, 1 Sep 2026 01:36:45 +0300 Subject: [PATCH 2/2] =?UTF-8?q?docs(diarization):=20=D1=80=D0=B0=D1=81?= =?UTF-8?q?=D1=88=D0=B8=D1=80=D0=B5=D0=BD=20=D0=BE=D1=82=D1=87=D0=B5=D1=82?= =?UTF-8?q?=20Ryzen-=D1=8D=D0=BA=D1=81=D0=BF=D0=B5=D1=80=D0=B8=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Зачем: - Сохранить полный ход проверки гипотез и исключить ненужный повтор старой матрицы. Что: - Добавлен ledger этапов W/M/Q/E/C с рецептами, числами и причинами отсева. - Зафиксированы правила переиспользования результатов и границы повторных прогонов. - Связана следующая end-to-end карта в задаче #45. Проверка: - uv run --no-sync pytest -q (389 passed, 2 skipped). - Codex taste-review и ai-text-lint: APPROVED. --- .../2026-08-31-cpu-diarization-ryzen.md | 190 ++++++++++++++++-- 1 file changed, 171 insertions(+), 19 deletions(-) diff --git a/docs/benchmarks/2026-08-31-cpu-diarization-ryzen.md b/docs/benchmarks/2026-08-31-cpu-diarization-ryzen.md index a30a53e..46edb03 100644 --- a/docs/benchmarks/2026-08-31-cpu-diarization-ryzen.md +++ b/docs/benchmarks/2026-08-31-cpu-diarization-ryzen.md @@ -9,23 +9,23 @@ Production-профиль не менялся. На AMD Ryzen 7 8845H выполнен этап 5 эксперимента из [спецификации](../specs/2026-08-30-cpu-diarization-experiment.md) по единственному -кандидату C2 из [Intel-этапа](2026-08-31-cpu-diarization-intel.md). Этап 6 -остановлен до полных прогонов: победителя нет. C2 сохраняет baseline-рецепт -диаризации, но выполняет embedding-вызовы через два outer -worker и одну общую ONNX Runtime session с общим intra-op пулом из восьми -потоков; `inter_op_threads=1`. +кандидату C2 из [Intel-этапа](2026-08-31-cpu-diarization-intel.md). Этап 6 не +запускали: победителя нет. C2 сохраняет `window_shift_ratio=0.1`, Pyannote +Segmentation 3.0 FP32, WeSpeaker ResNet34 LM FP32 и `FastClustering(0.89)`. +Embedding-вызовы выполняют два outer worker через одну общую ONNX Runtime +session с общим пулом из восьми intra-op потоков; `inter_op_threads=1`. Изолированный A/B механизма shared-session дал ускорение 24,05%. Первичный A/B C2 показал 22,90%, поэтому кандидат прошел порог продолжения 10%, и для него запустили пять чередующихся циклов. Они дали медиану 23,57% при MAD 0,62 процентного пункта, то есть ниже итогового порога 30%; по одному Ryzen-кандидату C2 итог был бы `stop`. -Полная воронка, однако, соответствует отдельному исходу `new map`: все шаги -окна W1-W4 потеряли кластер, а C1 с CAMPPlus + NME ускорилась на Intel на 33,65%, -но добавила 2 слова без говорящего и осталась без требуемой слуховой проверки. -C1 на Ryzen не передавали, победителем она не считается; возвращать ее в матрицу -без подготовленной приватной слуховой проверки нельзя, и автоматической оценкой -эта проверка не подменялась. + +Вся воронка дала `new map`. W1-W4 потеряли обязательное число кластеров. C1 с +CAMPPlus + NME ускорила Intel-прогон на 33,65%, но оставила два дополнительных +слова без говорящего; требуемую слуховую проверку не проводили и автоматической +оценкой не подменяли. Поэтому C1 не передавали на Ryzen и не считаем победителем. +Вернуть ее в матрицу можно только после приватной слуховой проверки. Все принятые ячейки прошли обязательное качество: `3 / 2 / 2` кластера, неизменный текст, `51 / 13 / 9` слов без говорящего, те же значения @@ -34,8 +34,120 @@ C1 на Ryzen не передавали, победителем она не сч Пиковый RSS C2 оставался в диапазоне 410,3-418,1 МиБ; OOM, активности swap и линейного роста памяти не наблюдалось. -Полные записи и пятикатегорийная слуховая приемка по правилам этапа 6 не -запускались. +Полные записи и слуховая приемка по пяти категориям на этапе 6 не запускались. + +## Что именно проверяли + +Отчет охватывает всю воронку: этапы 0-4 на Intel и этап 5 на Ryzen. На Intel +проверили шаг окна, модели эмбеддингов, счетчик говорящих, квантизацию, способы +параллельного исполнения и две комбинации. Таблицы ниже собирают этот путь в +одном месте. Подробные хеши и исходные идентификаторы ячеек остаются в +[Intel-отчете](2026-08-31-cpu-diarization-intel.md). + +Intel-ячейки W/M/E/C были разведочными одиночными прогонами трех пятиминутных +фрагментов. Они показывают направление и причину отсева, но не дисперсию. +Исключение - соседняя пара W0/C2 под runtime guard. На Ryzen сначала выполнялась +одна диагностическая пара, затем первичная пара C2 и только после прохождения +порога - пять чередующихся циклов. + +### Этап 0: пригодность механизмов + +До дорогих CPU-ячеек проверили, что экспериментальный стенд вообще измеряет +задуманные механизмы, не теряя строки и не меняя пользовательский результат. + +| Гипотеза | Что проверяли | Что получилось | Решение | +|---|---|---|---| +| `Prepare/Finalize` | Подготовка один раз, кластеризация копии матрицы и финализация | Результат совпал со штатным `process()`; пропусков и дублей строк нет | Использовать в стенде | +| Счетчик eigengap | CAMPPlus и TitaNet на матрицах `531 / 414 / 440` строк | CAMPPlus дал `6 / 1 / 7`, TitaNet - `7 / 2 / 4` вместо `3 / 2 / 2` | Исключить | +| Счетчик NME | Те же шесть матриц без повторного embedding-инференса | Обе модели дали `3 / 2 / 2`; метки повторяемы, потерянных строк нет | Использовать для M2 и M3 | +| WeSpeaker S8S8 QDQ | Схема, opset, метаданные, динамические длины и загрузка через sherpa-onnx | Граф технически корректен; качество и скорость проверены отдельно в Q1 | Допустить к CPU-ячейке | +| Shared-session worker | Один и два worker на одном наборе prepared-входов | Строки, метки и интервалы совпали; гонок и нечисловых значений нет | Допустить E1/E2 | + +Задача этапа 0 - подтвердить, что последующие CPU-ячейки сравнивают одинаковые +вычисления и используют исправный экспериментальный API. Скорость измеряли на +следующих этапах. + +### Этап 1: можно ли считать меньше окон + +Гипотеза: увеличить `window_shift_ratio`, сократить число segmentation-окон и +вместе с ними число embedding-заданий. Все варианты использовали Pyannote +Segmentation 3.0 FP32, WeSpeaker FP32 и текущий `FastClustering(0.89)`. + +| Ячейка | Шаг | Окна по трем фрагментам | Embedding-задания | Время, с | Кластеры | Решение | +|---|---:|---|---|---:|---|---| +| W0 | 0,10 | `291 / 291 / 291` | `531 / 414 / 440` | 148,220 | `3 / 2 / 2` | baseline | +| W1 | 0,15 | `195 / 195 / 195` | `357 / 274 / 302` | 100,799 | `2 / 2 / 2` | исключена: потерян кластер | +| W2 | 0,20 | `146 / 146 / 146` | `264 / 206 / 222` | 76,127 | `2 / 2 / 2` | исключена: потерян кластер | +| W3 | 0,25 | `117 / 117 / 117` | `211 / 165 / 175` | 61,369 | `2 / 2 / 2` | исключена: потерян кластер | +| W4 | 0,50 | `59 / 59 / 59` | `105 / 82 / 89` | 31,291 | `1 / 2 / 2` | исключена: потеряны два кластера | + +Скорость росла именно вместе с сокращением работы, но даже осторожный W1 +нарушил обязательное качество на первой записи. Рабочим остался шаг `0.1`. +Комбинация C0 совпала бы с baseline, поэтому ее не запускали. + +### Этап 2: можно ли удешевить модель эмбеддингов + +Гипотеза: сохранить полный набор окон, но заменить WeSpeaker и при необходимости +счетчик говорящих. В M2 и M3 сохраняли `window_shift_ratio=0.1`, Pyannote +Segmentation 3.0 FP32 и последовательный инференс. Менялись модель эмбеддингов и +счетчик говорящих. Проценты ниже посчитаны относительно W0 148,220 с. + +| Ячейка | Рецепт | Время, с | Изменение | RSS, МиБ | Кластеры | Решение | +|---|---|---:|---:|---:|---|---| +| W0 (рецепт M0) | WeSpeaker FP32 + `FastClustering(0.89)` | 148,220 | baseline | 362,6 | `3 / 2 / 2` | точка сравнения | +| M2 | CAMPPlus + NME + `FastClustering(N)` | 94,550 | -36,21% | 358,6 | `3 / 2 / 2` | самый быстрый, собрать C1 | +| M3 | TitaNet small + NME + `FastClustering(N)` | 107,205 | -27,67% | 511,9 | `3 / 2 / 2` | прошла, но медленнее и тяжелее M2 | + +M2 и M3 прошли обязательное число кластеров. Окончательная диагностика M2 +появилась в C1: purity выросла, но число слов без говорящего на первой записи +изменилось с 51 до 53. По спецификации это требует прослушивания отмеченных мест. + +### Этап 3: квантизация и исполнение эмбеддингов + +Для QDQ проверяли, даст ли INT8 выигрыш старому Intel без VNNI. Q1 и E1-E3 +сохраняли `window_shift_ratio=0.1`, Pyannote Segmentation 3.0 FP32 и текущий +`FastClustering(0.89)`. Q1 меняла модель WeSpeaker, E1-E3 - только топологию +исполнения FP32-модели. Во всех вариантах `inter_op_threads=1`; E1-E3 использовали +общий бюджет четырех физических ядер. + +| Ячейка | Рецепт | Время, с | Изменение к W0 | RSS, МиБ | Кластеры | Решение | +|---|---|---:|---:|---:|---|---| +| Q1 | WeSpeaker S8S8 QDQ, sequential | 177,537 | +19,78% | 367,7 | `2 / 2 / 2` | исключена: медленнее и потерян кластер | +| E1 | 2 outer worker, одна session, общий pool 4 | 139,219 | -6,07% | 398,6 | `3 / 2 / 2` | передать как аппаратный кандидат | +| E2 | 4 outer worker, одна session, `intra_op=1` | 152,167 | +2,66% | 551,2 | `3 / 2 / 2` | исключена по времени и памяти | +| E3 | 4 отдельные session, `intra_op=1` | 159,038 | +7,30% | 732,3 | `3 / 2 / 2` | отрицательный контроль подтвердился | + +E3 измерялась составным интервалом на заранее подготовленных заданиях, поэтому +ее wall time нельзя сравнивать с `process()` так же строго, как E1/E2. Для +решения хватило отрицательного результата одновременно по времени и памяти. + +TitaNet batch проверили отдельно на тех же трех матрицах: + +| Контроль | Строк | Batch 1, с | Batch 4, с | Число кластеров | Метки строк совпали | +|---:|---:|---:|---:|---:|:---:| +| 1 | 531 | 27,175 | 37,588 | 3 | нет | +| 2 | 414 | 25,871 | 32,443 | 2 | нет | +| 3 | 440 | 22,877 | 28,565 | 2 | нет | + +Batch 4 сохранил число кластеров, но оказался медленнее batch 1, поменял метки +строк на всех трех матрицах и дал максимальный дрейф эмбеддингов `0,277-0,317`. +Ветвь остановили до отдельной CPU-ячейки. SHA-256 закрытого batch-result: +`f12bfdb5cadf80c62871c0725eac744a99825cb5d06b70fcf11e4e538ff6df72`. + +### Этап 4: комбинации перед Ryzen + +Полного декартова перебора не было. C0 совпала с baseline; из оставшихся +собрали самый быстрый модельный вариант C1 и переносимый аппаратный вариант C2. + +| Комбинация | Точный рецепт | Время, с | Ускорение | RSS, МиБ | Диагностика | Решение | +|---|---|---:|---:|---:|---|---| +| C1 | `0.1` + Pyannote Segmentation 3.0 FP32 + CAMPPlus + NME + `FastClustering(N)` + sequential | 98,347 | 33,65% | 399,5 | `3 / 2 / 2`; purity выше; `+2` слова без говорящего | не передавать без слуховой проверки | +| C2 | `0.1` + Pyannote Segmentation 3.0 FP32 + WeSpeaker ResNet34 LM FP32 + `FastClustering(0.89)` + 2 outer worker + 1 shared session + pool 4 | 140,632 | 4,32% | 406,0 | точное совпадение с соседним W0 | передать на Ryzen | + +У C1 процент считается от разведочного W0 148,220 с. У C2 был соседний W0 +146,978 с под тем же runtime guard. C2 не достигла Intel-порога продолжения, +но shared-session заранее считалась аппаратной гипотезой: Ryzen имеет восемь +физических ядер и другой ISA, поэтому спецификация требовала отдельный A/B. ## Handoff и воспроизводимость @@ -53,10 +165,10 @@ C1 на Ryzen не передавали, победителем она не сч | SHA-256 Ryzen-wheel | `baaaa2532606f1e1411e5c84655afa70107ad4b1676420953f5e67233c46a484` | Входная Intel-капсула прошла полную проверку: schema v3, 199 файлов, -QDQ-артефакт доступен, предупреждений нет. Ryzen-manifest без изменений закрепил -из Intel-капсулы handoff, артефакты, контрольные записи, calibration, QDQ, патч, -версии runtime и интервал RSS. Baseline и candidate каждой запускаемой -combination-пары совпали с переносимыми Intel-рецептами. +QDQ-артефакт доступен, предупреждений нет. Ryzen-manifest без изменений +зафиксировал данные Intel-капсулы: handoff, артефакты, контрольные записи, +calibration, QDQ, патч, версии runtime и интервал RSS. Рецепты baseline и +кандидата во всех запущенных парах совпали с переносимыми Intel-рецептами. Ryzen-manifest, сырые результаты, рабочие артефакты стенда, guard-свидетельства и журналы сохранены в закрытой папке Яндекс Диска. Чтобы восстановить @@ -94,7 +206,7 @@ Ryzen-manifest отдельно связывается с handoff и входа температурный датчик или аппаратные лимиты мощности. Температура записана как недоступная; это ограничивает тепловой guard; - нативные WSL-интерфейсы не отдали supply, platform profile и throttle count, - поэтому для принятых ячеек стенд взял override + поэтому для принятых ячеек использовали override `LOCAL_TRANSCRIBER_RUNTIME_GUARD_JSON`, заполненный проверенными Windows-фактами: AC, `balanced`, turbo, power policy, нулевая подкачка, отсутствие throttling. До и после принятых пар Windows-проверка подтверждала AC, неизменный `balanced` @@ -134,6 +246,44 @@ E1 и C2 сохранили все обязательные и диагност медиана недобрала 6,43 процентного пункта. На Intel тот же механизм давал только 4,32%; Ryzen ускоряет shared-session заметнее, но недостаточно для `go`. +## Что уже не нужно прогонять заново + +Полный replay этапов 0-5 для следующей гипотезы не нужен. Результаты закреплены +публичными агрегатами, хешами и закрытой капсулой. + +| Результат | Что можно переиспользовать | Когда повтор действительно нужен | +|---|---|---| +| W1-W4 | На текущей Pyannote-модели увеличение шага теряет кластер раньше порога скорости | Изменилась segmentation-модель, логика сборки окон или обязательный quality gate | +| M2/M3 | CAMPPlus и TitaNet с NME сохраняют `3 / 2 / 2`; M2 быстрее и экономнее | Изменилась модель, счетчик, clustering или контрольные входы | +| Q1 | Этот S8S8 QDQ-граф на Intel медленнее FP32 и дает `2 / 2 / 2` | Новая калибровка, формат квантизации, модель или runtime | +| E2/E3 | Больше worker и отдельные session не окупились при прежнем бюджете потоков | Изменилась топология session, runtime или бюджет ядер | +| E1/C2 на Ryzen | Для точного рецепта shared-session медиана равна 23,57%, качество и память проходят | Изменилась реализация механизма, runtime, машина или режим питания | +| C1 | Замер скорости уже есть; открыт только диагностический слуховой gate | Сначала прослушать подготовленные места; новый CPU-замер нужен лишь после положительного решения | + +Есть три разных вида проверки, и они не заменяют друг друга: + +1. `--validate-only` сверяет commit, схему, хеши, handoff и план. Модели он не + запускает. +2. `uv run pytest` проверяет код стенда на mock-данных. Полный Python-suite перед + PR нужен, но он не повторяет аудиозамеры и не скачивает модели. +3. Измерительная ячейка запускает реальные модели. Для новой гипотезы нужен + только затронутый минимальный срез, один совпадающий baseline и первичный A/B. + Пять циклов и полные записи нужны лишь кандидату, прошедшему записанные пороги. + +## Что осталось неизвестным + +- C1 не получила слухового решения. Из автоматических метрик нельзя вывести, + допустимы ли два дополнительных слова без говорящего. +- На Ryzen не опубликовано разложение wall time на segmentation, embeddings, + clustering и orchestration. По общему времени нельзя надежно назвать следующий + доминирующий компонент. +- Полные записи не запускались: C2 не достигла порога, а C1 не дошла до Ryzen. +- Температурный датчик в WSL и Windows недоступен. Внешняя проверка питания и + throttling снижает риск дрейфа, но не заменяет измерение температуры во время + каждой ячейки. +- Эксперимент проверял локальные замены внутри прежнего каскада. End-to-end + архитектуры в эту матрицу не входили. + ## Индекс публичных доказательств Хеши связывают обезличенные агрегаты с приватными JSON-результатами и не @@ -160,7 +310,9 @@ E1 и C2 сохранили все обязательные и диагност диагностическим слуховым gate: сокращение окон и цены эмбеддинга уперлось в качество раньше итогового порога. 4. По правилу спецификации следующий шаг - отдельная карта end-to-end - диаризации; этот отчет ее не открывает и новых замеров не назначает. + диаризации, заведенная как + [задача #45](https://git.dementev.space/ddmitry/local-transcriber/issues/45). + Ее гипотезы и замеры относятся к отдельному эксперименту. 5. Полные записи и Ryzen-слуховая приемка не требуются без победителя. 6. Production-профиль, CLI, конфигурационный контракт и модель по умолчанию остаются без изменений. -- 2.54.0