はじめに
前回の「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.py で sys._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.dll を a.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問題やサイズ削減を経て,最終的に以下のような形になりました.
音声認識→音声返答をローカルで完結できる構成になっています.
内部では faster-whisper と voicevox_core を利用しています.
未解決の課題
exeとスクリプト実行の速度差
python app.py と比較してexe版のWhisper推論が若干遅いです.cpu_threads=os.cpu_count(), num_workers=2, temperature=0 を指定することで改善しましたが,完全には解消していません.PyInstaller の import 処理や frozen 環境特有のオーバーヘッドが影響している可能性があります.
