← All articles

Same Sentence, 9.32s to 5.29s — Moving Qwen3-TTS from PyTorch to Apple MLX

A real-world test of migrating a local TTS framework: 31% smaller, 1.76× faster inference, zero quality loss — plus three pitfalls that nearly derailed me

Same Sentence, 9.32s to 5.29s — Moving Qwen3-TTS from PyTorch to Apple MLX
On this page 8

voxflow uses Qwen3-TTS for local Chinese voice cloning and voice design, and it has always run on PyTorch + MPS. This time I moved it to Apple MLX, and every number comes from A/B tests on the same M3 Pro.

The Numbers First

Same model, same sentence, same reference audio — only the inference framework changed:

Metric PyTorch + MPS Apple MLX 8-bit Change
Model size (Base + VoiceDesign) 8.4 GB 5.8 GB −31%
Inference time 9.32 s 5.29 s 1.76× faster
Output duration 4.64 s 4.56 s Identical
Audio quality (whisper transcription round-trip) Not a single character off Not a single character off No loss
Peak memory — 6.97 GB (measured) Documented

After changing the code I ran the end-to-end test again: load 7.90s, inference 5.93s, output 4.40s — 2.35× faster than baseline.

Why MLX Is the “Right” Framework on a Mac

Apple Silicon solders the CPU, GPU, and memory onto a single chip, sharing one pool of unified memory. A traditional PC has one chunk of memory for the CPU and another chunk of VRAM for the discrete GPU, with data shuttling across PCIe in between.

That difference decides whether a local model runs smoothly:

Apple M series x86 + discrete GPU
What runs the model Unified memory (one pool for CPU/GPU) VRAM
Does data get moved? No, the CPU writes and the GPU reads directly Yes, across PCIe every time
Not enough VRAM The concept doesn’t exist here It just crashes; your only option is a smaller quantized version

But the real root cause was hiding in a single environment variable.

Root Cause: MPS’s Silent Degradation

The first line of voxflow’s old code was:

os.environ["PYTORCH_ENABLE_MPS_FALLBACK"] = "1"

It means operators MPS doesn’t support silently fall back to the CPU, shipping the tensors back and forth along the way. All the benefit of “unified memory” is wasted at that step, and you can’t see it from the outside — clean logs, no warnings, just slow.

MLX has no fallback: either everything runs on the Metal GPU, or it errors. Silent degradation doesn’t exist.

That’s the biggest win from the migration: not just being 1.76× faster, but that “a GPU operator secretly running on the CPU” can no longer happen.

Three Pitfalls That Nearly Derailed Me

Pitfall 1: An empty ref_text = garbled output

The MLX base model’s clone mode requires ref_text (the transcription of the reference audio). Leave it empty and you get no error — just truncated output with garbled content:

ref_text Output ASR round-trip
"" 2.08s (truncated) 「这是一字语音色争要了根根」❌
Real text 4.56s 「这是一次语音合成测试…」✅ Not a single character off

Fix: transcribe the sample audio with whisper and put it into personas.json. The cost: one extra required field per character — but what it buys is the cloning quality itself.

Pitfall 2: “Emotion instructions” were a capability I assumed, not one I was actually using

The PyTorch version of the base model accepts both x_vector_only_mode=True and instruct_ids. Reading the source, I treated them as “two independent channels, both active”, and almost used that to conclude the migration was lossy.

Then I went through the configuration of all two characters in the project and found:

  • jxx_host goes through cloning and never relied on emotion instructions at all
  • demo_narrator goes through the design path, and its instruction is a timbre description, not a dynamic emotion

The question “is emotion control a hard requirement” is answered by looking at the data — no character in the project uses it. A capability matrix derived from reading code loses to going through the real configuration once.

Pitfall 3: Dependency conflict — transformers 4 vs 5

mlx-audio wants transformers>=5.14, the project pins ==4.57.3, and the two don’t fit in the same venv. My first instinct was “keep PyTorch as a fallback” — later I realized that fallback was a net liability:

  • Every change required confirming the old path still worked
  • A/B tests had to run twice
  • And MLX had already been verified end to end

Delete it. MLX became the only backend, and core/engine.py went from 122 lines down to 61. Rolling back went from “git checkout three files” to “git revert one commit” — the PyTorch weights stay where they are, ready whenever.

Migration Cost and Verification

Under 50 lines changed, concentrated in engine loading plus 3 call sites:

# MLX 的 API 跟 PyTorch 几乎同签名
results = list(model.generate(
    text=text,
    ref_audio=seed_path,      # 文件路径直接传
    ref_text=ref_text,        # 必填
    lang_code="chinese",
    temperature=0.7, top_k=50, top_p=0.9,
))
audio = results[0].audio      # mx.array

Verification method: transcribe both versions’ output back to text with whisper — not a single character off is the pass bar. “Sounds about the same” doesn’t count — an ASR round-trip as objective comparison is stricter than an A/B test by ear.

What This Actually Means for an 18GB Mac

迁移前:TTS 8.4G + ASR 1.5G + 视觉 3.0G = 12.9G  ← 18G 机器爆边缘
迁移后:TTS 5.8G + ASR 1.5G + 视觉 3.0G = 10.3G  ← 留出余量

The 2.6 GB freed up by TTS is exactly the key that lets “voice + image recognition” stay resident at the same time. Lazy loading and idle unloading are no longer optimizations; they’re a hard constraint that couldn’t be met before.

When You Shouldn’t Migrate

  • Not Apple Silicon — MLX is Mac-only
  • Model > unified memory — MLX’s advantage comes from zero-copy; if it doesn’t fit in memory, all of that goes to zero
  • Running in the cloud — CUDA is more mature there, and MLX makes no sense
  • You need operators MPS has already tuned — run an A/B on your real workload before migrating; don’t trust my numbers

Reflecting on This Round

The most valuable thing wasn’t that comparison table; it was the two judgments that got overturned along the way:

  1. “AI image generation writes the wrong numbers, so AI can’t draw technical comparison graphics” — gpt-image-2 slapped that down on the first try, getting the Chinese text and every number right in one shot. Using an old impression as a conclusion without verifying it was my biggest mistake this round.
  2. “The capability matrix comes from reading the source” — no matter how carefully you read, it loses to going through the project’s real configuration once. Both “nearly lossy migration” misjudgments died from not looking at the data first.

Don’t guess, measure. The time it takes to run one A/B test is shorter than arguing for an afternoon.

🚀相关开源工具与实践
读完不过瘾?把文中思考变成可落地的生产力
🎬

reel-kit

创作者工具

竖版短视频生产工作台(UI+CLI) —— 素材+文案+配音,套 HTML 模板极速出片。

💬

cn-chat-style-gen

内容神器

高质感聊天氛围图生成器:对话/朋友圈/列表多模式,爆款短视频图文必备。

🔮

ai-orb

前端组件

一颗会表态的 AI 助手状态球(思考/工作/等待/就绪),零依赖 4KB。

webkubor

关于作者 · 栖洲 (webkubor)

把审美做成工程 —— 经得起生产环境检验的 AI 系统、独立开发工作流与极客主题。