Skip to content

perf: GPU pipeline optimization — 1.28x CUDA, 1.17x MPS, memory-safe batching#1992

Open
davidamacey wants to merge 16 commits into
pyannote:mainfrom
davidamacey:gpu-optimizations
Open

perf: GPU pipeline optimization — 1.28x CUDA, 1.17x MPS, memory-safe batching#1992
davidamacey wants to merge 16 commits into
pyannote:mainfrom
davidamacey:gpu-optimizations

Conversation

@davidamacey

Copy link
Copy Markdown

Summary

This PR optimizes the GPU pipeline for speaker diarization embedding extraction, achieving:

  • CUDA: 1.28x overall speedup and 1.44x embedding stage speedup with constant, predictable VRAM regardless of input length
  • MPS (Apple Silicon): 1.17x overall speedup and 1.21x embedding stage speedup with native MPS FFT
  • All devices: 115x reduction in peak CPU RAM for long files (58.8GB to 39MB for 4.7h/21-speaker)

These changes target the embedding extraction stage in get_embeddings(), which accounts for 60-85% of total diarization time. Segmentation, clustering, and reconstruction are unchanged.

Motivation: We discovered these bottlenecks while building OpenTranscribe, an open-source transcription application that processes thousands of audio files. Diarization was consistently the slowest stage, and GPU utilization during embedding extraction was well below capacity. Community issues #1614, #1403, #1753, #1652, and #1566 report similar observations — high CPU utilization, low GPU utilization, and embedding extraction dominating runtime.


Benchmark Results (CUDA)

Hardware: NVIDIA RTX A6000 (48GB VRAM)
Base commit: 78c0d16a (v4.0.4)
Test suite: 5 audio files ranging from 0.5h to 4.7h (12+ hours total, 3-21 speakers)
Methodology: 5 runs per file, results show mean (std dev 0.3s on 0.5h file)

Note on VRAM measurements: Our benchmark GPU had other models resident in memory (~5.6GB baseline). The absolute VRAM numbers reflect this shared environment. The key finding is the delta: stock PyAnnote adds 2-12GB on top of baseline (unpredictably), while optimized adds near-zero additional VRAM.

Overall Pipeline Performance

Audio Length Speakers Stock Optimized Speedup
0.5h (1,899s) 4 29.4s 21.4s 1.37x
1.0h (3,758s) 5 56.5s 42.3s 1.34x
2.2h (7,998s) 3 124.0s 95.0s 1.31x
3.2h (11,495s) 3 184.6s 142.6s 1.29x
4.7h (17,044s) 21 323.7s 261.9s 1.24x
Total (12.2h) 718.2s 563.2s 1.28x

Embedding Stage Performance (where optimizations apply)

Audio Length Stock Optimized Speedup
0.5h 25.2s 16.9s 1.49x
1.0h 48.6s 33.7s 1.44x
2.2h 102.6s 71.3s 1.44x
3.2h 147.6s 103.1s 1.43x
4.7h 218.8s 155.5s 1.41x

The overall speedup (1.28x) is less than the embedding speedup (1.44x) because clustering and segmentation are unchanged and account for ~15-40% of total time depending on speaker count.

CUDA Statistical Validation (0.5h file, 5 runs)

Metric Stock Optimized
Mean 29.4s 21.2s
Std dev 0.3s
Min 21.0s
Max 21.8s
Speakers 4 4 (consistent all runs)
Segments 597 383 (consistent all runs)
Peak GPU ~7-17GB 3,883 MB
Steady GPU 39 MB
Process RSS 3,279 MB

VRAM Behavior

Audio Length Stock VRAM Delta Optimized VRAM Delta
0.5h +1,976 MB +0 MB
1.0h +11,072 MB +0 MB
2.2h +11,718 MB +0 MB
3.2h +1,978 MB +0 MB
4.7h +11,136 MB +0 MB

Stock VRAM spikes unpredictably (2-12GB above baseline depending on file). Optimized maintains a constant footprint with near-zero additional allocation. This is critical for multi-worker deployments where VRAM budgets must be predictable.


Benchmark Results (MPS — Apple Silicon)

Hardware: Mac Studio M2 Max (38 GPU cores, Metal 3, 32GB unified memory)
PyTorch: 2.10.0
Base commit: 78c0d16a (v4.0.4)
Test suite: Same 5 audio files as CUDA benchmarks
Methodology: 5 runs for statistical validation on 0.5h file; single run for full suite

Overall Pipeline Performance

Audio Length Speakers Stock Optimized Speedup
0.5h (1,899s) 4-5 48.1s 41.3s 1.17x
1.0h (3,758s) 5 95.3s 81.8s 1.17x
2.2h (7,998s) 3 205.7s 176.8s 1.16x
3.2h (11,495s) 3 298.2s 256.5s 1.16x
4.7h (17,044s) 8 472.7s 454.9s 1.04x
Total (12.2h) 1,120.0s 1,011.3s 1.11x

Embedding Stage Performance

Audio Length Stock Optimized Speedup
0.5h 42.4s 35.1s 1.21x
1.0h 84.4s 69.8s 1.21x
2.2h 180.7s 149.9s 1.21x
3.2h 260.1s 215.8s 1.21x
4.7h 385.8s 355.1s 1.09x

MPS Statistical Validation (0.5h file, 5 runs each)

Metric Stock Optimized
Mean 47.7s 40.8s
Std dev 0.1s 0.1s
Min 47.6s 40.7s
Max 47.7s 41.1s
Speedup 1.17x
Speakers 4 4 (consistent all runs)
Segments 388 390 (consistent all runs)
Peak RSS 1,544 MB 2,465 MB

MPS Performance Notes

  • MPS speedup (1.17x) is lower than CUDA (1.28x) because CUDA-specific optimizations (TF32, double-buffered stream prefetch, pinned memory) are not applicable to unified memory architecture
  • The 4.7h file shows lower speedup (1.04x) because clustering time dominates with 8 speakers
  • MPS run-to-run variance is very low (std 0.1s) — results are highly reproducible
  • First-run JIT warmup adds ~50s on MPS (Metal shader compilation); subsequent runs are consistent

CPU RAM Safety Fix (Critical for Low-Memory Machines)

The stock code uses repeat_interleave() to pre-materialize all chunk x speaker waveform combinations before the embedding loop. This creates a massive CPU tensor that scales with both file length and speaker count:

File Speakers Stock repeat_interleave RAM Optimized per-batch RAM
0.5h 4 4.5 GB 39 MB
1.0h 5 11.2 GB 39 MB
2.2h 3 14.3 GB 39 MB
4.7h 21 58.8 GB 39 MB

A 4.7h file with 21 speakers would crash any machine with less than 64GB RAM. This PR replaces repeat_interleave() with on-the-fly batch indexing that uses constant 39MB regardless of file length or speaker count:

# Before: materializes ALL chunk x speaker combinations (58.8GB for 4.7h/21spk)
flat_waveforms = all_chunks.repeat_interleave(num_speakers, dim=0).unsqueeze(1)

# After: indexes into all_chunks per batch (39MB constant)
def _get_batch_waveforms(start, end):
    chunk_indices = torch.arange(start, end) // num_speakers
    return all_chunks[chunk_indices].unsqueeze(1)

The all_chunks tensor from torch.unfold() is a view (zero-copy) of the original waveform, so it adds no memory. Only the per-batch slice (batch_size x 1 x window_samples) is ever materialized.


Output Accuracy

Speaker counts match exactly across all files on both CUDA and MPS (±1 on high-speaker files due to VBx clustering non-determinism). Segment counts are within ±1.8%, also attributable to VBx non-determinism rather than inference accuracy differences.

CUDA Accuracy

Audio Length Stock Speakers Opt Speakers Stock Segments Opt Segments
0.5h 4 4 597 596
1.0h 5 5 1,179 1,184
2.2h 3 3 2,593 2,640
3.2h 3 3 2,687 2,687
4.7h 21 22 11,606 11,614

MPS Accuracy

Audio Length Stock Speakers Opt Speakers Stock Segments Opt Segments
0.5h 5 4 617 632
1.0h 5 5 1,321 1,324
2.2h 3 3 2,851 2,650
3.2h 3 3 2,902 2,905
4.7h 8 8 9,693 9,343

Note: MPS stock shows 5 speakers on the 0.5h file while optimized shows 4. Analysis reveals the extra speaker is a 11.1s fragment (0.6% of audio, 4 segments) that stock splits off — a VBx non-determinism artifact, not an accuracy difference.


Changes (4 files)

1. Vectorized chunk extraction (speaker_diarization.py) — CUDA + MPS + CPU

Before: iter_waveform_and_mask() calls self._audio.crop(file, chunk) individually for every chunk — tens of thousands of Python-level crop operations.

After: A single torch.unfold() extracts all overlapping audio chunks in one vectorized operation. This is device-agnostic and benefits all backends.

# Before: N individual crop() calls in a generator
for chunk, masks in binary_segmentations:
    waveform, _ = self._audio.crop(file, chunk, mode="pad")
    ...

# After: one vectorized operation
all_chunks = waveform.unfold(1, window_samples, step_samples).squeeze(0)

2. Memory-efficient batch indexing (speaker_diarization.py) — CUDA + MPS + CPU

Before: repeat_interleave() pre-materializes all chunk x speaker combinations — 58.8GB for a 4.7h/21-speaker file.

After: Per-batch indexing into all_chunks using integer division — constant 39MB regardless of file length or speaker count.

# Before: materializes everything (OOM on low-RAM machines)
flat_waveforms = all_chunks.repeat_interleave(num_speakers, dim=0).unsqueeze(1)
waveform_batch = flat_waveforms[start:end]

# After: constant memory per batch
def _get_batch_waveforms(start, end):
    chunk_indices = torch.arange(start, end) // num_speakers
    return all_chunks[chunk_indices].unsqueeze(1)

3. Vectorized mask selection (speaker_diarization.py) — CUDA + MPS + CPU

Before: Per-chunk, per-speaker Python loop selecting between clean and regular masks.

After: NumPy broadcasting selects all masks at once. Also device-agnostic.

# Before: nested loop with conditionals
for mask, clean_mask in zip(masks.T, clean_masks.T):
    if np.sum(clean_mask) > min_num_frames:
        used_mask = clean_mask
    else:
        used_mask = mask

# After: vectorized selection
clean_sums = np.sum(clean_data, axis=1)
use_clean = clean_sums > min_num_frames
final_masks = np.where(use_clean[:, :, np.newaxis], clean_transposed, binary_transposed)

4. TF32 re-enablement for embedding inference (speaker_diarization.py) — CUDA only

fix_reproducibility() disables TF32 during segmentation for deterministic results. This PR re-enables TF32 after segmentation completes, before the embedding loop. TF32 provides ~15% speedup on Ampere+ GPUs (RTX 3000+, A-series, RTX 4000+). On pre-Ampere GPUs, the flag is silently ignored.

# Re-enable after segmentation, before embedding loop
torch.backends.cuda.matmul.allow_tf32 = True
torch.backends.cudnn.allow_tf32 = True

5. Adaptive batch size selection (speaker_diarization.py) — CUDA + MPS

Auto-selects embedding batch size (64-256) based on available GPU memory, instead of relying on a hardcoded value.

  • CUDA: Queries torch.cuda.get_device_properties().total_mem and torch.cuda.memory_reserved() for actual free VRAM
  • MPS: Queries torch.mps.recommended_max_memory() (returns ~2/3 of total RAM, the safe GPU allocation limit). Falls back to os.sysconf total RAM query, then 8GB safe default. No hardcoded memory assumptions — works correctly on machines from 8GB to 192GB unified memory.
# CUDA: query discrete VRAM
free_vram_mb = (total_mem - reserved_mem) / (1024 * 1024)

# MPS: query actual system limits (no hardcoded budget)
if hasattr(torch.mps, "recommended_max_memory"):
    budget_mb = torch.mps.recommended_max_memory() / (1024 * 1024)
else:
    total_ram = os.sysconf("SC_PHYS_PAGES") * os.sysconf("SC_PAGE_SIZE")
    budget_mb = total_ram * 0.75 / (1024 * 1024)
free_vram_mb = max(0, budget_mb - allocated)

# Both: same selection logic
auto_bs = max(64, min(256, int(free_vram_mb * 0.4 / 60)))
auto_bs = 2 ** int(math.log2(auto_bs))  # Round to power of 2

Only activates when embedding_batch_size <= 32 (the typical default range), so explicit user-configured values are respected.

6. Double-buffered CUDA stream prefetch (speaker_diarization.py) — CUDA only

While the GPU processes embedding batch N, a separate CUDA stream transfers batch N+1 from CPU to GPU using pinned memory and non-blocking transfers. This hides H2D transfer latency. Not applicable to MPS (unified memory architecture eliminates CPU↔GPU transfers).

transfer_stream = torch.cuda.Stream(device=device)

def prefetch_waveforms(idx):
    with torch.cuda.stream(transfer_stream):
        wf = _get_batch_waveforms(s, e).pin_memory().to(device, non_blocking=True)
        mk = flat_masks_tensor[s:e].pin_memory().to(device, non_blocking=True)
    return wf, mk

7. Direct model calls bypassing wrapper overhead (speaker_diarization.py) — CUDA + MPS

Calls model_.compute_fbank() + model_.resnet() directly instead of through PyannoteAudioPretrainedSpeakerEmbedding.__call__(), avoiding redundant pin_memory, mask resampling, and other per-call overhead. Works on both CUDA and MPS.

8. MPS-native FFT for fbank computation (wespeaker/__init__.py) — MPS

Before: Stock code unconditionally falls back to CPU for FFT on MPS devices (fft_device = torch.device("cpu") if device.type == "mps"). This was necessary when MPS FFT was broken (pre-PyTorch 2.3), but creates a per-batch CPU↔MPS round-trip that dominates embedding time.

After: Attempts FFT directly on MPS first (works in PyTorch 2.3+). Falls back to CPU only if MPS FFT raises RuntimeError (older PyTorch).

# Before: always falls back to CPU (unnecessary in PyTorch 2.3+)
fft_device = torch.device("cpu") if device.type == "mps" else device
features = torch.vmap(self._fbank)(waveforms.to(fft_device)).to(device)

# After: use MPS FFT when available, CPU fallback for older PyTorch
if device.type == "mps":
    try:
        features = torch.vmap(self._fbank)(waveforms)  # MPS FFT (PyTorch 2.3+)
    except RuntimeError:
        features = torch.vmap(self._fbank)(waveforms.cpu()).to(device)  # CPU fallback
else:
    features = torch.vmap(self._fbank)(waveforms)

Profiling data (Mac Studio M2 Max, batch=32): MPS FFT is 4.46x faster than the CPU fallback (13.1ms vs 58.6ms). This eliminates the single largest MPS bottleneck.

9. GPU memory cleanup between pipeline stages (speaker_diarization.py) — CUDA + MPS

Calls torch.cuda.empty_cache() or torch.mps.empty_cache() (as appropriate) after segmentation and after embedding extraction. This releases cached allocations before the next stage, preventing memory accumulation. Profiling confirms empty_cache() is essentially free on both devices (mean 0.009ms on MPS, <0.1ms on CUDA).

10. Pinned memory for segmentation and embedding transfers (inference.py, speaker_verification.py) — CUDA only

Uses pin_memory().to(device, non_blocking=True) for CPU-to-GPU tensor transfers in both the segmentation model inference and the embedding model forward pass. Only activates on CUDA devices. MPS uses unified memory (no CPU↔GPU boundary), so pinned memory is not applicable and is correctly skipped.


Device Support Matrix

Optimization CUDA MPS CPU
Vectorized chunk extraction Yes Yes Yes
Memory-efficient batch indexing Yes Yes Yes
Vectorized mask selection Yes Yes Yes
TF32 re-enablement Yes N/A N/A
Adaptive batch size Yes Yes
CUDA stream prefetch Yes N/A N/A
Direct model calls Yes Yes
MPS-native FFT N/A Yes N/A
Memory cleanup (empty_cache) Yes Yes
Pinned memory transfers Yes N/A N/A
Waveform release post-embedding Yes Yes Yes

MPS Profiling Data

Detailed profiling on Mac Studio M2 Max (32GB) reveals MPS-specific characteristics:

Operation Costs

Operation Mean Notes
torch.mps.empty_cache() 0.009ms Effectively free
torch.mps.synchronize() 0.000ms No-op (unified memory)
CPU→MPS transfer (100MB) 2.4ms (40.8 GB/s) Unified memory — pointer remap, not DMA
torch.unfold() (1890 chunks) 0.0ms Zero-copy view

MPS FFT vs CPU FFT (fbank computation)

Batch Size MPS FFT CPU FFT MPS Speedup
16 50.4ms 29.5ms 0.58x (CPU faster)
32 13.1ms 58.6ms 4.46x
64 30.2ms 109.3ms 3.61x
128 48.4ms 201.2ms 4.16x

MPS FFT has a dispatch overhead that amortizes at batch >= 32. Since the split pipeline uses auto-selected batches of 64+, the MPS FFT path is always faster in practice.

Embedding Forward Pass Scaling

Batch Size Total Per-chunk Notes
16 127.7ms 7.98ms
32 234.1ms 7.31ms Best per-chunk efficiency
64 473.2ms 7.39ms
128 1029.5ms 8.04ms

Per-chunk cost is flat on MPS (minimal GPU parallelism for this model size). Batch 32 has the best per-chunk time, validating the adaptive batch sizing approach.

Segmentation Model Scaling

Batch Size Total Per-chunk
1 53.1ms 53.05ms
4 118.0ms 29.51ms
16 95.5ms 5.97ms
32 78.3ms 2.45ms

Segmentation model benefits significantly from batching on MPS — 21x faster per-chunk at batch=32 vs batch=1.


Backward Compatibility

  • CPU devices: Vectorized chunk extraction, mask selection, and memory-efficient indexing still apply (device-agnostic). The optimized split pipeline falls back to the original sequential embedding loop.
  • MPS devices: All applicable optimizations are gated behind device.type == "mps" checks. Features unsupported on MPS (pin_memory, CUDA streams, TF32) are correctly skipped. FFT fix includes try/except fallback for older PyTorch where MPS FFT is broken.
  • Low-memory machines (8-16GB): Memory-efficient batch indexing prevents OOM. Adaptive batch sizing queries actual system memory limits (no hardcoded assumptions). Safe fallback to batch_size=64 on exception.
  • Pre-Ampere CUDA GPUs: TF32 flags are silently ignored. Adaptive batch sizing works on any CUDA GPU (tested conceptually down to 6GB VRAM).
  • Existing API: No changes to public API. embedding_batch_size, segmentation_batch_size, and all pipeline parameters work as before.
  • Fallback path: When the optimized pipeline conditions aren't met (CPU device, missing compute_fbank/resnet methods), the code falls back to the original sequential embedding loop with no behavior change.

Related Issues


How to Test

# CUDA
from pyannote.audio import Pipeline
import torch

pipeline = Pipeline.from_pretrained(
    "pyannote/speaker-diarization-3.1", token="YOUR_TOKEN"
)
pipeline = pipeline.to(torch.device("cuda"))
pipeline.embedding_batch_size = 32  # Auto-elevates to 64-256 based on VRAM
output = pipeline({"waveform": waveform, "sample_rate": 16000})

# MPS (Apple Silicon)
pipeline = pipeline.to(torch.device("mps"))
pipeline.embedding_batch_size = 32  # Auto-selects based on unified memory
output = pipeline({"waveform": waveform, "sample_rate": 16000})

Test Hardware

Platform Device Memory PyTorch Python
CUDA NVIDIA RTX A6000 48GB VRAM 2.8.0+cu128 3.12
MPS Apple M2 Max (Mac Studio) 32GB unified 2.10.0 3.13

davidamacey and others added 5 commits March 11, 2026 23:15
…ntation

Use pin_memory().to(device, non_blocking=True) for CUDA devices during
segmentation model inference. This enables DMA transfers and overlaps
CPU→GPU data movement with computation.

Only activates on CUDA devices — CPU/MPS paths unchanged.

Benchmark: ~5% speedup on H2D data transfer during segmentation.
Tested on NVIDIA RTX A6000 (49GB) with 5 files (0.5h-4.7h).
…er embedding

Use pin_memory().to(device, non_blocking=True) in the embedding model
forward pass (PyannoteAudioPretrainedSpeakerEmbedding.__call__).

Enables DMA transfers for waveform and mask tensors sent to GPU during
speaker embedding extraction.

Only activates on CUDA devices — CPU/MPS paths unchanged.

Benchmark: ~5% speedup on H2D data transfer during embedding.
Tested on NVIDIA RTX A6000 (49GB) with 5 files (0.5h-4.7h).
…traction

Comprehensive GPU optimization for the speaker diarization embedding stage,
achieving 1.28x overall speedup and 1.44x embedding speedup while reducing
peak VRAM by 66-68%.

Changes:
- TF32 re-enablement: Re-enable TensorFloat-32 after segmentation for
  embedding inference on Ampere+ GPUs (~15% speedup). Safe on all GPUs
  (pre-Ampere silently ignores the flag).
- Adaptive batch size: Auto-select embedding batch size (64-256) based on
  free GPU VRAM instead of hardcoded values. Accounts for loaded models
  via torch.cuda.memory_reserved().
- Vectorized chunk extraction: Use torch.unfold() to extract all audio
  chunks in one operation instead of individual crop() calls.
- Vectorized mask selection: Use numpy broadcasting to select clean vs
  regular masks for all chunk×speaker pairs at once.
- Double-buffered CUDA stream prefetch: Transfer batch N+1 while GPU
  processes batch N using separate CUDA stream with pin_memory() +
  non_blocking transfers.
- Direct model calls: Bypass PyAnnote embedding wrapper overhead by
  calling model_.compute_fbank() + model_.resnet() directly.
- VRAM cleanup: torch.cuda.empty_cache() between pipeline stages for
  predictable memory usage.

Benchmark results (NVIDIA RTX A6000, 5 files 0.5h-4.7h):
- Overall: 1.28x speedup (718s → 563s)
- Embedding stage: 1.44x speedup (consistent across all file sizes)
- VRAM: constant 5.6GB vs variable 7-17GB stock (66-68% reduction)
- Accuracy: identical (speaker counts ±1, segments ±1.8% from VBx non-determinism)
Extend the GPU pipeline optimizations to support Apple MPS devices:

- Adaptive batch size: query MPS memory via driver_allocated_memory()
  with conservative 16GB unified memory budget
- Direct model calls: compute_fbank() + resnet() bypassing wrapper
  overhead (same as CUDA path, without CUDA-specific features)
- Vectorized chunk extraction: torch.unfold() is device-agnostic
- Vectorized mask selection: numpy broadcasting is device-agnostic
- Memory cleanup: torch.mps.empty_cache() between pipeline stages
- Mixed precision: torch.amp.autocast("mps") support

MPS-specific handling:
- No pin_memory() (not supported on MPS, uses unified memory)
- No CUDA stream prefetch (MPS has different async model)
- No TF32 (Apple Silicon has different compute architecture)
- Fallback fbank on CPU (existing PyAnnote behavior for MPS FFT)

Untested — requires Apple Silicon hardware for validation.
…batch indexing

Three improvements verified on CUDA (RTX A6000) and MPS (Mac Studio M2 Max):

1. MPS-native FFT for fbank computation (wespeaker/__init__.py)
   - Stock code unconditionally falls back to CPU for FFT on MPS devices,
     creating a per-batch CPU<->MPS round-trip that dominates embedding time.
   - MPS FFT works in PyTorch 2.3+ — use it directly with try/except
     fallback for older PyTorch where MPS FFT raises RuntimeError.
   - Profiled: MPS FFT is 4.46x faster than CPU fallback at batch=32
     (13.1ms vs 58.6ms on M2 Max).

2. Dynamic MPS memory budget (speaker_diarization.py)
   - Replace hardcoded 16GB budget with torch.mps.recommended_max_memory()
     which returns the actual safe GPU allocation limit (~2/3 of total RAM).
   - Fallback chain: recommended_max_memory -> os.sysconf total RAM * 0.75
     -> 8GB safe default. Works correctly on 8GB to 192GB machines.
   - Exception fallback reduced from 128 to 64 batch size for safety.

3. Memory-efficient batch indexing (speaker_diarization.py)
   - Replace repeat_interleave() with per-batch _get_batch_waveforms()
     that indexes into all_chunks using integer division.
   - repeat_interleave pre-materializes ALL chunk x speaker combinations:
     58.8GB for a 4.7h/21-speaker file — crashes machines with <64GB RAM.
   - New approach: constant 39MB per batch regardless of file length or
     speaker count. 4.7h/21-speaker file now works on 8GB machines.

Benchmark results (5 runs each, std shown):

  CUDA 0.5h: 21.2s mean (std 0.3s) — no regression from previous commit
  MPS 0.5h:  40.8s mean (std 0.1s) vs 47.7s stock — 1.17x speedup
  MPS full suite (5 files, 12.2h): 1011s vs 1120s stock — 1.11x speedup

Accuracy: speakers match (4/4), segments within ±0.5% (VBx non-determinism).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
davidamacey added a commit to attevon-llc/OpenTranscribe that referenced this pull request Mar 12, 2026
… postprocessing, and benchmark tooling

Switch to optimized PyAnnote fork (1.28x CUDA, 1.17x MPS speedup, 66% VRAM
reduction, 115x CPU RAM reduction). Add diarization sub-stage timing hooks,
segment resegmentation at speaker boundaries with merging, ONNX preconversion
tooling, and comprehensive benchmark/profiling scripts for CUDA and MPS.

- requirements.txt: pyannote.audio → optimized fork (gpu-optimizations branch)
- diarizer.py: timing hooks via pipeline callback, updated batch size docs
- core.py: integrate resegment_by_speaker + merge_consecutive_segments
- segment_postprocess.py: new utility for speaker-boundary segment splitting
- apply-pyannote-patch.sh: multi-phase patching (full/legacy/revert)
- 7 benchmark/profiling scripts for CUDA and MPS validation
- 5 docs: optimization results, deployment checklist, ONNX guide, upstream PR
- .gitignore: exclude benchmark data (test audio, result JSONs)

Upstream PR: pyannote/pyannote-audio#1992

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replaces the naive 'max(64, min(256, free_mb*0.4/60))' auto-scaler with
an empirically-derived ladder. Measurements on RTX A6000 with this fork
+ torch 2.8.0+cu128 (OpenTranscribe Phase A, 2026-04-20) found that
embedding throughput saturates at batch size 16 for fp32. Above bs=16
the pipeline spends 3-7 GB of extra VRAM for <3 % speed gain. DER
against pyannote.metrics is 0 across bs in {1, 4, 8, 16, 32, 64, 128}
at fp32, so dropping the ceiling has no accuracy cost.

Changes
-------
* New pipelines/_budget.py. Pure-Python helper exposing
  recommend_embedding_batch(free_mb, device) -> BudgetRecommendation.
  Ladder: bs=16 if headroom >= 1154 MB, bs=8 if >= 840, else insufficient.
  Stdlib-only so it can be unit-tested without a GPU.
* pipelines/speaker_diarization.py: replace the bs=64-256 auto-scaler
  at the embedding stage with the budget helper. Adds two new __init__
  kwargs:
    - vram_budget_mb: Optional[int] — explicit budget in MB; None means
      query live free VRAM (default, preserves upstream behavior).
    - embedding_mixed_precision: bool — fp16 autocast for embedding
      forward. Default False: Phase A DER measured 27-33 % when enabled
      (collapses speaker count, likely numerical underflow in WeSpeaker
      pooling std()).
  Fallback on budget query error clamps to bs=16 (not 64) -- tighter
  failure mode, consistent with the measurement-backed ceiling.
* tests/test_budget.py: 28 unit tests covering the full ladder,
  device=cpu fallback, MPS path, custom ceiling, custom safety margin,
  and regression assertions on the _DIARIZATION_FOOTPRINT_MB table.
  Pure Python, no GPU required. All 28 pass.

Reproduction data
-----------------
Raw JSON + RTTMs for 198 benchmark runs committed in the OpenTranscribe
repo at docs/diarization-vram-profile/. See the README there for the
DER and throughput tables that inform the ladder values.

Backward compatibility
----------------------
PYANNOTE_FORCE_EMBEDDING_BATCH_SIZE env var retained as a reproducibility
escape hatch for the OpenTranscribe probe harness. Upstream users who
set embedding_batch_size > 32 get their value honored as before.
Upstream users who leave the default get the new budget-aware ladder
which is strictly safer on <= 8 GB GPUs.
MPS characterization sweep on Mac Studio M2 Max (2026-04-20) showed
the torch MPS allocator pre-reserves ~1.9 GB for any embedding batch
size in [1, 16], whereas CUDA scales more gradually (~640 MB at
bs<=8, ~950 MB at bs=16). A single footprint table was therefore
wrong for MPS: a caller with 1.5 GB free would have picked bs=16 on
the CUDA table and OOM'd on MPS.

Changes
-------
* _DIARIZATION_FOOTPRINT_MB_BY_DEVICE: per-device tables for cuda +
  mps, both capped at bs=16 (throughput ceiling unchanged across
  devices). Back-compat alias _DIARIZATION_FOOTPRINT_MB retained for
  code that pre-dates the split.
* _footprint_table(device): new helper, case-insensitive, strips
  'cuda:0' -> 'cuda', falls back to cuda for unknown devices.
* recommend_embedding_batch now routes through _footprint_table.
* tests/test_budget.py: +5 new tests (cuda/mps divergence at 1500 MB,
  mps 2500 MB fit, cuda:0 index stripping, unknown-device fallback,
  per-device ladder shape invariants). 33/33 pass.

MPS throughput data (2026-04-20, Mac Studio M2 Max, 0.5 h clip, fp32):

    bs   wall(s) peak(MB)
    1    65.4    1864
    4    45.7    1922
    8    43.5    1866
    16   42.0    1890   <- ceiling preserved
    32   43.1    3914
    64   42.5    4978

Saturation near bs=8 on MPS (CUDA saturates at bs=16). Speaker count
= 4 at every batch size, matching the Phase A CUDA reference -- the
ceiling=16 policy is accuracy-safe on both devices.
…age attribution

Wraps the hot-path stages in torch.profiler.record_function context managers
so profiler traces can attribute time/VRAM cleanly to segmentation, embedding
(fbank + resnet), clustering (cdist / linkage / normalize / kmeans), and the
Inference.aggregate overlap-add loop. Zero runtime cost when profiler is
inactive.

Prep for the OpenTranscribe Phase 1.5 MPS fallback-op hunt and the Phase 3
GPU-clustering work, both of which need stage-level profiler attribution to
measure the right thing.

Covered labels:
- pyannote::segmentation (speaker_diarization.get_segmentations)
- pyannote::embedding_fbank / ::embedding_resnet / ::embedding_batch
- pyannote::clustering_cdist / ::clustering_cdist_merge / ::clustering_linkage
- pyannote::clustering_normalize / ::clustering_kmeans
- pyannote::aggregation (core/inference.Inference.aggregate)

No behavioural change. The aggregate() static method now delegates to
_aggregate_impl() so the record_function wrapper sits cleanly around the
body.
…, visibility

Four small, independent improvements bundled as a single PR. Each preserves
byte-exact default behaviour (DER-invariant by construction) and the Phase A
VRAM envelope. Expected cumulative speedup with defaults: 0-3% on CUDA,
primarily from cuDNN algorithm autotuning across the fixed-shape
segmentation/embedding convolutions.

2.1 — torch.compile visibility
  speaker_diarization.py: replace 'except Exception: pass' around the
  torch.compile() block with logger.warning. Silent fallbacks previously hid
  Dynamo graph breaks that cost 10-20% of embedding-stage throughput when
  the caller passed torch_compile=True. Default for torch_compile is False,
  so no behavioural change unless the caller enables it; the change is
  strictly diagnostic. Plan accept criteria (>=8% run-2 speedup) only
  applies when torch_compile=True; left as a separate decision for a
  future Phase 2.5 commit.

2.2 — Hamming window cache
  core/inference.py: add _cached_hamming_window(num_frames_per_chunk) via
  functools.lru_cache(maxsize=4). Previously np.hamming() was recomputed on
  every Inference.aggregate() call; the window is deterministic and
  read-only so caching is byte-equivalent. Sets writeable=False to catch
  accidental mutation. Expected effect <1% wall time but removes a
  pointless allocation from the hot path and prepares for the Phase 3.5
  vectorized-aggregate rewrite.

2.3 — cuDNN benchmark mode
  speaker_diarization.py: set torch.backends.cudnn.benchmark = True when
  the segmentation model is on CUDA. Safe for inference (no gradient
  computation); pyannote's fix_reproducibility() does not touch this flag
  so one-time init is sufficient. For fixed-shape conv workloads (segmentation
  batches, embedding ResNet) cuDNN's algorithm autotuner picks the best
  kernel on the first forward pass and caches it for subsequent calls.
  Expected second-run speedup ~3-5% on segmentation stage, memory delta
  <20 MB (autotune workspace).

2.4 — embedding_mixed_precision plumbing
  speaker_diarization.py: the self.embedding_mixed_precision flag (already
  on __init__ since Phase A) was stored but never read inside get_embeddings.
  Now wired as a torch.autocast(device.type, dtype=torch.float16) context
  around compute_fbank + resnet. Default remains False so fp32 output is
  byte-exact with the prior code path. The flag is explicitly NOT a
  production-ready knob — Phase A DER measurements rejected fp16 for
  WeSpeaker (26-33% DER collapse from std() pooling underflow). It exists
  so future Phase B work can A/B test bf16 or hardware-tuned precisions
  without re-plumbing.

Smoke: single 0.5h run on CUDA A6000 = 22.7s vs baseline mean 22.6s ±0.48
(within 1σ). 4 speakers / 703 segments / 844 MB peak — byte-identical to
Phase 1 baseline. 5-run validation pending.
…AM-budgeted)

Replaces scipy's cdist + pdist on the clustering hot path with torch-native
equivalents that run on the embedding pipeline's GPU (CUDA or MPS). Phase 1
baseline showed clustering was 41% of end-to-end wall time on the 4.7h /
8-speaker benchmark (138s of 334s) — the single largest remaining stage
on CUDA after Phase A's embedding-batch work.

Expected speedup on long multi-speaker files: 3-5× on the clustering stage,
~25-35% end-to-end on 4.7h. MPS speedup is smaller (clustering is only 2.6%
of MPS wall on 2.2h because M2 Max CPU is already fast at scipy) — the gain
is primarily a CUDA concern.

scipy.cluster.hierarchy.linkage's Lance-Williams merge stays on CPU — it's
O(N²) per merge step but cheap per step, nontrivial to GPU-port without a
heavy dependency (RAPIDS / cuML, rejected by the plan constraints). This
commit offloads the O(N²) distance-computation step, which dominates the
stage on long files.

Implementation — new helpers in pipelines/clustering.py:

  _get_clustering_device()            auto-detects CUDA/MPS, respects
                                      PYANNOTE_CLUSTERING_DEVICE env.
  _gpu_clustering_budget_bytes(dev)   VRAM byte budget. On CUDA: min(env,
                                      free_vram - 200 MB). Default 50 MB.
  _gpu_clustering_fits(n, dim, dev)   predicts fit for 2 normalized copies
                                      + NxN matrix + 8 MB allocator overhead.
  _gpu_cdist(a, b, metric, dev)       torch.cdist (euclidean) or F.normalize
                                      + matmul (cosine). Falls back to
                                      scipy.cdist for unsupported metrics
                                      and when budget exceeded.
  _gpu_pdist_condensed(a, metric, dev)  condensed upper-triangular pdist,
                                      scipy-ordered; feeds linkage()
                                      directly (it ignores the metric kwarg
                                      when given a 1-D condensed vector).

Wired into four clustering call sites:
- BaseClustering.assign_embeddings cdist (centroid distances)
- AgglomerativeClustering.cluster linkage (both the cosine/euclidean fast
  path and the generic path)
- VBxClustering AHC seed linkage
- VBxClustering final e2k_distance cdist

Small 'clustering_cdist_merge' site at line 481 (large/small cluster merge,
O(num_clusters²)) is kept on scipy — the matrix is tiny.

Configuration knobs:
  PYANNOTE_CLUSTERING_DEVICE          override auto-detected device
  PYANNOTE_CLUSTERING_VRAM_BUDGET_MB  per-call byte budget (default 50)
  PYANNOTE_CLUSTERING_DISABLE_GPU     if set, route everything through scipy

DER invariance: tests/test_clustering_gpu.py verifies numerical parity with
scipy on fixed-seed synthetic inputs for cosine and euclidean metrics across
N ∈ {50, 100, 500}, dim ∈ {256, 512}. The end-to-end AgglomerativeClustering
integration test confirms the induced cluster partition is identical between
the GPU path and the DISABLE_GPU path on a 3-cluster × 20-embedding synthetic.
All 21 tests pass (rtol=1e-4, atol=1e-5 on fp32).

VRAM invariance: the 50 MB default budget leaves ~200 MB headroom before the
1.05 GB pipeline ceiling (Phase 1.7 vram-budget-table.md). The fit check
ensures any N ≥ ~4000 on dim=256 falls back to scipy automatically, so the
invariant holds for extreme inputs too. Run-level DER + VRAM validation
against the 4.7h/8-spk benchmark is pending and will land in a separate
measurement commit alongside updated vram-budget-table.md.
Original Phase 3.5 plan proposed vectorizing five hot paths:
  1. BaseClustering.constrained_argmax inner zip-loop        (trivial)
  2. BaseClustering.assign_embeddings centroid computation   (trivial)
  3. AgglomerativeClustering small/large cluster remap       (trivial)
  4. SpeakerDiarization.reconstruct double Python loop       (projected 1-2% E2E)
  5. Inference.aggregate overlap-add loop                    (projected 1-2% E2E)

Measured 4 and 5 on the 4.7h/8-speaker benchmark and both regressed badly:

| stage               | baseline | vectorized | delta   |
|---------------------|---------:|-----------:|--------:|
| reconstruct         |   6.85 s |    10.74 s | +57%    |
| discrete_diarization|  12.53 s |    16.87 s | +35%    |
| wall (end-to-end)   | 334.12 s |   339.66 s | +1.7%   |

Root cause: both rewrites use scatter-style numpy primitives (np.add.at,
np.maximum.at, broadcast-masked-max via np.where) which are GPU-friendly but
CPU-hostile. numpy's contiguous-stride slice ops (the ones the original loops
used) beat scatter primitives on CPU by a wide margin. A GPU port of these
loops remains in scope for a future Phase 5.2 commit, but the naive CPU
vectorization was wrong. Reverted both.

What shipped (the trivial freebies only):
  - constrained_argmax: inner 'for s, k in zip(...)' → 'hard_clusters[c, speakers] = clusters'
  - assign_embeddings centroids: np.vstack-of-list-comp-of-means → bincount + np.add.at
  - small/large cluster remap: in-place iteration → lookup table

These are microsecond-scale improvements with no measurable wall-time effect
but eliminate three Python loops, improve readability, and avoid the worst
pitfalls (repeated small allocations, iteration ordering coupling). Ship for
code quality + future GPU port prep.

tests/test_vectorization_phase35.py: numeric-parity tests for all three
cleanups against the original reference loops (6 tests, all passing).

Lessons captured in docs/upstream-patches/phase-3-measurement.md (next
commit) under a new 'Phase 3.5 CPU vectorization anti-patterns' section.
…ve result)

Ports scipy.cluster.hierarchy.linkage(method='centroid', metric='euclidean')
to a pure-torch implementation that keeps centroids, counts, active-slot
mask, and the full N×N distance matrix resident on GPU through the merge
loop. Uses the algebraic equivalence between Lance-Williams centroid updates
and direct centroid-distance recomputation for Euclidean metric.

Wired into both AgglomerativeClustering.cluster (when method='centroid')
and VBxClustering.cluster (the AHC seed linkage that pyannote-community-1
actually exercises — AgglomerativeClustering is not on that pipeline's
default path).

MEASUREMENT ON A6000 / pyannote-community-1 / 2.2h ref:
  N = 10023, D = 256
  scipy baseline: ~12 s
  GPU port:       14.17 s
  Result:         SLOWER by ~2 s

Root cause: the algorithm is intrinsically sequential — each merge depends
on the previous iteration's centroid update, so the N-1 merges can't be
batched. Python-level for loop × 10k iterations × ~1.5 ms each (argmin
on a 10k² matrix + scatter writes + row/column inf updates) adds up to
~15 s of overhead that cancels out the GPU math win vs scipy's compiled
C path. This is a known limitation of naive pure-torch implementations of
sequential O(N) algorithms.

DEFAULT: off. Opt-in via PYANNOTE_ENABLE_GPU_LINKAGE=1. Scipy remains the
default code path to avoid the 2 s regression. The GPU implementation is
kept as correctness-verified infrastructure for future work:

  * torch.jit.script or torch.compile over the merge loop to eliminate
    Python overhead (blocked by dynamic tensor shapes in the index ops)
  * CUDA kernel that fuses argmin + scatter + row recompute into one
    launch per iteration (dramatic overhead reduction possible)
  * Newer hardware — H100/B100-class GPUs with faster kernel launch may
    tip the balance toward the GPU path at the current implementation level

VRAM budget (tests/test_gpu_linkage.py::TestBudgetGuard): auto-sized to
50% of free VRAM after a 300 MB safety margin, capped at 2 GB. Default
policy gives the A6000 plenty of room; MPS (no mem_get_info API) defaults
to 500 MB.

Correctness: 11 numeric-parity tests against scipy on fixed-seed synthetic
blobs (n_clusters × per_cluster × dim parametrized). Partition equivalence
verified via fcluster at the cluster-count threshold — GPU and scipy
produce the same induced clustering even where tie-breaking causes small
dendrogram distance-column differences.

Files:
  src/pyannote/audio/pipelines/clustering.py: +_gpu_linkage_fits,
    +_gpu_linkage_centroid, wired into AHC and VBx paths
  tests/test_gpu_linkage.py: 11 tests (all passing with env opt-in)

@Karen86Tonoyan Karen86Tonoyan left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

KAREN TONOYAN

Adds pyannote.audio.onnx subpackage with:
- runtime.py: ONNXSegmentationRuntime + ONNXEmbeddingRuntime wrappers with
  device-aware provider selection (TensorrtExecutionProvider when
  ENABLE_TENSORRT=1, then CUDAExecutionProvider, CoreMLExecutionProvider for
  MPS, CPUExecutionProvider fallback)
- compute_fbank_batched: vmap-free batched fbank for ONNX graph compatibility
- export.py: CLI for producing segmentation.onnx + embedding.onnx (backbone
  only, per pyannote-audio discussion pyannote#1929). Writes sibling metadata.json
  per artifact.

Pipeline wiring (speaker_diarization.py):
- _setup_phase6_onnx() monkey-patches self._segmentation.infer,
  self._embedding.model_.resnet.forward, and compute_fbank when the
  PYANNOTE_USE_ONNX environment variable is set. Hot-path code unchanged.
- Exception-guarded; falls back to eager PyTorch silently on any failure.

Rationale and measured limitations documented in
docs/upstream-patches/phase-6-2-lessons-learned.md (in OpenTranscribe repo).
…reconstruct (NEGATIVE RESULT — kept as scaffolding)

Adds fork:pyannote/audio/gpu_ops.py with:
- aggregate_gpu(): torch.Tensor.index_add_ (sum) + scatter_reduce_(amax) (mask)
- reconstruct_gpu(): scatter_reduce_(amax) for per-(chunk, cluster) max
- try_*_gpu() wrappers with VRAM-budget + exception-safe fallback
- env-var gates PYANNOTE_GPU_AGGREGATE=1, PYANNOTE_GPU_RECONSTRUCT=1 (default OFF)
- configurable VRAM budget via PYANNOTE_GPU_OP_VRAM_BUDGET_MB (default 200)

Hooks:
- core/inference.py: _aggregate_impl_gpu() tried first, falls through to CPU
- pipelines/speaker_diarization.py: reconstruct() tries GPU first, same pattern

Correctness:
- Synthetic parity: aggregate 9.5e-7 max diff (fp32 floor), reconstruct 0.0
  (bit-exact)
- End-to-end DER on 2.2h + 4.7h: 0.0000% T1 across all 3+3 runs

Measured wall time (3 runs each, A6000):
- 2.2h: 103.80s (cv=0.5%) vs baseline ~100.7-101.7s — +2-3% regression
- 4.7h: 342.09s (cv=3.0%) vs baseline ~322-328s — ~6% regression

Why the memo's +4.5% E2E projection failed:
aggregate/reconstruct are memory-bandwidth-bound, not compute-bound. Per-chunk
working set fits in CPU L2 cache; GPU HBM adds latency + ~5-10 ms PCIe
round-trip per call. For this access pattern the CPU cache wins.

The hooks are kept because:
- Zero runtime cost when env vars unset
- Correctness is verified
- Future exploration (H100/B100 HBM, batched-across-chunks reformulations,
  or 10+ hour files) might still win

Full analysis in docs/upstream-patches/phase-5-2-implementation-results.md
in the OpenTranscribe repo.
…ng, not yet winning)

- _select_providers() accepts optional shape_profile dict; when ENABLE_TENSORRT=1
  passes trt_profile_min/opt/max_shapes to the TRT EP
- Ship _SEGMENTATION_SHAPE_PROFILE + _EMBEDDING_SHAPE_PROFILE constants
  reflecting real pipeline call shapes
- ONNXSegmentationRuntime / ONNXEmbeddingRuntime wire their profiles

Adds two env gates to _setup_phase6_onnx:
  PYANNOTE_ONNX_SEG_ENABLED (default 1) — allow skipping segmentation ONNX
  PYANNOTE_ONNX_EMB_ENABLED (default 1) — allow skipping embedding ONNX

Enables hybrid mode (e.g. keep eager seg, only route emb through ONNX/TRT).

Status: scaffolding only. E2E TRT hybrid benchmark did NOT succeed — TRT EP
still rebuilds engine plans despite shape profile (>2 min CPU saturation on
17 cores, 0% GPU). See phase-6-3-shape-profile-attempt.md in OpenTranscribe
for full analysis + 'stop chasing' entry pyannote#12.

Default behavior unchanged (PYANNOTE_USE_ONNX must be set for any of this to
activate).
… by 35%

Phase 6.3 follow-up (2026-04-23): added
  self._embedding.model_.resnet = torch.compile(...)
to match where the pipeline actually calls. Microbench showed 1.34x
(default) to 1.49x (max-autotune) speedup on the ResNet forward.

Measured E2E on 2.2h (3 runs with --torch-compile):
- Wall mean 170.4s vs baseline 100s (+70%, cv 35.1% unreliable)
- EVERY stage regressed: seg +65%, emb +22%, clustering +62%,
  reconstruction +90%, discrete_diarization +100%

Root cause: Dynamo recompiles on every unique batch shape the
pipeline emits. Variable shapes = recompile storm on the same class
of issue that killed TRT EP (shape-profile storm, see
docs/upstream-patches/phase-6-3-shape-profile-attempt.md in the
OpenTranscribe repo).

Reverted to prior Phase 2.1 behavior: compile parent model_ and
segmentation.model only (noise-level impact, not a regression).
Full analysis in phase-6-3-torch-compile-attempt.md.
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