Files
local-transcriber/docs/research/2026-08-30-diarization-embedding-inference.md
T
Dmitry Dementiev 6420eb07ea docs(research): добавлено исследование embedding-инференса
- Зачем:
  - нужно выбрать реалистичный способ ускорения эмбеддингов на разных x86 CPU.
- Что:
  - зафиксированы ограничения batch, stream/session и static INT8 для трех моделей.
  - рекомендованы два прототипа и минимальная матрица CPU/ISA.
- Проверка:
  - git diff --cached --check.
2026-08-30 13:46:21 +03:00

21 KiB
Raw Blame History

Ускорение извлечения голосовых эмбеддингов на x86 CPU

Дата: 2026-08-30

Статус: исследовательская записка. Production-код и конфигурация не менялись.

Метод: проверены sherpa-onnx==1.13.5, onnxruntime==1.28.0, официальные скрипты экспорта и исходники владельцев моделей. ONNX-модели не скачивались. Формы ниже подтверждены экспортными контрактами, проверками метаданных и кодом загрузчика. Файлы из выпуска повторно не разбирались.

Вопрос и краткий ответ

Текущий путь не использует 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 (адаптер проекта). Внутри sherpa-onnx стадии идут строго segmentation → embeddings → clustering, а ComputeEmbeddings проходит список пар обычным циклом: создает stream, добавляет все неперекрывающиеся фрагменты одного локального говорящего, завершает input и синхронно вызывает Compute (реализация Pyannote в sherpa-onnx 1.13.5).

Публичный C++ и Python API экспортирует create_stream, is_ready и compute(stream). Метода compute_batch(streams) нет (C++ API, Python binding). Python binding освобождает GIL на compute, поэтому внешний параллельный вызов в принципе возможен; готовый диаризатор промежуточные stream наружу не отдает.

Для embedding создается одна ORT session. Sherpa-onnx передает заданное число и в intra_op_num_threads, и в inter_op_num_threads (настройка session). ORT по умолчанию исполняет граф последовательно; inter_op начинает распараллеливать узлы только при ORT_PARALLEL, тогда как intra_op распараллеливает оператор (официальная документация ORT). Значит, в текущем CPU-пути основная ручка - intra_op.

Контракты трех графов

Модель Вход и выход официального экспорта Следствие для batch
WeSpeaker ResNet34 LM Один float32-вход [B, T, 80], один выход [B, D]. Скрипт, которым sherpa-onnx добавляет метаданные, проверяет символические B, T, размерность 80 и единственный выход. Метаданные включают framework=wespeaker, sample_rate, output_dim, normalize_samples=0 (проверка sherpa-onnx, экспорт WeSpeaker). Равные T можно сложить по B. Длины или маску граф не принимает; дополнение участвует в pooling и меняет эмбеддинг.
CAMPPlus zh/en Экспорт создает float32-вход [N, T, 80], метаданные framework=3d-speaker, output_dim, normalize_samples=1, feature_normalize_type=global-mean (скрипт sherpa-onnx). В скрипте есть ошибка: выход называется embedding, а dynamic axis задан для embeddings. Поэтому динамичность оси выхода файла из выпуска по исходнику не доказана. Upstream подтверждает лишь, что batch равной длины должен работать; для разных длин нужна маска (ответ разработчика).
NeMo TitaNet small Два входа: float32 [N, C, T] и int64 [N] с длинами; embedding имеет форму [N, D] (контракт C++-обертки). Метаданные содержат framework=nemo, output_dim, feat_dim, sample rate, параметры окна и нормализации (экспорт). Граф имеет нужный контракт для batch переменной длины с дополнением. Но sherpa-onnx создает обе входные формы с N=1 и возвращает только первую строку (реализация).

WeSpeaker ResNet34 состоит из Conv2d-блоков и statistics pooling (исходник WeSpeaker); CAMPPlus сочетает Conv2d, TDNN/Conv1d и statistics pooling (исходник 3D-Speaker); TitaNet использует depth-wise separable Conv1d, squeeze-and-excitation и attention statistics pooling (документация NeMo). Этого недостаточно, чтобы утверждать: каждый оператор будет квантизован и ускорен.

В официальном выпуске speaker-recognition models есть только FP32-файлы WeSpeaker, CAMPPlus и TitaNet; готовых INT8 embedding assets нет. Статический INT8 здесь означает собственный производный артефакт с отдельной проверкой лицензии, метаданных и качества.

Оценка способов ускорения

Способ Техническая доступность Вердикт
Настоящий batch Граф WeSpeaker поддерживает равные длины; CAMPPlus требует прямой проверки файла из выпуска; TitaNet поддерживает разные длины через второй вход. Sherpa-onnx для всех трех жестко формирует N=1 и не имеет batch API. Прототипировать только TitaNet и только условно для режима с известным числом говорящих. Для WeSpeaker/CAMPPlus без маски возможны лишь группы с одинаковым T; ожидаемая наполняемость неизвестна.
Несколько stream, одна session Stream независимы, Compute использует локальные тензоры, а ORT разрешает нескольким потокам одновременно вызывать Run одной session (архитектура ORT, контракт 1.28.0). Текущий цикл sherpa-onnx этого не делает. Достойно узкого прототипа на WeSpeaker. У одной session общий intra-op pool; нужно сравнить внутренний и внешний параллелизм. Полный pool для каждого worker не нужен.
Несколько session Можно создать несколько extractor, но каждая session получает собственные пулы потоков и арены памяти. ORT прямо отмечает эти накладные расходы для двух session (ответ сопровождающего). Не брать как самостоятельный кандидат. Допустима одна контрольная ячейка против shared-session, чтобы подтвердить отказ по реальному времени и RSS.
Статический INT8 ORT предоставляет quantize_static, использует calibration data и рекомендует static quantization для CNN. Для CPU первым выбором служит S8S8 QDQ; квантизация может ухудшить точность и даже скорость (официальное руководство). Главный кандидат для действующего 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). Один QDQ-граф поэтому нужно измерять на разных CPU. Отдельная сборка под каждый из них не нужна.

Официальное руководство ORT предупреждает:

  • на AVX2 и AVX-512 без VNNI путь U8S8 использует VPMADDUBSW и может дать saturation; при потере точности предусмотрены reduce_range или U8U8;
  • на x86 с VNNI этой проблемы нет, а INT8 обычно выигрывает больше;
  • на старом CPU без подходящих инструкций quantize/dequantize overhead способен сделать модель медленнее (разделы Data type selection и FAQ).

Доступный Core i7-6820HQ официально заявляет только SSE4.1/4.2 и AVX2 (Intel ARK). Современный класс должен выбираться по фактическим CPUID-флагам. Надписи "11-е поколение" недостаточно: минимум нужен avx512_vnni или avx_vnni. Для доступного Zen 4 AMD документирует AVX-512 и AVX512_VNNI как путь ускоренного integer GEMM (AMD AOCL).

В параллельном прототипе нужно измерять RSS. Уже последовательный тест показал, что TitaNet требует примерно на 147–149 МиБ больше RSS, чем WeSpeaker, а FP32 WeSpeaker на старом Intel использовал в среднем 6,84 логического ядра из восьми (локальный benchmark). Конкурентные Run одной session делят общий intra-op pool, но все равно конкурируют за его очереди и добавляют вызывающие потоки. Несколько session уже создают отдельные пулы и дополнительно размножают арены и подготовленные веса. Точный рост не следует считать линейным - его нужно измерить.

Что остается неизвестным

  • Реальный процент Conv/MatMul, который ORT сможет перевести в INT8, и выигрыш каждого графа на двух ISA-классах.
  • Сохранятся ли обязательные пользовательские метаданные после квантизации; без framework, output_dim и параметров обработки признаков sherpa-onnx модель не загрузит.
  • Точность статического INT8 после калибровки и необходимость исключить отдельные узлы через quantization debugging.
  • Фактическая batch-форма выхода CAMPPlus release asset из-за ошибки в экспортном скрипте.
  • Распределение 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 и слов без говорящего.

U8U8 или reduce_range добавлять лишь при подтвержденной потере точности на AVX2. В первую матрицу эти варианты не входят.

2. Shared-session streams - второй кандидат

Изолированный стенд или минимальная upstream-поправка должен сохранить одну модель и одну ORT session, подготовить независимые stream и сравнить:

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-матрица для обоих непосредственных кандидатов:

Класс Конкретный доступный ориентир Зачем
AVX2, без VNNI/AVX-512 Intel Core i7-6820HQ, 4C/8T Проверить нижнюю границу INT8 и конкуренцию на старом CPU.
VNNI + AVX-512 AMD Ryzen 7 8845H из предыдущих замеров, с обязательной фиксацией lscpu/CPUID Проверить специализированный INT8 kernel и масштабирование на 8C/16T.

Фактический целевой Core i5 11-го поколения добавить как приемочную машину, когда он появится; перед запуском записать точную модель и CPUID. Двух машин достаточно, чтобы решить судьбу кандидата на двух ISA-классах. Переносить полученный коэффициент ускорения на весь x86 нельзя.