🚀

AITuber向けに Mac MPS で Irodori-TTS をチューニングして2.72倍高速化 / bf16 では遅くなった

に公開

TL;DR

Apple Silicon の MacBook で AITuber 用の音声合成を回しています。Irodori-TTS(FlowMatching ベースの 500M モデル)を運用に乗せる過程で、生成時間そのものと体感待ち時間の両方を詰め、旧構成比 2.72倍 までチューニングしました。

生成時間を直接縮めた効果:

指標 旧構成(linear_40) 採用後 改善
5 文 162 字 total_gen 28.77s 10.58s 2.72x
30 字級 平均 gen 約 5.3s(28.77s ÷ 162 字 × 30 字で線形外挿) 1.84s 2.9x
overall_RTF(total_gen ÷ total_audio、HTTP 込み) ~0.93 0.434 2.14x

定常時の単発生成時間に直接効いたのは (A) Sway Sampling(E) seconds マージン削減の 2 つです。残りの (B) HTTP サーバ常駐化(C) 合成タスクの prefetch(D) 起動時 prewarm は、定常時の単発生成ではなく起動コスト除去・連続発話のレイテンシ隠し・コールドスタート吸収を担う層で、5 文 total_gen のベンチ数字には直接は関係しません。本番運用にはどれも必須なので併せて紹介します。

採用した最適化は以下の 5 層です。

  1. Sway Sampling(Irodori-TTS PR #10、未マージ) — 単発生成時間に効く
  2. HTTP サーバ常駐化+ asyncio.Lock で MPS 直列化 — 起動コスト除去・本番運用の前提
  3. 合成タスクの prefetch(再生中に裏で次を生成) — 連続発話の待ち時間を隠す
  4. 起動時 prewarm(Metal グラフコンパイル吸収) — コールドスタート吸収(初回リクエストの安定化)
  5. seconds 推定式のマージン削減(短文の無駄打ち除去) — 単発生成時間に効く

ベンチを取りながら検証する過程で、bf16 を MPS に乗せる試みが逆に遅くなるという反直感的な事実にもぶつかりました。後半でその失敗も含めて書きます。

1. 背景:AITuber を Mac で常時運用したい

自分は kurara-aituber というニュースライブ配信キャラを Mac 上でフルローカルに動かしています。LLM 経路は外部 API(Gemini)ですが、音声合成は完全ローカルにしたい。理由は単純で、配信時間に比例した API 課金を避けたいからです。

最初は MioTTS を使っていましたが、声質をくらら用にカスタムする要件があり、ref-wav 1 サンプルからのゼロショットクローンに強い Aratako/Irodori-TTS に切り替えました。FlowMatching ベースの 500M モデルで、品質は十分だったものの、デフォルト設定(linear スケジュールで 40 step)だと生成が遅く、ニュース読み上げ系の発話ではユーザーを待たせてしまう。

具体的には、5 文 162 字の生成に 28.77 秒。RTF(Real-Time Factor、生成時間 ÷ 音声長)でいうと約 0.93 で、ほぼリアルタイムには間に合わない水準でした。AITuber 配信は「次の発話の合成が間に合わない=沈黙」になるので、ここを詰める必要がありました。

2. 環境とベース構成

項目
マシン MacBook Pro 16-inch 2021(Apple M1 Max / 32GB RAM)
OS macOS 26.4
PyTorch 2.10.0(MPS バックエンド)
モデル Aratako/Irodori-TTS-500M-v2
推論デバイス MPS(Metal Performance Shaders)
旧構成 infer.py を毎回サブプロセス起動、linear schedule × 40 step

ベンチは「ニュース 1 本想定の 5 センテンス(合計 162 字)」を共通の入力として、time.perf_counter() で計測しています。

3. 効いた最適化を積み上げる

3.1 Sway Sampling(PR #10)— 単独で 2.19x

Irodori-TTS には PR #10 として、FlowMatching の t スケジューリングに sway sampling を導入する変更が提案されています(執筆時点で未マージ・OPEN)。本記事では fork に取り込んで使っています。linear で 40 step 必要だったところを sway で 8 step まで削れました。

採用設定:

num_steps: 8
t_schedule_mode: sway
sway_coeff: -1.0

ベンチ結果:

設定 num_steps total_gen(5 文) 音質
linear_40(旧) 40 28.77s 基準
sway_6 6 12.5s 前後 ❌ 末尾ノイズ(trim_tail が間に合わない)
sway_8(採用) 8 13.13s ✅ 耳判定で linear と差なし
sway_10 10 14.5s 前後 sway_8 と同等

PR の作者推奨は num_steps=6 でしたが、Mac MPS で試したところ末尾の trim_tail(無音末尾の自動カット)が間に合わず、文末にノイズが入りました。num_steps=8 まで上げるとノイズは解消し、音質は linear_40 と区別がつきません。Mac では推奨より 1〜2 step 多めに取るのが安全でした。

これだけで 28.77s → 13.13s(2.19x)です。

3.2 HTTP サーバ常駐化

旧構成では infer.py をサブプロセスで毎回起動していたので、毎回モデルロード(5〜10 秒)が走っていました。これを潰すために FastAPI で常駐サーバ化します。

import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI

_runtime: InferenceRuntime | None = None
_synth_lock = asyncio.Lock()  # ← MPS 保護用


@asynccontextmanager
async def lifespan(app: FastAPI):
    global _runtime
    _runtime = _load_runtime()
    yield
    _runtime = None


app = FastAPI(lifespan=lifespan)


@app.post("/tts")
async def synthesize(req: TTSRequest):
    seconds = _estimate_seconds(req.text)
    async with _synth_lock:  # ← 並列リクエストを直列化
        return await asyncio.to_thread(_synthesize_locked, req.text, seconds)

呼び出し側(自分の場合は saint_graph という配信制御プロセス)は httpx で叩くだけです。

これは実際にハマって、配信実験中にサーバが落ちて発見した罠でした。CUDA だと並列実行で速度が出るのが普通の感覚なので、Mac で同じノリで書くと事故ります。

3.3 合成タスクの prefetch

直列化したからといってレイテンシを諦める必要はありません。「再生中に次の発話を裏で合成しておく」 prefetch を入れます。

# 概略:speak action を queue に入れる時点で背景タスクを開始
async def enqueue_speak(text: str):
    audio_task = asyncio.create_task(_synth_via_http(text))
    await speak_queue.put({"text": text, "audio_task": audio_task})

# worker は再生時に await するだけ
async def speak_worker():
    while True:
        item = await speak_queue.get()
        audio_path = await item["audio_task"]  # 既に終わってればここで即返る
        await play(audio_path)

ニュース複数本を queue 投入すると、最初の 1 本を再生している間に 2 本目以降が裏で順次合成されます。asyncio.Lock でサーバ側が直列化されていても、呼び出し側のパイプライン化で「再生待ち」をほぼゼロにできるのがポイントです。

3.4 起動時 prewarm

サーバ起動直後の最初の合成リクエストは、Metal のグラフコンパイルが走るため数秒〜十数秒重くなります。これをユーザー体験に出さないために、lifespan で起動直後にダミー合成を 1 発打ちます。

def _prewarm(runtime: InferenceRuntime) -> None:
    """初回 synth は MPS Metal グラフコンパイルで数秒重い。起動時に 1 発打って吸収する。"""
    t0 = time.perf_counter()
    runtime.synthesize(SamplingRequest(
        text="ウォームアップ。",
        ref_wav=REF_WAV,
        seconds=8.0,
        num_steps=NUM_STEPS,
        t_schedule_mode=T_SCHEDULE_MODE,
        sway_coeff=SWAY_COEFF,
        # ... 通常の synth と同じ設定
    ))
    logger.info("Prewarm done in %.2fs", time.perf_counter() - t0)


@asynccontextmanager
async def lifespan(app: FastAPI):
    global _runtime
    _runtime = _load_runtime()
    _prewarm(_runtime)  # ← ここで初回コンパイルを吸収
    yield
    _runtime = None

実測で起動時間に +2.78 秒載りますが、その代わり最初のリクエストから安定速度で返ります。

3.5 seconds 推定式のマージン削減

Irodori-TTS は seconds 引数で「生成する音声の長さ」を指定します。重要なのは、生成時間がこの seconds 値にリニア比例する仕様だということです。

最初は雑に長めに取っていました。

# Before
def _estimate_seconds(text: str) -> float:
    est = len(text) / 4.5 + 2.0   # 日本語は 1 秒 ≒ 4.5 文字
    return max(8.0, min(30.0, est))

これだと「次のニュースだよっ」のような 10 字の短文でも seconds=8.0 で生成してしまい、本来 1.6 秒の音声を出すのに 8 秒分の latent を計算する無駄が発生します。

# After
def _estimate_seconds(text: str) -> float:
    est = len(text) / 4.5 + 1.0
    return max(4.0, min(30.0, est))

下限を 8.0 → 4.0、マージンを +2.0 → +1.0 に縮めました。短文の生成時間は 1.89s → 1.14s(-40%)まで削れます。中長文(30 字以上)は元から seconds が下限を超えるのでマージン削減(+2.0→+1.0)の分だけ効きます。30 字付近で約 -12%、長文になるほど効果は小さくなります(100 字なら約 -4%)。

末尾切れリスクが上がるので、trim_tail=True(無音末尾の自動カット)が前提です。マージンを削りすぎると稀に末尾の意味語が落ちることがあるので、実際に使う文長分布で耳判定してから本番に入れるのがおすすめです。自分の用途(ニュース読み上げ、30〜100 字中心)では +1.0 が安定でした。

4. ベンチ結果(最終)

5 層を全部入れた状態で計測。

# 文字数 gen 時間 音声長 RTF
s1 10 1.14s 1.52s 0.752
s2 41 2.48s 5.16s 0.481
s3 56 3.27s 8.28s 0.395
s4 26 1.77s 4.48s 0.396
s5 29 1.91s 4.96s 0.385
  • 5 文 total_gen: 10.58s(旧 linear_40 比 2.72x)
  • 30 字級 平均 gen: 1.84s(30 字を 2 秒以内で合成できる水準に到達)
  • overall_RTF: 0.434(total_gen ÷ total_audio で算出。各文 RTF の単純平均ではないので注意)

短文の RTF(s1: 0.752)が他より高めですが、これは「短文ほど固定オーバーヘッドの比率が大きい」だけで、絶対時間(1.14s)は十分実用的です。配信用途では 30 字級が中心なので、その帯域での安定が大事でした。

ここまでの累計改善グラフ的にはこんな感じです。生成時間に直接効いた層だけを比較します。

旧 linear_40        28.77s ████████████████████████████
+ sway_8            13.13s ████████████              (2.19x)
+ seconds マージン   10.58s ██████████                (2.72x)

このグラフには HTTP 常駐化と prefetch は載っていません。それらは「サブプロセス起動コストの除去」と「連続発話の体感レイテンシ隠し」を担っており、5 文の単純な total_gen を比較するベンチには現れにくい性質の最適化です。とはいえ本番では併用必須で、特に prefetch を入れると体感では「最初の 1 発以外は再生待ちがほぼゼロ」になります。

5. 不採用:bf16 on MPS で逆に遅くなった話

ここからは「ベンチを取らないと到達できなかった反直感的な発見」です。

5.1 なぜ試したか

CUDA 環境では、モデルや実装次第で fp32 → bf16 が高速化に効くケースがあります。sway sampling と直交する最適化なので、Mac MPS でも取り込めば組み合わせでさらに伸びる可能性がある——という仮説で着手しました。検証は PyTorch 2.10.0 です。

ただ、Irodori-TTS 本体には bf16 を CUDA 限定にする明示的なガードが入っていました。

# inference_runtime.py(修正前)
def resolve_runtime_dtype(*, precision: str, device: torch.device) -> torch.dtype:
    if precision == "bf16":
        if device.type != "cuda":
            raise ValueError("precision='bf16' currently requires CUDA device.")
        return torch.bfloat16

本家を fork して、MPS でも bf16 を選択できるようにガードを外しました。

def resolve_runtime_dtype(*, precision: str, device: torch.device) -> torch.dtype:
    if precision == "bf16":
        if device.type in ("cuda", "mps"):
            return torch.bfloat16
        raise ValueError(f"precision='bf16' requires CUDA or MPS, got {device.type!r}.")

これで MPS でも precision="bf16" が通るようになったので、4 設定でベンチを回しました。

5.2 何が起きたか

計測経路は Irodori-TTS の直接呼び出し(HTTP 経由ではない)。FastAPI サーバ経由のオーバーヘッドが乗らないぶん、絶対値は 4 章のベンチ(HTTP 経由 10.58s)より小さく出ます。比較対象として 4 設定すべて同じ条件で測りました。

設定 計測経路 total_gen(5 文) overall_RTF 結果
fp32@mps + codec fp32@mps(現状) 直接 9.10s 0.376 ✅ 最速
fp32@mps + codec fp32@cpu 直接 17.96s 0.74 約 2 倍遅い
bf16@mps + codec bf16@mps 直接 10.81s 0.412 0.84x(19% 遅い)
bf16@mps + codec fp32@cpu 直接 MPS assertion でクラッシュ

5.3 なぜ遅くなったか

PYTORCH_ENABLE_MPS_FALLBACK=1 を有効にした状態で MPS に bf16 を載せると、未対応 op に当たった時点で CPU にフォールバックします(fallback を無効にすると例外で落ちます)。fallback 自体は動作しますが、MPS↔CPU 間のテンソル転送コストが発生するため、結果として fp32 で MPS 内で完結させた方が速くなる、という構造でした。

mixed precision(model bf16、codec fp32)も試しましたが、MPS の MPSNDArrayMatrixMultiplication で datatype mismatch のアサートが出てクラッシュします。

failed assertion `Destination NDArray and Accumulator NDArray cannot have
different datatype in MPSNDArrayMatrixMultiplication'

bf16 と fp32 のテンソルを同じ matmul に流すのは MPS では現状サポートされていません。

5.4 副産物の発見

ベンチを取っていて気づいたのが、codec=cpu にすると約 2 倍遅くなるという事実です。Irodori-TTS には PR #2 という macOS 向け改善があり、そこでは「macOS では codec=cpu を推奨デフォルトに」という変更が提案されています。

これは互換性・安定性の観点では合理的な判断(MPS では一部 codec op が落ちることがある)ですが、速度面ではペナルティになります。Apple Silicon Mac で codec が MPS で問題なく動く環境であれば、自分の用途では codec=mps fp32 で統一する方が速く回りました。

5.5 着地

bf16 ブランチは破棄しました。upstream への PR も出していません。「MPS で機能としては動くけど速くはならない」というだけの変更だと、負担が増えるだけで実利が薄いからです。

将来 PyTorch MPS の bf16 op 対応がさらに広がったら再評価する価値はあります。それまでは、自分の環境(M1 Max で FlowMatching TTS)では fp32@mps + codec fp32@mps が一番速かった、という結論にしています。

6. なぜこれ以上速くできないか

ここまでで頭打ちが見えてきたので、「その他の最適化が残っているか」を整理しておきます。

方向 状況
並列化 MPS Lock 必須で効果が出にくい
dtype 削減 上記の通り bf16 で逆に遅い、fp16 は未検証
num_steps 削減 sway で 8 まで削った、これ以上は音質と引き換え
seconds マージン +1.0 まで削った、これ以上は末尾品質リスク

残ってる選択肢としては:

  • 音声キャッシュ:定型句(「次のニュースです」等)を事前生成して使い回す。配信用途なら効きやすい
  • MLX 移植:Apple 純正 ML フレームワーク。他のモデルでは PyTorch+MPS より速くなる事例も見かけるが、FlowMatching の MLX 化は手動移植が必要
  • CoreML / Apple Neural Engine:op 対応次第だが、ハマれば大きな改善余地がある

このあたりは工数が大きいので、一旦現状の構成で本番運用しながら、効きが大きそうなところから順に試していく予定です。

7. まとめ

5 層の最適化で MacBook 上の Irodori-TTS を旧構成比 2.72x まで詰めました。30 字級の音声を 2 秒以内で合成できる水準に到達し、AITuber のリアルタイム配信に乗せられる速度になっています。

詰める過程で得た学びをまとめます。

  1. Apple Silicon で TTS を本番投入するなら 5 層の組み合わせが現実解:sway sampling、HTTP 常駐、prefetch、prewarm、seconds マージン調整。どれか 1 つではなく重ね掛けで効果を発揮します
  2. MPS の Metal command buffer はスレッドセーフではないasyncio.Lock で直列化必須。これを知らずに並列化するとサーバがクラッシュします
  3. この Irodori-TTS 構成では bf16 on MPS は現状 op fallback で逆に遅かった。CUDA の常識をそのまま持ち込めない例で、ベンチを取らないと「fp32@mps が最速」という反直感的な結論には届きません
  4. codec を CPU に逃がすと約 2 倍遅い。互換性のためのデフォルト推奨は、必ずしも速度推奨ではない

数字を出すコストは数十分でした。最適化前に必ずベンチを取る、という基本を改めて確認した実験でもありました。

実際にこの構成で配信した記録はこちらです。

https://www.youtube.com/live/AdBsBzvfYl0

次のステップ

  • 音声キャッシュ:定型句のヒット率測定 → 事前生成バンク
  • 本番配信での RTF 観測:ニュース文長分布の実データを蓄積して、seconds マージンをさらにチューニング
  • MLX 移植の検討:FlowMatching 系の MLX 実装は調査中

続編が書けるくらいの結果が出たら、また記事にします。


AI 共創について: 本記事のコード実装、ベンチマーク設計、記事執筆は Claude Code(Anthropic)との協働で行いました。

参考リンク:

Discussion