0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PyInstaller + faster-whisper + voicevox_core をexe化して遭遇したDLLクラッシュとサイズ削減

0
Last updated at Posted at 2026-05-23

はじめに

前回の「VOICEVOX HTTP API のレイテンシを削る.最終的に voicevox_core に移行した話」では,VOICEVOX HTTP API のレイテンシ改善から最終的に voicevox_core へ移行するまでを書きました.

今回はその続きとして,voicevox_core や faster-whisper を組み込んだアプリを PyInstaller で exe 化した際に遭遇した DLL 問題や,Python 側にログすら残らないネイティブクラッシュとの戦い,さらに配布サイズ削減までをまとめます.


この記事で扱う問題

  • CUDA DLL でアクセス違反クラッシュ
  • collect_all 後のフィルタが効かない罠
  • ctranslate2 の DLL ロード順問題でアクセス違反
  • MSVCP140.dll バージョン不一致(全クラッシュの真因)
  • OpenMP DLL の二重ロード衝突
  • os.add_dll_directory の戻り値GC罠
  • faster_whisper と av の隠れた依存
  • Whisper ウォームアップのスレッド競合で初回認識失敗
  • exe サイズ 600MB → 270MB 削減

ネイティブクラッシュ系は Python 側のログに何も出ないことが多いです.
faulthandler や crash.log が空でもあきらめるな.答えはイベントビューアーにある.


環境

  • Python 3.12.4
  • PyInstaller 6.20.0
  • faster-whisper(ctranslate2 4.7.2)
  • voicevox_core(blocking API)
  • customtkinter
  • Windows(64bit)

依存関係の全体像

今回問題になったライブラリの依存ツリーを先に整理しておきます.
クラッシュの原因は末端の VC++ Runtime まで追わないと見えてきません.

+-----------------------------+
|        Python アプリ        |
+-----------------------------+
      |              |
      v              v
+-------------+  +-------------+
|faster-whisper|  |voicevox_core|
+-------------+  +-------------+
      |              |
      v              v
+-------------+  +-------------+
| ctranslate2 |  |OpenJTalk辞書|
+-------------+  +-------------+
      |
 +----+----+
 |         |
 v         v
CUDA DLL  libiomp5md
群         (OpenMP)
      \    /
       \  /
        vv
+-------------------------+
| VC++ Runtime            |
| MSVCP140.dll            |
| PyInstaller同梱版は古い  |
+-------------------------+
         |
         v
+--------------------+
|     Windows OS     |
+--------------------+

ハマり1: ctranslate2 の CUDA DLL が原因でアクセス違反クラッシュ

症状

CUDA非搭載PCでexeを起動すると即クラッシュします.

原因

ctranslate2 には CUDA 用スタブ DLL(cudnn.dll, cublas.dll など)が同梱されており,import ctranslate2 時に ctypes.CDLL で強制ロードされます.CUDA 非搭載環境では当然アクセス違反になります.

失敗した対策

specファイルの collect_all("ctranslate2") の直後でフィルタリングしても意味がありません.

# ❌ これは効かない
ct2_datas, ct2_binaries, ct2_hiddenimports = collect_all("ctranslate2")
ct2_binaries = [(s, d) for s, d in ct2_binaries if not CUDA_RE.search(s)]

Analysis() 内部で再収集されるため,フィルタが無効になります.

正しい対策

Analysis()a.binaries を直接書き換えます.

a = Analysis([...])

_CUDA_RE = re.compile(
    r"(cudnn|cublas|cublasLt|curand|cusolver|cusparse|cufft|cudart|nvcuda|nvrtc)",
    re.IGNORECASE,
)

a.binaries = [
    (name, path, typ) for name, path, typ in a.binaries
    if not _CUDA_RE.search(name)
]

ハマり2: ctranslate2 の DLL ロード順問題でアクセス違反(PyInstaller環境限定)

症状

CUDA DLL を除外してもまだクラッシュします.

原因

ctranslate2/__init__.py が起動時に以下のようなコードを実行しています(実際のコードは異なりますが,動作は同様).

for dll in glob.glob(os.path.join(os.path.dirname(__file__), "*.dll")):
    ctypes.CDLL(dll)

PyInstaller環境では ctranslate2/ サブフォルダ内の DLL が想定と異なる順序でロードされることがあり,依存関係が満たされていない状態でロードされてアクセス違反が起きます.

対策

specファイルで ctranslate2/ サブフォルダ内の DLL を全て _internal/ 直下(sys._MEIPASS)に移動します.これにより glob.glob が何も見つけられなくなります.

_new_binaries = []
for _name, _path, _typecode in a.binaries:
    _norm = _name.replace('\\', '/')
    if _norm.startswith('ctranslate2/') and _norm.lower().endswith('.dll'):
        if not _CUDA_RE.search(_norm):
            # ctranslate2/ サブフォルダからルートへ移動
            _new_binaries.append((os.path.basename(_name), _path, _typecode))
    else:
        _new_binaries.append((_name, _path, _typecode))
a.binaries = _new_binaries

そして launcher.pysys._MEIPASS を DLL 検索パスに追加します.

_dll_dirs = []  # GCされないようにモジュールレベルで保持

if getattr(sys, "frozen", False):
    _dll_dirs.append(os.add_dll_directory(sys._MEIPASS))
    for _sub in ("ctranslate2", "av.libs", "numpy.libs", "scipy.libs"):
        _d = os.path.join(sys._MEIPASS, _sub)
        if os.path.isdir(_d):
            _dll_dirs.append(os.add_dll_directory(_d))

os.add_dll_directory() の戻り値はハンドルで,GCされると検索パスから削除されます.必ずリストに保持してください(後述のハマり5も参照).


ハマり3(真犯人): MSVCP140.dll のバージョン不一致

症状

上記を全部対策してもまだクラッシュします.fault.log / faulthandler にはスタックトレースすら出ません.

原因の特定方法

Python 側には有効な情報が残っていませんでした.
faulthandler を仕込んでいましたが,ネイティブクラッシュが早すぎてPythonランタイム自体が落ちており何も記録されません.

決定打になったのは Windowsのイベントビューアーeventvwr.msc)でした.
Windowsログ → アプリケーション を確認すると:

障害が発生しているモジュール名: MSVCP140.dll
障害が発生しているモジュールのバージョン: 14.16.27033.0

PyInstaller が同梱する MSVCP140.dll はバージョン 14.16(2019年製) で古いです.ctranslate2 が要求する新しい VC++ ランタイムと食い違い,アクセス違反を起こしていました.

→ これが全クラッシュの真因でした.

ネイティブクラッシュでPythonログが空のときはイベントビューアーを最初に確認します.
「障害が発生しているモジュール名」の一行が全てを教えてくれます.

対策

VC++ ランタイム DLL をバンドルから除外し,OSにインストール済みのものを使います.

_VCRT_RE = re.compile(
    r"^(MSVCP140|VCRUNTIME140|VCRUNTIME140_1|CONCRT140|MSVCP140_1|MSVCP140_2)\.dll$",
    re.IGNORECASE,
)

_new_binaries = []
for _name, _path, _typecode in a.binaries:
    _basename = os.path.basename(_name.replace('\\', '/'))
    if _VCRT_RE.match(_basename):
        pass  # 除外(システムの MSVCP140.dll を使う)
    else:
        _new_binaries.append((_name, _path, _typecode))
a.binaries = _new_binaries

VC++ ランタイムをバンドルしないので,配布先のPCに Microsoft Visual C++ 再頒布可能パッケージ(2015-2022) が必要です.


ハマり4: OpenMP DLL の二重ロード

症状

numpy と ctranslate2 が両方 libiomp5md.dll をロードしようとして衝突し,警告またはクラッシュします.

対策

launcher.py の冒頭で環境変数を設定します.

os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE")

ハマり5: os.add_dll_directory の戻り値を捨てていた

症状

os.add_dll_directory() を呼んでも DLL が見つかりません.

原因

戻り値(ハンドル)を変数に保持しないと GC に回収され,検索パスから削除されます.

# ❌ NG - GCされた瞬間に検索パスから消える
os.add_dll_directory(sys._MEIPASS)

# ✅ OK - モジュールレベルのリストで生存させる
_dll_dirs = []
_dll_dirs.append(os.add_dll_directory(sys._MEIPASS))

ハマり6: faster_whisper が av をモジュールレベルでimportしている

症状

サイズ削減のため excludes=["av"] を specファイルに追加して再ビルドしたところ,起動時に以下のエラーが出ます.

File "faster_whisper\audio.py", line 15, in <module>
ModuleNotFoundError: No module named 'av'

原因

faster_whisper は numpy 配列を直接渡す場合でも,audio.py のモジュールレベルで import av を実行します.excludes に入れるだけでは起動時に即死します.

対策

空のスタブパッケージで import av を通しつつ,a.binaries から av.libs(FFmpeg DLL群,65MB)を手動除外します.

# _av_stub/av/__init__.py
# PyInstaller用スタブ.numpy配列を直接渡すのでavの実機能は呼ばれない.
# specファイル
a = Analysis(["launcher.py"], pathex=["_av_stub"], ...)  # スタブを優先

# a.binaries から av.libs を手動除外
elif _norm.startswith('av.libs/'):
    pass  # 65MB削減

ハマり7: 初回音声認識が失敗し,次の発話で前後のテキストが混在する

症状

  • アプリ起動直後の最初の発話が認識されません
  • 次に話したとき,前の発話と今回の発話が一緒に読み上げられます

原因

Whisper のウォームアップ(ONNX ランタイムの事前初期化)を別スレッドで実行していたため,最初の実際の音声認識と並行して走ってしまっていました.WhisperModel.transcribe() はスレッドセーフではないため,初回認識が失敗していました.

また without_timestamps=True オプションがセグメント生成の挙動を変え,前後の発話テキストが混在する原因にもなっていました.

対策

ウォームアップをモデルロードと同じスレッドで完了させてから,開始ボタンを有効化します.

def _load():
    self._engine.reload_model(...)
    self._engine.warmup()  # ここで完了させる(別スレッドに投げない)
    def _done():
        self._toggle_btn.configure(state="normal")  # 完了後に有効化
    self.after(0, _done)

また without_timestamps=True は使いません.


最終的な spec ファイルの構成

a.binaries の後処理(順番に適用):
1. VC++ ランタイム DLL を除外(MSVCP140 等)
2. CUDA スタブ DLL を除外(cudnn 等)
3. ctranslate2/ サブフォルダの DLL を _internal/ 直下に移動
4. av.libs/ DLL を除外(スタブで代替)
5. dbghelp.dll, sqlite3.dll を除外
launcher.py の役割:
1. faulthandler で native クラッシュログ出力
2. KMP_DUPLICATE_LIB_OK 設定
3. os.add_dll_directory でハンドルを保持しつつ検索パス追加
4. 例外ログ(excepthook / threading.excepthook)

まとめ

問題 原因 対策
CUDA DLL クラッシュ collect_all後フィルタが無効 Analysis後に a.binaries を書き換え
ctranslate2 DLLロード順問題 glob.glob で想定外の順序でロード DLLを _internal/ 直下に移動
真犯人 MSVCP140.dll バージョン不一致 VC++ランタイムをバンドルから除外
OpenMP 衝突 numpy+ctranslate2 二重ロード KMP_DUPLICATE_LIB_OK=TRUE
DLL 検索パス消える add_dll_directory の戻り値GC リストで保持
av 除外で起動クラッシュ faster_whisper がモジュールレベルでimport 空スタブで代替 + a.binaries から手動除外
初回認識失敗・発話混在 ウォームアップとの並行transcribe ウォームアップを同スレッドで先に完了させる

ネイティブクラッシュでPythonログが空なら,まずイベントビューアー(eventvwr.msc)を見てください.
「障害が発生しているモジュール名」の一行が全てを教えてくれます.


サイズ削減の取り組み

最終的な uncompressed サイズの変化:

状態 サイズ
初期ビルド ~600MB
scipy 除外 447MB
av スタブ化 + pygments/hf_xet 除外 366MB
VVM ファイル同梱廃止 + onnxruntime/setuptools/rich/dbghelp/sqlite3 除外 270MB

削減できたもの

scipy(83MB)
リサンプリング(resample_poly)にしか使っていなかったため,numpy.interp で代替して除外しました.

def _resample(audio: np.ndarray, orig_sr: int, target_sr: int) -> np.ndarray:
    if orig_sr == target_sr:
        return audio
    target_len = round(audio.shape[0] * target_sr / orig_sr)
    x_orig = np.arange(audio.shape[0], dtype=np.float64)
    x_new  = np.linspace(0, audio.shape[0] - 1, target_len)
    if audio.ndim == 1:
        return np.interp(x_new, x_orig, audio).astype(np.float32)
    return np.column_stack([
        np.interp(x_new, x_orig, audio[:, i])
        for i in range(audio.shape[1])
    ]).astype(np.float32)

av.libs(65MB)
faster_whisper は numpy 配列を直接渡す場合,av の実機能(FFmpeg)を呼びません.空のスタブパッケージで import av を通し,av.libs の DLL を a.binaries から手動除外しました.(ハマり6参照)

VVM 音声モデル(58MB)
配布時にモデルファイルを同梱せず,アプリ内のキャラクター管理画面からユーザーが個別にダウンロードする形にしました.キャラクターの利用規約上も同梱より安全です.

その他
excludes=["scipy", "pygments", "hf_xet", "onnxruntime", "setuptools", "rich"] および dbghelp.dll, sqlite3.dlla.binaries から除外しました.

削減できなかったもの(機能上の下限)

コンポーネント サイズ 理由
open_jtalk_dic 103MB voicevox_core の日本語辞書,必須
ctranslate2.dll 58MB Whisper 推論エンジン,必須
numpy.libs 21MB 数値計算ライブラリ,必須

さらに削減するには voicevox_core を外して VOICEVOX HTTP API に戻す(open_jtalk_dic 103MB が消えます)か,Whisper をやめて別の音声認識に切り替えるしかありません.


完成したアプリ画面

DLL問題やサイズ削減を経て,最終的に以下のような形になりました.

LuRuRu1.png

音声認識→音声返答をローカルで完結できる構成になっています.
内部では faster-whisper と voicevox_core を利用しています.


未解決の課題

exeとスクリプト実行の速度差

python app.py と比較してexe版のWhisper推論が若干遅いです.cpu_threads=os.cpu_count(), num_workers=2, temperature=0 を指定することで改善しましたが,完全には解消していません.PyInstaller の import 処理や frozen 環境特有のオーバーヘッドが影響している可能性があります.

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?