Skip to content

feat(pipeline): extract speaker embeddings with one model call per chunk - #2048

Open
YUNGC0DE wants to merge 1 commit into
pyannote:developfrom
YUNGC0DE:feat/shared-trunk-embeddings
Open

YUNGC0DE wants to merge 1 commit into
pyannote:developfrom
YUNGC0DE:feat/shared-trunk-embeddings

Conversation

@YUNGC0DE

Copy link
Copy Markdown

What

get_embeddings calls the embedding model once per (chunk, speaker) pair, feeding it
the same waveform num_speakers times. Masks only matter to the pooling layer, so
everything before it is computed num_speakers times and discarded num_speakers - 1
times.

This PR calls the model once per chunk with the masks of all its speakers at once, for
embedding models that support it. Output is unchanged.

How

  • BaseInference.supports_multi_speaker_masks (defaults to False) tells whether
    __call__ accepts (batch, speakers, frames)-shaped masks.
  • PyannoteAudioPretrainedSpeakerEmbedding answers it by running the model once on a
    dummy batch and checking the output shape, the same way min_num_samples probes the
    model. This covers models whose post-pooling layers are not agnostic to the extra
    dimension, without having to enumerate model classes.
  • get_embeddings picks _get_embeddings_per_chunk when supported and
    _get_embeddings_per_pair (the current code, moved as is) otherwise.
  • embedding_batch_size keeps counting (chunk, speaker) pairs, so peak memory does not
    depend on which implementation runs.
  • SpeechBrain, NeMo and ONNX WeSpeaker wrappers keep the current behaviour: their
    __call__ takes 2-dimensional masks, the probe returns False for them.

Results

The number of forward passes through the embedding model drops exactly
num_speakers-fold: 333 → 111 for the file below.

pyannote/speaker-diarization-community-1, this repo's sample.wav repeated 4× (120s),
preloaded as a waveform, pyannote.audio 4.0.7 + torch 2.13, Apple M3 laptop, medians
of 3 runs across several invocations:

device embeddings step whole pipeline output
cpu ×3.7–4.8 ×3.1–4.1 identical
mps ×2.6–2.8 ×2.4–2.7 identical

Tests

tests/test_speaker_embedding_masks.py builds a WeSpeakerResNet34 (no download) and
checks that one call with (batch, speakers, frames) masks returns what several
one-speaker calls return, plus that the capability probe answers True for it.

tests/test_speaker_embedding_masks.py ..                       [100%]
2 passed

test_stats_pool.py, test_clustering.py, test_sample.py and test_import_lib.py
also pass locally.

Masks are only used by the pooling layer of speaker embedding models: the layers
before it, which account for nearly all of the compute, do not depend on them.
`get_embeddings` however called the embedding model once per (chunk, speaker)
pair, feeding it the very same waveform `num_speakers` times.

Embedding models supporting (batch, speakers, frames)-shaped masks are now called
once per chunk, with the masks of all its speakers at once. `StatsPool` already
pools the very same sequence of features once per speaker, so the output is
unchanged. `embedding_batch_size` keeps counting (chunk, speaker) pairs, so peak
memory is unchanged as well.

Support is declared by `BaseInference.supports_multi_speaker_masks`, `False` by
default, and decided for pyannote.audio embedding models by running the model once
on a dummy batch, in the same spirit as `min_num_samples`. Pipelines relying on
SpeechBrain, NeMo or ONNX WeSpeaker embeddings keep the current code path.
@happyarts

Copy link
Copy Markdown

We arrived at exactly this optimization independently (#2050 / #2051, now closed in favor of this earlier PR) and can contribute an independent, corpus-level verification of its correctness claim.

Measured on VoxConverse dev (216 files, 20.3 h, community-1 pipeline, scored with pyannote.metrics at collar 0 with overlap), comparing the stock per-(chunk, speaker) grouping against the per-chunk grouping with stacked masks:

grouping DER full pipeline (M1 Max, MPS)
per (chunk, speaker) 7.18 % 47.1 min
per chunk, stacked masks 7.18 % — identical on every single file 21.4 min

Maximum absolute embedding difference 2.3e-6 (float noise from batch shape), minimum cosine similarity 0.9999998; final diarization is segment-identical with both automatic and constrained speaker counts. Since the change removes work rather than tuning a kernel, the relative win is largest on CPU, where the embedding step dominates the pipeline by a wide margin. It likely also explains the unexplained "embeddings are ~30x slower than manual extraction" report in #1614: 10x chunk overlap times 3 speaker slots.

Two details of this PR we can specifically endorse from our own attempt: probing supports_multi_speaker_masks empirically (one tiny forward, check the output shape) is more robust than the class-based gate we used — it automatically covers WeSpeaker variants with two_emb_layer=True, whose BatchNorm1d head rejects the (batch, speakers, dim) pooled tensor; and rearranging the per-batch hook artifact back to (batch*speakers, dimension) keeps the hook contract unchanged.

See also #1996, which contains the same core idea (splitting forward_frames from the pooling) bundled with further optimizations.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants