Optimize Qwen3-ASR batch decode CPU sync#234
Conversation
|
@codex review |
There was a problem hiding this comment.
Pull request overview
This PR introduces a correctness-safe Qwen3-ASR batch transcription API and CLI support that reduces CPU/GPU synchronization overhead during greedy decoding, plus supporting benchmarks and documentation for evaluating batch-size caps and experimental true-batched decoding.
Changes:
- Add
Qwen3ASRModel.transcribeBatch(...)with a production-safe “bulk token sync” greedy path and an env-gated experimental true[B,1,H]decode path. - Extend
audio transcribe-batchwith--batch-sizegrouping and additional per-item/per-batch timing fields (JSONL and human output). - Add benchmark/power-monitor scripts and documentation for fixed-chunk batch decode sweeps and experimental validation.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| Tests/Qwen3ASRTests/Qwen3ASRBatchedDecodeTests.swift | Unit coverage for option resolution, seq-len bucketing, prompt token boundaries, and batched causal mask. |
| Tests/Qwen3ASRTests/E2EQwen3ASRBatchedDecodeCorrectnessTests.swift | E2E regression ensuring public batch transcription matches serial greedy output for repeated chunks. |
| Tests/AudioCLITests/AudioCLITests.swift | CLI parsing tests for transcribe-batch --batch-size and related flags. |
| Sources/Qwen3ASR/Qwen3ASR.swift | Implements transcribeBatch, seq-len bucketing helpers, bulk-sync greedy batch decode, and env-gated experimental true-batched decode. |
| Sources/AudioCLILib/TranscribeBatchCommand.swift | Adds --batch-size grouping for Qwen3 runs and emits new per-batch timing fields. |
| scripts/monitor_apple_gpu.py | Non-interactive powermetrics wrapper for GPU/CPU power sampling during benchmarks. |
| scripts/benchmark_qwen3_batch_decode.py | Fixed-chunk batch-size sweep harness around audio transcribe-batch --jsonl. |
| docs/inference/qwen3-asr-inference.md | Documents the batched greedy decode behavior and experimental gating. |
| docs/benchmarks/qwen3-batch-decode.md | Benchmark methodology, usage, and known limitations for batched decode sweeps. |
| /// Transcribe multiple audio chunks in a single batched decoder pass. | ||
| /// | ||
| /// For greedy decoding (default options) this amortises decoder weight | ||
| /// loads across `B` independent token streams on the MLX/GPU backend. | ||
| /// | ||
| /// Non-greedy options fall back to serial per-chunk decoding. |
| var perItemTokenIds: [MLXArray] = [] | ||
| for b in 0..<B { | ||
| let itemHidden = lastH[b..<(b + 1), 0..., 0...] // [1, 1, H] | ||
| let itemLogits = textDecoder.embedTokens.asLinear(itemHidden) // [1, 1, V] | ||
| perItemTokenIds.append(argMax(itemLogits, axis: -1).asType(.int32)) | ||
| } | ||
| let nextBatchTokenIds = concatenated(perItemTokenIds, axis: 0) // [B, 1] |
| if jsonl { | ||
| let escaped = result | ||
| .replacingOccurrences(of: "\\", with: "\\\\") | ||
| .replacingOccurrences(of: "\"", with: "\\\"") | ||
| print("{\"file\":\"\(name)\",\"text\":\"\(escaped)\"," | ||
| + String(format: "\"time\":%.3f,\"rtf\":%.4f,\"duration\":%.2f}", | ||
| elapsed, rtf, duration)) | ||
| print("{\"file\":\"\(item.name)\",\"text\":\"\(escaped)\"," | ||
| + String(format: "\"time\":%.3f,\"rtf\":%.4f,\"duration\":%.2f,\"batch_time\":%.3f,\"batch_rtf\":%.4f,\"batch_size\":%d}", | ||
| allocatedElapsed, itemRTF, item.duration, elapsed, groupRTF, loaded.count)) |
|
Thanks for raising this. Traced the experimental Default BulkSync path (+11%) is unaffected. mlx-lm's Cleanup follow-up with regression test: ml-explore/mlx-swift#236. |
|
Thanks for tracing this down to I agree with keeping the true |
Context
This is a follow-up to the ASR/Qwen3-ASR infrastructure work tracked in ml-explore/mlx-swift#229. Thanks for reviewing and merging that groundwork; this PR keeps the next optimization small, correctness-first, and benchmark-driven.
Summary
[B, 1]token tensor per decode step.audio transcribe-batch --batch-sizetiming fields for Qwen3 batch runs.[B,1,H]experimental path.Benchmark
Qwen3-ASR 0.6B 4-bit, repeated 10s chunks.
Local benchmark environment: MacBook Pro with an M4 chip, fans set to maximum speed.
56444fd6d4b956444fd6d4b956444fd6d4b9Batch size 8 was slower than 6 in the local sweeps, so the recommended cap should be benchmark-driven.
Known limitation
The true
[B,1,H]MLX decoder forward is still behindQWEN3_ASR_EXPERIMENTAL_BATCH_DECODE=1. It currently fails repeated-chunk row correctness at batch size 2, where row 0 completes and row 1 truncates, so this PR keeps the production path conservative.Validation
make buildswift test --disable-sandbox --filter 'Qwen3ASRBatchedDecodeTests|TranscribeBatchCommandTests'swift test --disable-sandbox --filter E2EQwen3ASRBatchedDecodeCorrectnessTestsmake testpython3 -m py_compile scripts/benchmark_qwen3_batch_decode.py scripts/monitor_apple_gpu.pygit diff --checkRefs ml-explore/mlx-swift#229
Refs ml-explore/mlx-swift#233