perf: GPU pipeline optimization — 1.28x CUDA, 1.17x MPS, memory-safe batching#1992
Open
davidamacey wants to merge 16 commits into
Open
perf: GPU pipeline optimization — 1.28x CUDA, 1.17x MPS, memory-safe batching#1992davidamacey wants to merge 16 commits into
davidamacey wants to merge 16 commits into
Conversation
…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)
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR optimizes the GPU pipeline for speaker diarization embedding extraction, achieving:
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)
Overall Pipeline Performance
Embedding Stage Performance (where optimizations apply)
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)
VRAM Behavior
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
Embedding Stage Performance
MPS Statistical Validation (0.5h file, 5 runs each)
MPS Performance Notes
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:repeat_interleaveRAMA 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:The
all_chunkstensor fromtorch.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
MPS Accuracy
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 + CPUBefore:
iter_waveform_and_mask()callsself._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.2. Memory-efficient batch indexing (
speaker_diarization.py) — CUDA + MPS + CPUBefore:
repeat_interleave()pre-materializes all chunk x speaker combinations — 58.8GB for a 4.7h/21-speaker file.After: Per-batch indexing into
all_chunksusing integer division — constant 39MB regardless of file length or speaker count.3. Vectorized mask selection (
speaker_diarization.py) — CUDA + MPS + CPUBefore: Per-chunk, per-speaker Python loop selecting between clean and regular masks.
After: NumPy broadcasting selects all masks at once. Also device-agnostic.
4. TF32 re-enablement for embedding inference (
speaker_diarization.py) — CUDA onlyfix_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.5. Adaptive batch size selection (
speaker_diarization.py) — CUDA + MPSAuto-selects embedding batch size (64-256) based on available GPU memory, instead of relying on a hardcoded value.
torch.cuda.get_device_properties().total_memandtorch.cuda.memory_reserved()for actual free VRAMtorch.mps.recommended_max_memory()(returns ~2/3 of total RAM, the safe GPU allocation limit). Falls back toos.sysconftotal RAM query, then 8GB safe default. No hardcoded memory assumptions — works correctly on machines from 8GB to 192GB unified memory.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 onlyWhile 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).
7. Direct model calls bypassing wrapper overhead (
speaker_diarization.py) — CUDA + MPSCalls
model_.compute_fbank()+model_.resnet()directly instead of throughPyannoteAudioPretrainedSpeakerEmbedding.__call__(), avoiding redundantpin_memory, mask resampling, and other per-call overhead. Works on both CUDA and MPS.8. MPS-native FFT for fbank computation (
wespeaker/__init__.py) — MPSBefore: 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).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 + MPSCalls
torch.cuda.empty_cache()ortorch.mps.empty_cache()(as appropriate) after segmentation and after embedding extraction. This releases cached allocations before the next stage, preventing memory accumulation. Profiling confirmsempty_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 onlyUses
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
MPS Profiling Data
Detailed profiling on Mac Studio M2 Max (32GB) reveals MPS-specific characteristics:
Operation Costs
torch.mps.empty_cache()torch.mps.synchronize()torch.unfold()(1890 chunks)MPS FFT vs CPU FFT (fbank computation)
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
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
Segmentation model benefits significantly from batching on MPS — 21x faster per-chunk at batch=32 vs batch=1.
Backward Compatibility
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.embedding_batch_size,segmentation_batch_size, and all pipeline parameters work as before.compute_fbank/resnetmethods), the code falls back to the original sequential embedding loop with no behavior change.Related Issues
How to Test
Test Hardware