Compare commits

...
4 Commits
Author SHA1 Message Date
Dmitry Dementiev 2fc777fdee docs(cpu): уточнена аппаратная матрица
- Зачем:
  - Конкретный Core i5 ошибочно стал обязательной приемочной машиной при разнородном парке пользователей.
- Что:
  - CPU-дефолт отделен от воспроизводимых Intel и AMD baseline.
  - README, backlog и исследование приведены к единой терминологии.
- Проверка:
  - `git diff --check`.
2026-08-30 20:58:18 +03:00
Dmitry Dementiev b09036eaa3 docs(research): выбрана схема оценки числа говорящих
- Зачем:
  - быстрым моделям эмбеддингов нужен автоматический подсчет без повторного инференса.
- Что:
  - проверены возможности повторного использования эмбеддингов в публичном API sherpa-onnx.
  - сопоставлены порог, eigengap, NME-SC и двухшаговая кластеризация.
  - зафиксирована граница NumPy-прототипа без production-зависимости от NeMo.
- Проверка:
  - git diff --cached --check.
2026-08-30 20:56:21 +03:00
Dmitry Dementiev 5c6697ecbf docs(research): уточнены batch-контракты embedding-моделей
- Зачем:
  - вопрос тикета требует проверить формы официальных release-графов, а исходная записка опиралась только на экспортные контракты.
- Что:
  - зафиксированы реальные формы и CPU smoke-test batch size 2 для WeSpeaker, CAMPPlus и TitaNet.
  - уточнены поддержка equal-length batch у CAMPPlus и fallback между S8S8, U8U8 и U8S8.
- Проверка:
  - git diff --check.
2026-08-30 20:56:21 +03:00
Dmitry Dementiev bfabf09c83 docs(research): добавлено исследование embedding-инференса
- Зачем:
  - нужно выбрать реалистичный способ ускорения эмбеддингов на разных x86 CPU.
- Что:
  - зафиксированы ограничения batch, stream/session и static INT8 для трех моделей.
  - рекомендованы два прототипа и минимальная матрица CPU/ISA.
- Проверка:
  - git diff --cached --check.
2026-08-30 20:56:21 +03:00
4 changed files with 528 additions and 12 deletions

No files matched your search

+2 -1
View File
@@ -353,7 +353,8 @@ faster-whisper её не поддерживает, и запуск с `--device
| `parakeet-v3` | ~600 MB | 7,6×* | 25 языков | ✅ | | `parakeet-v3` | ~600 MB | 7,6×* | 25 языков | ✅ |
\* Наблюдение на AMD Ryzen 7 8845H, Windows, `int8`, три записи общей \* Наблюдение на AMD Ryzen 7 8845H, Windows, `int8`, три записи общей
длительностью 43:37. Это не приёмочный замер для целевого Intel Core i5. длительностью 43:37. Это результат на одной из доступных тестовых машин, а не
прогноз производительности на других CPU.
Методика и качественное сравнение: Методика и качественное сравнение:
[benchmark GigaAM и Whisper](docs/benchmarks/2026-08-11-gigaam-model-comparison.md). [benchmark GigaAM и Whisper](docs/benchmarks/2026-08-11-gigaam-model-comparison.md).
+24 -11
View File
@@ -13,7 +13,12 @@
**Промежуточный статус 2026-08-11:** короткая матрица на трёх записях завершена **Промежуточный статус 2026-08-11:** короткая матрица на трёх записях завершена
и описана в [benchmark GigaAM и Whisper](benchmarks/2026-08-11-gigaam-model-comparison.md). и описана в [benchmark GigaAM и Whisper](benchmarks/2026-08-11-gigaam-model-comparison.md).
Multilingual Large добавлена как явный качественный профиль; решение о default Multilingual Large добавлена как явный качественный профиль; решение о default
по-прежнему ждёт целевого Intel Core i5 и длинных негативных примеров. по-прежнему ждёт длинных негативных примеров и проверки производительности на
нескольких доступных классах CPU.
Под CPU-дефолтом в этом разделе понимается программный профиль: backend, модель
и `compute_type`, выбранные без явных аргументов пользователя. Целевого
процессора и обязательного эталонного SKU у проекта нет.
**Проблема:** текущий CPU-путь через OpenVINO Whisper medium нестабилен на **Проблема:** текущий CPU-путь через OpenVINO Whisper medium нестабилен на
длинных записях с тихими участками: модель генерирует правдоподобные повторы и длинных записях с тихими участками: модель генерирует правдоподобные повторы и
@@ -68,10 +73,15 @@ Multilingual Large добавлена как явный качественный
**Матрица производительности:** **Матрица производительности:**
- обязательный прогон на реальном Intel Core i5 11-го поколения; точный SKU, - использовать минимум два доступных x86-ориентира: консервативный baseline на
число ядер, объём RAM, power mode и число потоков записать вместе с результатом; Intel Core i7-6820HQ и современный baseline на AMD Ryzen 7 8845H;
- Ryzen 7 8845H разработчика и ограничение числа потоков использовать только для - для каждого прогона записывать точный SKU, физические ядра и логические
предварительного smoke-теста, не как приёмку производительности i5; потоки, CPUID/ISA, объём RAM, power mode и фактическое число потоков;
- Ryzen использовать как основную машину для итераций и как современный
контролируемый baseline, а не переносить его результат на остальные CPU;
- при доступности отдельно собирать результаты на других x86-ноутбуках и Apple
Silicon как данные о переносимости. Такие внешние прогоны не заменяют
контролируемую матрицу;
- измерять отдельно cold start/загрузку модели и warm transcription, медиану - измерять отдельно cold start/загрузку модели и warm transcription, медиану
трёх прогонов, RTFx, peak RSS и размер скачиваемой модели; трёх прогонов, RTFx, peak RSS и размер скачиваемой модели;
- короткий 15-минутный фрагмент нужен для итераций, полные записи 22–81 мин — - короткий 15-минутный фрагмент нужен для итераций, полные записи 22–81 мин —
@@ -92,8 +102,9 @@ Multilingual Large добавлена как явный качественный
**Если подтвердится бенчмарком:** **Если подтвердится бенчмарком:**
- Если E2E RNN-T сохраняет качество и приемлемую скорость на i5 — сделать его - Если E2E RNN-T сохраняет качество и приемлемую скорость на выбранной
русским CPU-дефолтом, OpenVINO оставить явной опцией. CPU-матрице - сделать его русским CPU-дефолтом, OpenVINO оставить явной
опцией.
- Если лучший вариант зависит от языка — выбрать language-aware default: - Если лучший вариант зависит от языка — выбрать language-aware default:
GigaAM v3 для русского, multilingual-модель для смешанной речи. GigaAM v3 для русского, multilingual-модель для смешанной речи.
- Если модели GigaAM проигрывают по пунктуации/смыслу — сохранить текущий - Если модели GigaAM проигрывают по пунктуации/смыслу — сохранить текущий
@@ -135,10 +146,12 @@ Multilingual Large добавлена как явный качественный
- Переключить русский CPU-дефолт на победителя сравнительного GigaAM-бенчмарка. - Переключить русский CPU-дефолт на победителя сравнительного GigaAM-бенчмарка.
- Предупреждать пользователя при `--device openvino-cpu` для файлов >30 мин. - Предупреждать пользователя при `--device openvino-cpu` для файлов >30 мин.
**Приоритет:** высокий — проблема затрагивает текущий OpenVINO CPU-путь, а **Приоритет:** высокий - проблема затрагивает текущий OpenVINO CPU-путь, а
целевая аудитория включает ноутбуки с Intel Core i5 11-го поколения. GigaAM v3 пользователи работают на ноутбуках разных поколений и производителей.
остаётся рабочей явной альтернативой для русского, но выбор безопасного Встречаются и машины уровня Intel Core i5 11-го поколения, но это пример
auto/default требует сравнительного бенчмарка. распространенной конфигурации, а не целевой SKU. GigaAM v3 остается рабочей
явной альтернативой для русского, но выбор безопасного auto/default требует
сравнительного бенчмарка.
--- ---
@@ -0,0 +1,221 @@
# Ускорение извлечения голосовых эмбеддингов на x86 CPU
**Дата:** 2026-08-30
**Статус:** исследовательская записка. Production-код и конфигурация не
менялись.
**Метод:** проверены `sherpa-onnx==1.13.5`, `onnxruntime==1.28.0`, официальные
скрипты экспорта и исходники владельцев моделей. Три официальных release asset
прочитаны потоком без сохранения на диск: WeSpeaker ResNet34 LM, CAMPPlus en и
TitaNet small. Их входы, выходы и метаданные разобраны через ORT 1.28.0; для
каждого выполнен CPU smoke-test с batch size 2.
## Вопрос и краткий ответ
Текущий путь не использует batch: sherpa-onnx последовательно создает stream и
вызывает один `Session::Run` для каждой пары "окно, локальный говорящий".
Публичный `SpeakerEmbeddingExtractor` также принимает один stream за вызов.
Из трех направлений достойны прототипа два:
1. статическая INT8-квантизация действующего WeSpeaker в формате S8S8 QDQ;
2. несколько независимых stream поверх **одной** ORT session с ограниченным
общим числом потоков.
Настоящий batch технически лучше всего подготовлен у TitaNet: граф принимает
вектор длин. Но TitaNet пока допустим только при явно известном числе говорящих,
а sherpa-onnx все равно фиксирует batch size 1. Поэтому batch TitaNet -
условный следующий прототип; действующую конфигурацию он не ускоряет.
Несколько отдельных session не стоит прототипировать первым: это не уменьшает
работу, создает отдельные пулы потоков и арены памяти, повышает риск
oversubscription и
роста RSS. Для WeSpeaker и CAMPPlus batch с простым дополнением также не готов:
их графы не принимают длины или маску.
## Что именно выполняется последовательно
Приложение создает один `OfflineSpeakerDiarization` и задает одно и то же
`num_threads` отдельно для segmentation и embedding
([адаптер проекта](../../src/local_transcriber/speaker_diarizer.py)). Внутри
sherpa-onnx стадии идут строго `segmentation → embeddings → clustering`, а
`ComputeEmbeddings` проходит список пар обычным циклом: создает stream,
добавляет все неперекрывающиеся фрагменты одного локального говорящего,
завершает input и синхронно вызывает `Compute`
([реализация Pyannote в sherpa-onnx 1.13.5](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/csrc/offline-speaker-diarization-pyannote-impl.h)).
Публичный C++ и Python API экспортирует `create_stream`, `is_ready` и
`compute(stream)`. Метода `compute_batch(streams)` нет
([C++ API](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/csrc/speaker-embedding-extractor.h),
[Python binding](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/python/csrc/speaker-embedding-extractor.cc)).
Python binding освобождает GIL на `compute`, поэтому внешний параллельный вызов
в принципе возможен; готовый диаризатор промежуточные stream наружу не отдает.
Для embedding создается одна ORT session. Sherpa-onnx передает заданное число и
в `intra_op_num_threads`, и в `inter_op_num_threads`
([настройка session](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/csrc/session.cc)).
ORT по умолчанию исполняет граф последовательно; `inter_op` начинает
распараллеливать узлы только при `ORT_PARALLEL`, тогда как `intra_op`
распараллеливает оператор
([официальная документация ORT](https://onnxruntime.ai/docs/performance/tune-performance/threading.html)).
Значит, в текущем CPU-пути основная ручка - `intra_op`.
## Контракты трех графов
| Модель | Вход и выход официального экспорта | Следствие для batch |
|---|---|---|
| WeSpeaker ResNet34 LM | Release asset имеет один `float32`-вход `[B, T, 80]` и выход `[B, 256]` ([asset](https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-recongition-models/wespeaker_en_voxceleb_resnet34_LM.onnx)). Скрипт, которым sherpa-onnx добавляет метаданные, проверяет символические `B`, `T`, размерность 80 и единственный выход. Метаданные включают `framework=wespeaker`, `sample_rate`, `output_dim`, `normalize_samples=0` ([проверка sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/scripts/wespeaker/add_meta_data.py), [экспорт WeSpeaker](https://github.com/wenet-e2e/wespeaker/blob/master/wespeaker/bin/export_onnx.py)). | Равные `T` можно сложить по `B`. Длины или маску граф не принимает; дополнение участвует в pooling и меняет эмбеддинг. |
| CAMPPlus en | Release asset имеет `float32`-вход `[N, T, 80]` и выход с динамической первой осью `[*, 512]` ([asset](https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-recongition-models/3dspeaker_speech_campplus_sv_en_voxceleb_16k.onnx)). Метаданные: `framework=3d-speaker`, `output_dim=512`, `normalize_samples=1`, `feature_normalize_type=global-mean` ([экспорт sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/scripts/3dspeaker/export-onnx.py)). | Несмотря на опечатку экспортного скрипта (`embedding`/`embeddings`), release asset принимает batch равной длины и возвращает все строки. Для разных длин граф по-прежнему не принимает маску или длины. |
| NeMo TitaNet small | Release asset имеет входы `float32 [N, 80, T]` и `int64 [N]`; выходы — logits `[N, 16681]` и embedding `[N, 192]` ([asset](https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-recongition-models/nemo_en_titanet_small.onnx), [контракт C++-обертки](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/csrc/speaker-embedding-extractor-nemo-model.h)). Метаданные содержат `framework=nemo`, `output_dim`, `feat_dim`, sample rate, параметры окна и нормализации ([экспорт](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/scripts/nemo/speaker-verification/export-onnx.py)). | Граф принимает batch и вектор длин. Но sherpa-onnx создает обе входные формы с `N=1` и возвращает только первую строку ([реализация](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/csrc/speaker-embedding-extractor-nemo-impl.h)). |
Smoke-test с синтетическими `float32`-признаками и одним intra-op thread дал
для WeSpeaker `(2, 256)`, для CAMPPlus `(2, 512)` и для TitaNet `(2, 192)`.
У WeSpeaker и CAMPPlus результат batch size 2 совпал с конкатенацией двух
последовательных `Run` (`max_abs_diff=0`). У TitaNet элемент длиной 100 кадров
также совпал, но элемент длиной 80, дополненный до 100 кадров, отличался от
отдельного непаддированного вызова (`max_abs_diff=0,0269`). Это не тест качества
на голосе, а подтверждение, что допустимый порог для variable-length batch нужно
определить в прототипе, а не считать эквивалентность гарантированной контрактом.
WeSpeaker ResNet34 состоит из Conv2d-блоков и statistics pooling
([исходник WeSpeaker](https://github.com/wenet-e2e/wespeaker/blob/master/wespeaker/models/resnet.py));
CAMPPlus сочетает Conv2d, TDNN/Conv1d и statistics pooling
([исходник 3D-Speaker](https://github.com/modelscope/3D-Speaker/blob/main/speakerlab/models/campplus/DTDNN.py));
TitaNet использует depth-wise separable Conv1d, squeeze-and-excitation и
attention statistics pooling
([документация NeMo](https://docs.nvidia.com/nemo-framework/user-guide/25.02/nemotoolkit/asr/speaker_recognition/models.html)).
Этого недостаточно, чтобы утверждать: каждый оператор будет квантизован и
ускорен.
В [официальном выпуске speaker-recognition models](https://github.com/k2-fsa/sherpa-onnx/releases/tag/speaker-recongition-models)
есть только FP32-файлы WeSpeaker, CAMPPlus и TitaNet; готовых INT8 embedding
assets нет. Статический INT8 здесь означает собственный производный артефакт с
отдельной проверкой лицензии, метаданных и качества.
## Оценка способов ускорения
| Способ | Техническая доступность | Вердикт |
|---|---|---|
| Настоящий batch | Release-графы WeSpeaker и CAMPPlus поддерживают batch равной длины; TitaNet принимает batch и второй вход с длинами. Sherpa-onnx для всех трех жестко формирует `N=1` и не имеет batch API. | Прототипировать только TitaNet и только условно для режима с известным числом говорящих. Для WeSpeaker/CAMPPlus без маски возможны лишь группы с одинаковым `T`; ожидаемая наполняемость неизвестна. |
| Несколько stream, одна session | Stream независимы, `Compute` использует локальные тензоры, а ORT разрешает нескольким потокам одновременно вызывать `Run` одной session ([архитектура ORT](https://onnxruntime.ai/docs/reference/high-level-design.html), [контракт 1.28.0](https://github.com/microsoft/onnxruntime/blob/v1.28.0/onnxruntime/core/session/inference_session.h)). Текущий цикл sherpa-onnx этого не делает. | Достойно узкого прототипа на WeSpeaker. У одной session общий intra-op pool; нужно сравнить внутренний и внешний параллелизм. Полный pool для каждого worker не нужен. |
| Несколько session | Можно создать несколько extractor, но по умолчанию каждая session получает собственный intra-op pool и CPU arena. ORT отдельно предлагает общий pool против конкуренции session pools и общий allocator против роста памяти ([thread management](https://onnxruntime.ai/docs/performance/tune-performance/threading.html), [C API guide](https://onnxruntime.ai/docs/get-started/with-c.html#share-allocator-s-between-sessions)). | Не брать как самостоятельный кандидат. Допустима одна контрольная ячейка против shared-session, чтобы подтвердить отказ по реальному времени и RSS. |
| Статический INT8 | ORT предоставляет `quantize_static`, использует calibration data и рекомендует static quantization для CNN. Для CPU первым выбором служит S8S8 QDQ; квантизация может ухудшить точность и даже скорость ([официальное руководство](https://onnxruntime.ai/docs/performance/model-optimizations/quantization.html)). | Главный кандидат для действующего WeSpeaker. CAMPPlus и TitaNet квантизовать только после продуктового решения использовать соответствующую модель. |
Batch сокращает число `Session::Run` и может лучше загрузить SIMD, но число
эмбеддингов и основная модельная арифметика сохраняются; padding может даже
добавить работу. Несколько stream или session лишь перекрывают независимые
вызовы. Только INT8 меняет арифметику и объем движения весов. Увеличение шага
окна или отказ от части эмбеддингов действительно сокращают работу. Их нужно
рассматривать в отдельном исследовании шага окна.
## ISA, oversubscription и память
AVX2, VNNI и AVX-512 служат уровнями runtime dispatch одного формата модели.
MLAS в ORT 1.28.0 проверяет CPUID во время запуска и выбирает отдельные AVX2,
AVX-VNNI, AVX-512 и
AVX512-VNNI kernels
([runtime dispatch](https://github.com/microsoft/onnxruntime/blob/v1.28.0/onnxruntime/core/mlas/lib/platform.cpp)).
Один QDQ-граф поэтому нужно измерять на разных CPU. Отдельная сборка под каждый
из них не нужна.
Официальное руководство ORT предупреждает:
- S8S8 QDQ остается первым выбором CPU. Если он дает значимую потерю
точности, ORT советует проверить U8U8;
- отдельный путь U8S8 на AVX2 и AVX-512 без VNNI использует `VPMADDUBSW` и
может дать saturation; если до него дойдет матрица, этот риск снижают
`reduce_range` или переход на U8U8;
- на x86 с VNNI этой проблемы нет, а INT8 обычно выигрывает больше;
- на старом CPU без подходящих инструкций quantize/dequantize overhead способен
сделать модель медленнее
([разделы Data type selection и FAQ](https://onnxruntime.ai/docs/performance/model-optimizations/quantization.html)).
Доступный Core i7-6820HQ официально заявляет только SSE4.1/4.2 и AVX2
([Intel ARK](https://www.intel.com/content/www/us/en/products/sku/88970/intel-core-i76820hq-processor-8m-cache-up-to-3-60-ghz/specifications.html)).
Современный класс должен выбираться по фактическим CPUID-флагам. Надписи
"11-е поколение" недостаточно: минимум нужен `avx512_vnni` или `avx_vnni`. Для доступного
Zen 4 AMD документирует AVX-512 и AVX512_VNNI как путь ускоренного integer GEMM
([AMD AOCL](https://docs.amd.com/r/en-US/68552-AOCL-api-guide/Hardware-Utilization)).
В параллельном прототипе нужно измерять RSS. Уже последовательный тест показал,
что TitaNet требует примерно на 147–149 МиБ больше RSS, чем WeSpeaker, а FP32
WeSpeaker на старом Intel использовал в среднем 6,84 логического ядра из восьми
([локальный benchmark](../benchmarks/2026-08-30-lightweight-diarization-cpu.md)).
Конкурентные `Run` одной session делят общий intra-op pool, но все равно
конкурируют за его очереди и добавляют вызывающие потоки. Несколько session уже
создают отдельные пулы и дополнительно размножают арены и подготовленные веса.
Точный рост не следует считать линейным - его нужно измерить.
## Что остается неизвестным
- Реальный процент Conv/MatMul, который ORT сможет перевести в INT8, и выигрыш
каждого графа на двух ISA-классах.
- Сохранятся ли обязательные пользовательские метаданные после квантизации; без
`framework`, `output_dim` и параметров обработки признаков sherpa-onnx модель
не загрузит.
- Точность статического INT8 после калибровки и необходимость исключить отдельные
узлы через quantization debugging.
- Распределение `T` у пар "окно, локальный говорящий". Без него нельзя оценить
полезность exact-length buckets для WeSpeaker/CAMPPlus.
- Выигрыш shared-session concurrency после ограничения общего числа потоков,
его пиковый RSS и стабильность повторных конкурентных вызовов TitaNet.
## Рекомендация и минимальная граница прототипа
### 1. INT8 WeSpeaker - первый кандидат
На отдельном стенде, не меняя загрузчик проекта:
- выполнить `quant_pre_process`, затем `quantize_static` в S8S8 QDQ;
- проверить opset, вход/выход, все пользовательские метаданные и загрузку через
`SpeakerEmbeddingExtractor`;
- сравнить FP32 и INT8 по реальному времени embedding-стадии, процессорному
времени, пиковому RSS и
расстоянию между эмбеддингами;
- только после технического выигрыша повторить действующие проверки числа
голосовых кластеров, purity и слов без говорящего.
При значительной потере точности S8S8 проверить U8U8. Отдельный U8S8 с
`reduce_range` в первую матрицу не входит.
### 2. Shared-session streams - второй кандидат
Изолированный стенд или минимальная upstream-поправка должен сохранить одну
модель и одну ORT session, подготовить независимые stream и сравнить:
```text
outer workers / shared intra-op pool: 1 / P, 2 / P, P / 1
inter-op threads: 1
```
Здесь `P` - число физических ядер. Первая ячейка показывает текущий внутренний
параллелизм, вторая проверяет конкурентные `Run` на общем pool, третья переносит
параллелизм наружу и не создает intra-op pool. Измерять нужно полный набор
независимых embedding-вызовов. Latency одного удачного stream для решения
недостаточно. Отдельные session оставить одной отрицательной контрольной ячейкой
`P workers / 1 thread`.
### 3. Batch TitaNet - условный кандидат
Если отдельное решение разрешит TitaNet для режима с известным числом
говорящих, прямой ORT-стенд должен сначала доказать на размерах batch `1 / 4`,
что batch переменной длины с дополнением и `x_lens` дает те же эмбеддинги в
заданном допуске, что и последовательные вызовы. Затем измерить пропускную
способность и RSS. До этого менять
sherpa-onnx API ради TitaNet преждевременно. CAMPPlus и batch без mask в эту
границу не входят.
Минимальная доступная CPU-матрица для обоих непосредственных кандидатов:
| Класс | Конкретный доступный ориентир | Зачем |
|---|---|---|
| Консервативный x86 baseline: AVX2, без VNNI/AVX-512 | Intel Core i7-6820HQ, 4C/8T | Проверить нижнюю границу INT8 и конкуренцию на старом CPU. |
| Современный x86 baseline: VNNI + AVX-512 | AMD Ryzen 7 8845H из предыдущих замеров, с обязательной фиксацией `lscpu`/CPUID | Проверить специализированный INT8 kernel и масштабирование на 8C/16T. |
Эти две машины доступны для воспроизводимых прогонов; ни одна из них не является
целевым SKU проекта. Ноутбук с Intel Core i5 11-го поколения - лишь пример
распространенного пользовательского класса и не обязательная приемочная машина.
Для первого отбора кандидата двух ориентиров достаточно; на любой дополнительной
машине нужно записывать точную модель и CPUID. Переносить полученный коэффициент
ускорения на весь x86 нельзя.
@@ -0,0 +1,281 @@
# Оценка числа говорящих для быстрых эмбеддингов
**Дата:** 2026-08-30
**Статус:** исследовательская записка. Production-код и конфигурация не
менялись.
## Вопрос
Как отделить оценку числа говорящих от порога `FastClustering`, чтобы
использовать быстрые CAMPPlus или TitaNet и не запускать сегментацию и
извлечение эмбеддингов второй раз?
## Краткий вывод
Нужна двухшаговая схема: один раз получить ровно те эмбеддинги, которые
подготовил конвейер sherpa-onnx, отдельно оценить по ним число голосовых
кластеров `N`, затем передать `N` штатному `FastClustering`. Для первого
прототипа рекомендован **только счетчик NME**, без полной spectral clustering:
ядро алгоритма из статьи реализовать на уже доступном NumPy, а итоговые метки
по-прежнему получить через `FastClustering(num_clusters=N)`. Простой eigengap с
фиксированным прореживанием нужен как дешевый контрольный вариант.
Публичного промежуточного интерфейса для этого в sherpa-onnx 1.13.5 нет.
`SpeakerEmbeddingExtractor` и `FastClustering` публичны, но
`OfflineSpeakerDiarization.process()` возвращает только готовые интервалы.
Матрица эмбеддингов и соответствие строк локальным говорящим остаются внутри
реализации. После `set_config()` повторный `process()` снова выполнит модельный
инференс. Python-адаптер проекта сам по себе не может дать дешевую повторную
кластеризацию; будущему прототипу нужен узкий исследовательский интерфейс на
уровне sherpa-onnx.
## Что подтверждено про sherpa-onnx
### `FastClustering` уже умеет второй шаг
В версии 1.13.5 алгоритм нормирует строки, строит все попарные cosine
dissimilarity, выполняет complete-linkage AHC и затем выбирает один из двух
способов разрезать одно и то же дерево:
- при `num_clusters > 0` - ровно на заданное число кластеров;
- иначе - по высоте `threshold`.
Это видно непосредственно в
[`fast-clustering.cc`](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/csrc/fast-clustering.cc)
и закреплено в комментарии к
[`FastClusteringConfig`](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/csrc/fast-clustering-config.h):
при известном числе кластеров порог игнорируется. Python binding публично
принимает C-contiguous матрицу `float32` размера `M x D` и возвращает метку для
каждой строки
([исходник binding](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/python/csrc/fast-clustering.cc)).
Значит, передавать оцененное `N` в существующий кластеризатор не требует новой
реализации распределения эмбеддингов по кластерам.
Порог `FastClustering` не принадлежит модели эмбеддингов. Это высота
complete-linkage дерева в шкале cosine dissimilarity. Официальный пример
CAMPPlus для speaker verification лишь вычисляет cosine score и не задает
универсальной границы решения
([`infer_sv.py`](https://github.com/modelscope/3D-Speaker/blob/065629c313eaf1a01c65c640c46d77e61e9607b4/speakerlab/bin/infer_sv.py)).
Официальный diarization-рецепт 3D-Speaker отдельно задает `pval=0.012` для
spectral clustering и `mer_cos=0.8` для последующего слияния центров
([`infer_diarization.py`](https://github.com/modelscope/3D-Speaker/blob/065629c313eaf1a01c65c640c46d77e61e9607b4/speakerlab/bin/infer_diarization.py)).
Эти величины имеют другой смысл и не могут быть перенесены в sherpa как
`порог модели`.
### Публичных деталей недостаточно для повтора всего конвейера
Публичный `SpeakerEmbeddingExtractor` позволяет создать stream, подать в него
аудио и получить один embedding
([Python binding](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/python/csrc/speaker-embedding-extractor.cc),
[C/C++ API](https://k2-fsa.github.io/sherpa/onnx/c-api/html/speaker_embedding.html)).
Это пригодно для самостоятельно выбранного фрагмента, но не выдает материал,
который выбрал diarization-конвейер.
Внутри `OfflineSpeakerDiarization` после сегментации:
1. из перекрывающихся окон строятся пары `окно x локальный говорящий`;
2. речь с одновременными голосами исключается;
3. для каждой пары собирается свой stream и вычисляется embedding;
4. вся матрица передается в `FastClustering`;
5. метки строк переводятся обратно в разметку говорящих.
Последовательность и временная матрица видны в
[`OfflineSpeakerDiarizationPyannoteImpl::Process`](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/csrc/offline-speaker-diarization-pyannote-impl.h#L87-L204),
а сборка строк - в
[`GetChunkSpeakerSampleIndexes` и `ComputeEmbeddings`](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/csrc/offline-speaker-diarization-pyannote-impl.h#L376-L528).
Это private-методы реализации.
Python-класс `OfflineSpeakerDiarization` экспортирует только `sample_rate`,
`set_config` и `process`; `process` возвращает готовый результат, а не признаки
([binding 1.13.5](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/python/csrc/offline-speaker-diarization.cc#L73-L128)).
Внутренний `SetConfig` заменяет только объект кластеризации, тогда как каждый
`Process` заново вызывает segmentation и `ComputeEmbeddings`
([реализация](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/csrc/offline-speaker-diarization-pyannote-impl.h#L79-L175)).
Актуальная публичная C API также документирует только создание, настройку и
получение итоговых сегментов
([официальная страница](https://k2-fsa.github.io/sherpa/onnx/c-api/html/speaker_diarization.html)).
Итог: публичный API позволяет **кластеризовать имеющуюся матрицу повторно**.
Извлечь или переиграть промежуточную матрицу штатного diarization-конвейера
через него нельзя.
## Варианты оценки `N`
### 1. Отдельный порог для каждой embedding-модели
Это текущий `FastClustering` в автоматическом режиме. Он дешев, не добавляет
зависимостей и на одном проходе сразу оценивает число кластеров и назначает
метки. На порог влияют архитектура модели эмбеддингов, длительность и состав
выбранных фрагментов, акустика и распределение голосов. Локальная калибровка
уже не нашла общего рабочего порога для CAMPPlus,
а TitaNet не прошел автоматическую сетку
([исходное исследование](2026-08-14-diarization-cpu-cost.md),
[повторный CPU-тест](../benchmarks/2026-08-30-lightweight-diarization-cpu.md)).
Вводить еще один `официальный порог модели` оснований нет: upstream-рецепты
публикуют параметры конкретного clustering pipeline, а не переносимую границу
для complete-linkage sherpa. Вариант остается контрольным, но не решает тикет.
### 2. Spectral clustering с простым eigengap
Официальный CAMPPlus-конвейер 3D-Speaker строит cosine affinity, оставляет в
каждой строке фиксированную долю ближайших соседей, симметризует матрицу,
вычисляет ненормированный Laplacian и несколько наименьших собственных пар.
Число говорящих - позиция максимального eigengap в заданных пределах, после
чего собственные векторы кластеризуются k-means
([`SpectralCluster`](https://github.com/modelscope/3D-Speaker/blob/065629c313eaf1a01c65c640c46d77e61e9607b4/speakerlab/process/cluster.py#L21-L106)).
Для нашей двухшаговой схемы нужны только собственные значения и оценка `N`;
k-means можно не выполнять. Это одна spectral decomposition и потому хороший
минимальный контрольный вариант. Однако фиксированный `pval` остается новым
порогом.
Официальная реализация задает его для своего способа нарезки; пары
`окно x локальный говорящий` sherpa устроены иначе. Вариант отделяет `N` от
порога AHC, но сохраняет калибруемый параметр.
Если брать реализацию 3D-Speaker как зависимость, она импортирует NumPy, SciPy,
scikit-learn, fastcluster, UMAP и HDBSCAN, причем последние два обязательны уже
при импорте модуля
([исходник](https://github.com/modelscope/3D-Speaker/blob/065629c313eaf1a01c65c640c46d77e61e9607b4/speakerlab/process/cluster.py#L3-L18),
[requirements](https://github.com/modelscope/3D-Speaker/blob/065629c313eaf1a01c65c640c46d77e61e9607b4/requirements.txt)).
Для production это несоразмерно счетчику.
### 3. NME-SC
NME-SC создан именно для совместной оценки числа кластеров и параметра
прореживания без dev-set. Для каждого числа соседей `p` алгоритм бинаризует
cosine affinity, симметризует ее, строит ненормированный Laplacian и считает
eigengap. Нормированный gap равен максимальному gap, деленному на наибольшее
собственное значение; выбирается `p`, минимизирующий отношение `p / gap`, а `N`
задает позиция максимального gap
([статья авторов, раздел III](https://arxiv.org/abs/2003.02405)).
Авторы ограничивали поиск `p` диапазоном до четверти числа фрагментов и число
говорящих - восемью; результаты статьи получены на x-vector и англоязычных
корпусах, поэтому перенос на наши эмбеддинги еще предстоит проверить.
Поддерживаемая реализация NeMo принимает готовые эмбеддинги, строит cosine
affinity и отдельно получает `est_num_of_spk`; известное число говорящих затем
подменяет эту оценку перед spectral clustering
([`offline_clustering.py`](https://github.com/NVIDIA-NeMo/Speech/blob/d47d7a3c306790637aff39c2d3f9eceb83ca8879/nemo/collections/asr/parts/utils/offline_clustering.py#L1087-L1161)).
Для ускорения NME она по умолчанию ограничивает матрицу анализа 512 строками,
перебирает разреженную сетку из 30 значений `p` и предупреждает, что меньше 20
может ухудшить оценку
([класс `NMESC`](https://github.com/NVIDIA-NeMo/Speech/blob/d47d7a3c306790637aff39c2d3f9eceb83ca8879/nemo/collections/asr/parts/utils/offline_clustering.py#L801-L958)).
Для коротких записей NeMo добавляет случайные anchor embeddings и повторяет
оценку. Это отдельное расширение за пределами минимального ядра статьи
([исходник](https://github.com/NVIDIA-NeMo/Speech/blob/d47d7a3c306790637aff39c2d3f9eceb83ca8879/nemo/collections/asr/parts/utils/offline_clustering.py#L536-L595)).
Полная NME-SC после оценки `N` еще раз раскладывает Laplacian, строит spectral
embeddings и запускает k-means. Для этого тикета такая замена итогового
кластеризатора не нужна: локальные тесты уже показывают полезный путь
`FastClustering` с известным числом говорящих. Нужна только функция
`embeddings -> N`.
### 4. Оценить `N`, затем передать известное число кластеров
Это не отдельный счетчик. Это рекомендуемая граница ответственности:
```text
segmentation -> embeddings +-> count-only NME -> N
+-> FastClustering(features, num_clusters=N)
-> штатная финализация sherpa
```
Плюсы:
- порог complete-linkage больше не определяет число голосовых кластеров;
- CAMPPlus и TitaNet используют один модельный проход;
- итоговое назначение меток остается в уже принятом FastClustering;
- на одной матрице можно независимо сравнить несколько счетчиков.
Минус один, но архитектурный: без нового промежуточного интерфейса текущий
`process()` не позволяет вставить счетчик между вычислением матрицы и
кластеризацией.
## Зависимости, лицензии и цена
Штатная установка NeMo требует среди прочего NumPy, scikit-learn и PyTorch
([`pyproject.toml`](https://github.com/NVIDIA-NeMo/Speech/blob/d47d7a3c306790637aff39c2d3f9eceb83ca8879/pyproject.toml)).
| Вариант | Новые runtime-зависимости | Дополнительная работа после эмбеддингов | Ограничение |
|---|---|---|---|
| Порог `FastClustering` | нет | уже существующие попарные расстояния и AHC | порог калибруется для модели и материала |
| Простой eigengap | SciPy в официальной реализации; либо только уже доступный NumPy | affinity `M x M`, одно собственное разложение | фиксированный параметр прореживания |
| Полная NME-SC NeMo | `nemo-toolkit`, PyTorch, scikit-learn и другие зависимости NeMo | до нескольких десятков eigenvalue-разложений, затем еще spectral embedding и k-means | самый тяжелый и меняет итоговый кластеризатор |
| Счетчик NME + FastClustering | только NumPy при чистой реализации ядра статьи | NME на ограниченной матрице, затем штатный AHC по известному `N` | нужен промежуточный интерфейс и локальная проверка переноса |
`FastClustering` уже хранит `M(M-1)/2` расстояний и строит AHC. Простой
eigengap добавляет еще одну плотную affinity-матрицу. NME повторяет eigenvalue-
анализ для набора `p`; ограничение размера и разреженный поиск контролируют
цену, но их влияние на качество нужно измерить. Полная spectral clustering
также хранит собственные векторы и запускает k-means, поэтому для одной оценки
`N` избыточна. В проектных замерах основной расход по-прежнему приходится на
извлечение эмбеддингов, а не на clustering
([профиль стоимости](2026-08-14-diarization-cpu-cost.md),
[CPU-тест](../benchmarks/2026-08-30-lightweight-diarization-cpu.md)); это не
заменяет отдельного замера NME на целевой матрице.
sherpa-onnx, 3D-Speaker и NeMo Speech распространяют код под Apache-2.0
([sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/LICENSE),
[3D-Speaker](https://github.com/modelscope/3D-Speaker/blob/065629c313eaf1a01c65c640c46d77e61e9607b4/LICENSE),
[NeMo Speech](https://github.com/NVIDIA-NeMo/Speech/blob/d47d7a3c306790637aff39c2d3f9eceb83ca8879/LICENSE)).
Авторская реализация NME-SC опубликована под MIT и сама направляет пользователей
к поддерживаемой версии NeMo
([репозиторий авторов](https://github.com/tango4j/Auto-Tuning-Spectral-Clustering/tree/ae97f9be9c33b554205c7192c5137ce8f456c1b7)).
Файл NeMo с clustering дополнительно сохраняет BSD-уведомление scikit-learn.
NumPy имеет BSD-3-Clause
([лицензия 2.4.3](https://github.com/numpy/numpy/blob/v2.4.3/LICENSE.txt)) и уже
присутствует в [lock-файле проекта](../../uv.lock). Лицензионного запрета на
прототип нет;
прямое заимствование кода потребует сохранить его уведомления. Новые модели
для счетчика не нужны.
## Ограничения вывода
- NME-SC проверялась авторами на другой нарезке и x-vector. Строки sherpa - это
локальные говорящие перекрывающихся Pyannote-окон после исключения overlap;
переносимость нельзя считать доказанной.
- Правильное `N` не гарантирует качественную разметку говорящих. Для TitaNet в
режиме известного числа кластеров уже наблюдался малый кластер, поэтому нужны
прежние proxy-метрики и слуховая проверка.
- У sherpa есть отдельный путь для единственного окна, который обходится без
общей матрицы эмбеддингов. Прототип не должен ломать этот special case.
- Python binding `FastClustering` нормирует переданную матрицу на месте;
сравниваемым вариантам нужно выдавать отдельные C-contiguous копии.
- Цена NME на числе строк, которое реально создает sherpa, здесь не измерялась.
## Точная граница будущего прототипа
Production-код, CLI, модели по умолчанию и ADR не менять. Сделать отдельный
исследовательский стенд на закрепленном sherpa-onnx 1.13.5:
1. Узко разделить внутренний `Process` на подготовку и финализацию либо добавить
исследовательский callback после `ComputeEmbeddings`. За один проход
сохранить в памяти матрицу `float32`, список валидных пар
`окно x локальный говорящий` и локальные frame labels; аудио и текст не
сериализовать.
2. На **одной и той же** матрице сравнить три счетчика:
текущий порог как контроль, простой eigengap с фиксированным прореживанием и
count-only NME. Ограничить `N` диапазоном `1..8`.
3. Минимальный NME реализовать по статье на NumPy: cosine affinity,
top-`p` binarization, симметризация, ненормированный Laplacian,
`numpy.linalg.eigvalsh`, выбор `p` и `N` по NME. Для первого прогона взять
детерминированное ограничение 512 строк и sparse search из 20 значений;
anchor embeddings, Torch, spectral embeddings и k-means не включать.
4. Для каждого оцененного `N` вызвать публичный `FastClustering` на копии той же
матрицы с `num_clusters=N`, затем выполнить существующую финализацию без
повторной сегментации или извлечения эмбеддингов.
5. На тех же трех контрольных фрагментах измерить: оцененное `N`, общее и
содержательное число кластеров, mapped speaker purity, слова без говорящего,
время счетчика/кластеризации и peak RSS. Прогон с CAMPPlus и TitaNet должен
завершиться слуховой проверкой.
6. Если простой eigengap и NME одинаково проходят проверку, выбрать более
дешевый eigengap. Если проходит только NME, отдельно решить, переносить ли
чистую NumPy-реализацию в production. Если оба ошибаются, быстрые embedding-
модели оставить только для явно заданного числа говорящих.
Этот прототип отвечает ровно на два неизвестных: можно ли стабильно оценить
`N` по внутренним эмбеддингам CAMPPlus/TitaNet и укладывается ли count-only NME
в приемлемую CPU-цену. Он не выбирает новую модель по умолчанию, не заменяет
FastClustering и не расширяет продуктовый контракт.