VOICEVOX/voicevox_engine

`/speaker_info`と`/singer_info`の高速化

sabonerune opened this issue · 27 comments

内容

VOICEVOXエディタのデータ準備中の待機時間が話者追加に伴って増加傾向にあります。
この原因の一つに/speaker_info/singer_infoの取得に時間がかかっている点があると思います。

ref: #1073 (comment)
このコメントのBlockingのURL、Load9118からLoad9952までが/speaker_info/singer_infoの取得処理だと思います。

Pros 良くなる点

エディタの起動速度改善

Cons 悪くなる点

コードの複雑化やそれに伴うバグ

実現方法

  • ref: #1126
    レスポンスヘッダにETagを付与することによってブラウザのキャッシュを活用する。

あとそもそもの処理速度が上がれば他の方法でもいいのですが…

その他

Load9118からLoad9952Waiting for socket threadが伸びているのはHTTP1.1の同時接続制限によるものだと思います。
しかしFirefoxの設定から強引に同時接続数を増やしても速度がほぼ改善しなかったことから接続数の方は大きなボトルネックではないように思います。(そもそもこの制限を回避することは非常に困難だと思います)

VOICEVOXエディタのデータ準備中の待機時間が話者追加に伴って増加傾向

👍
明確な UX 上の問題点だと認識しました。


このコメントのBlockingのURL、Load9118からLoad9952までが/speaker_info/singer_infoの取得処理

プロファイルにリクエストURLがあれば確定できるんですが、このプロファイルには無さそうですね。
エディタの実動作を知らないのですが、Load9118 ~ Load9947 で30リクエストが同時発行されています。現在の speaker 数は 30 キャラなので、同じ数です。
なのでここのリクエスト実体は:

  • Load9117: GET /speakers でキャラ一覧取得
  • Load9118 ~ Load9947: GET /speaker_info を全キャラ同時リクエスト

と解釈するのが妥当そうに見えます(本当はエディタコードおよびプロファイル中の URL を確認すべき)。


HTTP1.1の同時接続制限

👍
正しい解釈に見えます。
30 の同時リクエストのうち、Load9118 ~ Load9123 の 6 リクエストが同時に実通信を開始しています。HTTP1.1 の同時接続上限は 6 であり、同じ数です。


Firefoxの設定から強引に同時接続数を増やしても速度がほぼ改善しなかったことから接続数の方は大きなボトルネックではないように思います。

この解釈はあり得る一方、早計な気もします。

GET /speaker_info の内部処理は話者フィルタリング+ファイルロードであり、話者依存性が小さいです。
Load9118 は 0.371 秒でリクエスト完了しているため、他話者であっても 1 秒はかからないと想定されます。
なので 30 の同時リクエストが parallel に処理されれば、30リクエスト全てが 1 秒以内に処理されうるはずです。

しかし同時接続設定をいじってもこうならなかったわけで、その解釈は以下の2通りに絞られそうです:

  • Firefox 設定が上手く効いていない
  • ENGINE 内部に parallel 処理をブロックする何かがある(例: I/O 速度限界、mutex)

前者の可能性は #1073 (comment) と同様のプロファイルを取れば排除できるはずです。

@sabonerune
このプロファイルを取るのはかなり手間でしょうか?
私はフロントエンドのプロファイリング実務がわからないので、手間が評価できず…🥺

とりあえずFirefoxのnetwork.http.max-persistent-connections-per-serverの値を6にしたものと1024にしたものを共有します。

同時接続数1024のものはWaiting for socket threadが非常に短くなりますがHTTP request and waiting for responseがその分伸びています。
結果、パフォーマンスはほぼ改善していないという感じです。


そもそもこの制限を回避するのはElectronでlocalhostのHTTPサーバーにリクエストを送るというVOICEVOXの構成上以下の点で非常に難しいと思います。

  • Electronの同時接続数を変更する現実的な方法は恐らく無い
  • HTTP2を使用すれば回避できるかもしれないがHTTPSが必要

検証ありがとうございます!


この制限を回避するのは ... 非常に難しい

👍
同意です。素の HTTP 1.1 を維持するのは必須要件と考えます。
あくまで原因切り分けのために必要、という整理ですね。


同時接続数1024のものはWaiting for socket threadが非常に短くなります

👍
「Firefox 設定が上手く効いていない」は明確に No ですね。


HTTP request and waiting for responseがその分伸びています。
結果、パフォーマンスはほぼ改善していない

👍
これは ENGINE 内部の並列性に問題有りですね。

キャッシュは有効な対症療法になると思います。
ただキャッシュは設計上の考慮事項をドンドン増やしていくので、根本原因の解決ができるならそっちを優先したいです。

ENGINE が同時リクエストを受けるフローで並列性/並行性が問題になりそうな箇所だと:

  • HTTP server がそもそもシングルスレッド制約
  • CORE 利用の mutex による待機
  • CORE DLL 側の同時利用制約
  • 話者情報ファイル I/O 性能上限
  • 並行処理の同期 I/O 待ち

あたりが怪しそうです。
@sabonerune さんは他に怪しそうな要素思い当たりますか?

他にあるとしたらシリアライズ周りでしょうか?
PythonはブロッキングIOはマルチスレッドの恩恵を受ける一方、CPUバウンドの処理はGILによって恩恵を受けることができません。

レスポンスのサイズと速度になんとなく相関関係があるような感じがあるので…

シリアライズ ... レスポンスのサイズと速度になんとなく相関関係があるような感じ

単純に処理が重いということですね。全然有り得そうです。
その場合の対応策はキャッシュ系かマルチプロセス化くらいですかねぇ。

現時点での文献調査結果

FastAPI server モデル

デフォルトだとシングルプロセス・マルチスレッド式(スレッドプール)。
高度な設定としてマルチプロセスが可能(docs)。

ENGINE mutex

1回の GET /speaker_info で 1 回の CoreAdapter.speakers アクセス。
.speaker は mutex を利用していない
よって影響はない。

話者情報ファイル I/O 性能上限

FastAPI はスレッドプールで並行処理を提供し、GIL は I/O アクセス時に開放される。
よって

  • 同時 6 リクエスト
  • リクエスト A が GIL 取得 → 非 I/O 処理 → I/O 処理 syscall 発行 → GIL 開放
  • リクエスト B が GIL 取得 → 非 I/O 処理 → I/O 処理 syscall 発行 → GIL 開放
  • 以下同様

となるため、OS には複数スレッドから I/O syscall が発行されうる。
ゆえに最悪ケースだと 6 ファイルの I/O が OS に要求されるため、性能上限に引っかかる可能性がある。
すなわち ENGINE として I/O バウンドになりうる。

並行処理の同期 I/O 待ち

Python GIL は I/O アクセス時に開放される。ゆえにスレッド間で I/O ブロッキングは起きない。

現段階だと有り得そうな仮説は次のいずれかになりそうです:

  • I/O 仮説: 「I/O バウンドなのでファイル読み込み数を減らさないともう限界」
  • CPU 仮説: 「CPU バウンドなのでシングルプロセスならもう限界」

後者は speaker_info() 関数の Python プロファイルを測定し、b64encode_str() あたりが時間食っているかで判断できそうです。
前者はファイル読み込み数をいじれば検証可能?

議論に抜け漏れありそうでしょうか?

意見修正しました。

ただキャッシュは設計上の考慮事項をドンドン増やしていくので、根本原因の解決ができるならそっちを優先したいです。

この発想が足りてませんでした、仰るとおりだと思います。

議論に抜け漏れありそうでしょうか?

そもそもFastAPIがこれだけのクエリ数(と容量)をさばくのにちょっと時間かかる、とかも無きにしもあらずかもです。

前者はファイル読み込み数をいじれば検証可能?

他の方法として、ちょっと、いやかなり面倒かもですが、ファイルを読みこんでバイナリを返す部分を関数化してfunctools.cache辺りで包んだ上で、起動時に全てのファイルを読み込んでキャッシュを作ったあと、エンジンにリクエストを投げまくって時間測定すれば一応切り分けはできるのかなと思いました。


仮にファイルI/Oが重い場合、そもそも画像や音声をbase64で返すのではなく、ファイルAPIを作ってそのURLを返して読み込みを遅延させる方法とかも有り得るのですが、これはなるべく考慮から省きたいと思っています。
どう考えても設計上こっちのが良いのですが、APIのmodelの破壊的変更になってしまうので
、まあなるべく避けたいかなと・・・・・ 😇

軽くline_profilerを使用して_speaker_infoを動かしてみました。その結果、

同時接続数1の場合

  • filter_speakers_and_styles()が実行時間の5割以上を占める
  • そのうちのほとんどはdeepcopy()らしい
  • 続いてb64encode_str()read_bytes()が遅い。

同時接続数を増やした場合

  • read_bytesread_textの実行時間の割合が増える
  • 一方でb64encode_str()filter_speakers_and_styles()の割合は減少する。

まだline_profilerがどのような基準で時間を計測しているか分からないため同時接続数を増やした場合の信頼度が分かりません。
また、fastapi周りの影響については確認できていません。

プロファイリングありがとうございます!
私の勘とは違う結果で、やはり想像より計測ですね👍️

どうも CPU バウンドでは無さそうな雰囲気ですね。
現実装の I/O はブロッキング I/O なので、同時接続数を上げた際の read_ 系も改善の余地がありそうです。

感想としては、根本原因の排除で速度改善ができそうな予感がします(キャッシュ実装は時期尚早?)。

良いですね!!

実際の実行時間はどうなっていましたか?
割合(相対量)だけじゃなく実際の実行時間(絶対時間)も大事だと思ったためです。
1個だけ処理するのにかかる時間と、複数並列で処理するのにかかった時間÷処理数を比べて、どこが並列かの恩恵を受けててどこが受けてないのかを分析する必要がある気がします。
(なんかもう十分に並列ができている可能性もある気がちょっとしてます)

あととりあえず原因候補と解決策を列挙してみました。

  • filter_speakers_and_styles
    • キャラクターごとにこれを実行するのは確かにちょっとだけ遅そう(for文がちょっとあるので)
    • エンジン起動時に各キャラのspeaker情報・singer情報をもつdictを一発で作ってAPI ではそれを返す感じにすれば良さそう
    • 並列にできて速いならここでファイル読み込みしちゃうのも良さそう
  • deepcopy
    • 念のためにとっているだけなので、浅いコピーにするのも良さそう(その代わりコメントドキュメントで案内)
    • でも並列実行できてるんだったらボトルネックはこれじゃなさそう
  • read_bytesやread_text
    • 待機時間が増えているのは他のファイルリードの終了待っているからな気がする
    • つまりこの時間はどうあがいても減らない気がする
  • b64encode_str
    • これは必ず減らない
    • レスポンスのキャッシュを取るのではなく、エンジン側でtmpディレクトリにキャッシュを作るとかの手はある
    • けどどっちにしてもたぶんキャッシュ化するしかない

個人的にはもうキャッシュの方向に倒しちゃっても良い気がしてきました。
というのも、エンジンのバージョンをキャッシュのタグに正しく含めていればキャッシュの問題は発生しえないことに気付いたためです。
(マルチエンジンのこと考えると念のためエンジン名も含めた方が良いかもですが。)

あとたぶん銀の弾丸はなさそうなことと、どうしても高速ができない部分がいくつかあるのもあって、割とキャッシュが妥当なのかなと思い始めました!
エディタにとって起動速度の向上はかなり嬉しいので早めに実装されると嬉しい、という思いもあっての意見です。

でも未知のトラブルもありそうなのでキャッシュは避けられるなら避けたいし、エンジンのアップデートの度に起動時間がまた遅くなるのもできれば避けたいですが、まあトラブルが思いつかなければキャッシュ実装もありに思いました。
とりあえずキャッシュ実装してから高速化を頑張っていく、的なニュアンスかなと。

ENGINE が本質的に遅いという暫定見解が得られたのでエディタで対処する、もアリな気がします。
エディタは素人で自信無いのですが、エディタの遅延リソースロード(placeholder 画像で起動したうえでバックグラウンド API call & 画像差し替え)で本質的に解決できるという話を見た覚えがあります。
手間は掛かると思いますが、初回ロードも高速化できるし話者数に依存しなくなるため、ENGINE にワークアラウンド的キャッシュを実装するより筋が良いかもです。

@tarepan 現状のエンジンの API 構成だとエディター側の改善はかなり難しいです・・・!

というのも結構単純な話で、どのキャラクターがエンジンにいるかわからないと、例えば一番最初に表示するテキスト欄のキャラクターを誰にすればいいのかがわからなかったりするので、UIが表示できなかったりします。
もちろんその起動シーケンスを変える方法もありますが、どちらかというとエンジン側を何とかした方が圧倒的に早そうに感じます。
あと、課題があるのはどちらかというとエディターではなく API 側だと思っていて、例えば他のサードパーティーから使う場合もかなりハンドリングが難しかったりするんじゃないかなと。

キャッシュが難しいのであれば、ちょっと頑張って互換性のない API v2 を作っていくのはありかもと思いました。
とりあえず/speaker_info系内にあるバイナリを全て URL に置き換えた物を v2にするのを考えてます。

まあでも個人的にはとりあえずキャッシュにして起動が遅い問題を何とかして、後で時間をかけて頑張っていくのが良いのかもとか思いました。
エンジン ID+話者 IDで一意・・・なはず。

(v2 API の設定をしていくならなんか専用で考えられる場所があるといいかもですね!)

どのキャラクターがエンジンにいるかわからない

👍️
スタートアップに必須な話者情報テキストが単独で取り出せない(画像とセットになっている)ため ENGINE 側の問題、ということですね。理解しました。

とりあえずキャッシュにして起動が遅い問題を何とかして、後で時間をかけて頑張っていく

👍️
キャッシュ先行実装に同意です。


互換性のない API v2

/speaker_info にテキスト情報のみを高速返却するオプションを付ける手がありそうです。
GET /speaker_info?minimum=true をすると画像バイト列を placeholder とした話者情報を返すイメージです。
こうすれば v1 として後方互換性を維持 & 最小の ENGINE 実装変更で段階的情報取得ができそうです。

この辺はご指摘の通り、別の issue で更に議論できればと思います。

@Hiroshiba

どのキャラクターがエンジンにいるかわからないと

確か/Speakers/Singersにスタイル情報(スタイル名、スタイルID、スタイルのタイプ)が含まれているので一応現在のAPIでも遅延ロードは可能だと思います。

ただ、現在のエディタ側は初期化時にSpeakersSingersSpeakerInfoSingerInfoを単一のツリーに組み替えて保持しています。
これに遅延ロードの機能を追加するのはかなり面倒な作業だと思います。
(少なくとも自分にはできそうもなさそうでした)

@sabonerune
たしかにテキストデータは別でも得られますね!
改修がとんでもない労力な気がするのも同感です。
正直どこから手を付けて良いのかパッと思いつかないくらいには複雑なんですよね・・・。


@tarepan

/speaker_info にテキスト情報のみを高速返却するオプションを付ける手がありそうです。
GET /speaker_info?minimum=true をすると画像バイト列を placeholder とした話者情報を返すイメージです。
こうすれば v1 として後方互換性を維持 & 最小の ENGINE 実装変更で段階的情報取得ができそうです。

なるほどです。
いつどうやってそのplaceholderのデータを取得するか、表示UI側のコードからどうやったらそのデータ取得関数を叩けるか、placeholder中の表示をどうするか、とかも考えると結構しんどいんですよね・・・。

引数パラメータ指定で他の値を返すのは面白いアイデアかもと思いました。
その仕組みで画像や音声はURLを返せば良いのかもしれません。
まあ、マルチエンジンとかのことも考えないといけないのと、複雑化してしまうのと、v2を作ったときに仕様がダブるのととかいろいろ考えないといけないことはありそうですが。


とりあえずissue化しようと思います!

URLにする方法をいろいろ検討してisuse化してみました!

書いてて思ったのですが、エディタ側は「srcに指定しているからURLにするとコード変更量が少なくかなり直感的なコードになる」というのが自分が推してる理由として大きいかもと感じました。

#1240 を用い、本番環境ベンチマークを取りました。

追記: #1240 更新に伴いベンチマーク結果も更新

手法

本番環境にて、GET /speaker_info直列で全話者に対してリクエストしました。すなわち最悪ケースの模倣です。
また、擬似的なプロファイルを ablation study により取りました。条件は「リソースファイル読み込み変換 and/or deepcopy をコメントアウト」です。

結果

以下が生データになります(detail)。

intel Core i3 13100 CPU, WSL2 ubuntu, OSS版 VOICEVOX ENGINE latest + 製品版 VOICEVOX 0.19.1
# フルスペック
vscode ➜ /workspaces/voicevox_engine (add/benchmark_speed) $ python -m test.benchmark.speed.speaker
Warning: cpu_num_threads is set to 0. Setting it to half of the logical cores.
Info: Loading core 0.15.3.
`GET /speakers` fakeserve: 0.0217 sec
`GET /speakers` localhost: 0.0200 sec
Warning: cpu_num_threads is set to 0. Setting it to half of the logical cores.
Info: Loading core 0.15.3.
全話者 `GET /speaker_info` fakeserve: 0.570 sec
全話者 `GET /speaker_info` localhost: 0.633 sec
Warning: cpu_num_threads is set to 0. Setting it to half of the logical cores.
Info: Loading core 0.15.3.
全話者 `GET /` fakeserve: 0.069 sec
全話者 `GET /` localhost: 0.064 sec

# リソースファイル読み込み&変換をスキップ
vscode ➜ /workspaces/voicevox_engine (add/benchmark_speed) $ python -m test.benchmark.speed.speaker
Warning: cpu_num_threads is set to 0. Setting it to half of the logical cores.
Info: Loading core 0.15.3.
`GET /speakers` fakeserve: 0.0224 sec
`GET /speakers` localhost: 0.0200 sec
Warning: cpu_num_threads is set to 0. Setting it to half of the logical cores.
Info: Loading core 0.15.3.
全話者 `GET /speaker_info` fakeserve: 0.252 sec
全話者 `GET /speaker_info` localhost: 0.261 sec
Warning: cpu_num_threads is set to 0. Setting it to half of the logical cores.
Info: Loading core 0.15.3.
全話者 `GET /` fakeserve: 0.072 sec
全話者 `GET /` localhost: 0.068 sec

# リソースファイル読み込み&変換をスキップ + deepcopy スキップ
vscode ➜ /workspaces/voicevox_engine (add/benchmark_speed) $ python -m test.benchmark.speed.speaker
Warning: cpu_num_threads is set to 0. Setting it to half of the logical cores.
Info: Loading core 0.15.3.
`GET /speakers` fakeserve: 0.0202 sec
`GET /speakers` localhost: 0.0186 sec
Warning: cpu_num_threads is set to 0. Setting it to half of the logical cores.
Info: Loading core 0.15.3.
全話者 `GET /speaker_info` fakeserve: 0.157 sec
全話者 `GET /speaker_info` localhost: 0.159 sec
Warning: cpu_num_threads is set to 0. Setting it to half of the logical cores.
Info: Loading core 0.15.3.
全話者 `GET /` fakeserve: 0.070 sec
全話者 `GET /` localhost: 0.063 sec

上記を集計した結果、直列リクエスト時の所要時間が以下のようになることがわかりました。

|-------------| 0.65
|-|             0.05 request
 |--------|     0.40 file load & convert
         |--|   0.10 deepcopy
           |--| 0.10 other processings

ファイルI/O+変換は 0.4 秒程度かかっています。

考察

ボトルネック候補になっていたファイルI/O+変換が 0.4 秒程度しかかかっていません。
本実験は直列リクエストであるため、これ以上遅くなることはありません。またこの結果は Core i3 13100 CPU で得られたため、他 CPU はより高速と考えられます。

この結果に基づき、製品版 VOICEVOX 立ち上げが徐々に遅くなっている原因は以下の 2 つのいずれかと考えます:

  • エディタ側にボトルネックがある
  • ネットワークが遅い一部の環境では数十個の HTTP リクエストするのがそもそも筋悪い
    (追記: リクエストは直列でも 0.05 秒しかかかっていないのでボトルネックとは考えづらい。)

(追記)
現状ではエンジンの GET /speaker_info は充分な速度を出せている、と考えます。換言すれば、エディタ側(内部処理かリクエスト発出周りか)にボトルネック有りと示唆される、と考えます。

ゆえにエンジン側に複雑な高速化システムを入れるのはデメリットが勝ると考えます。

後者の原因を重視するのであれば GET /speaker_info/all API を新しく生やすのが良いと考えます。(追記: 直列で問題無しなのでこれは無意味)

@Hiroshiba @sabonerune @y-chan
ご意見伺えれば幸いです。

#1129 (comment) のプロファイリングでネットワークのグラフが大半を占めていることからエディタのコード自体は現状ボトルネックにはなっていないと思います。
もしボトルネックになっているとしたら通信をしていない区間が多く占めるはずです。
(エンジン側のレスポンスが高速化した場合次はエディタ側のBase64デコードがボトルネックになる可能性はあります)

となると遅いのはここで計測していない通信周りでしょうか?(FastAPI~ElectronのHTTPデコードまで辺り?)

そもそもVOICEVOX 0.19.0時点でspeaker_infoディレクトリ全体のファイルサイズは280MBもあります。
これを起動時に全部読み込みを行うという設計に限界があると思います。
となるとURL化 #1208 かエディタ側で遅延読み込み#1129 (comment) をするのがいいような気がします。

@sevenc-nanashi
(メンションし損ねてました)ご意見伺えると嬉しいです。エディタ起動高速化の一助になれば幸いです。

@tarepan 検証ありがとうございます!

他の実験結果からの推察が外れたことになるのでとても驚きです!!!
0.5秒くらいだというのは意外と直感には合う気がします。ファイルロードとbase64化しかしてないはずなので。。

こちらでも試してみたいのと、なにかしらのミスがありえなくはなとで、もしよければ検証コード見てみたいです・・・!!

0.5秒くらいだというのは意外と直感には合う気がします

私の経験上も、この程度の負荷であれば 0.5 秒は妥当な処理時間に感じます。

検証コード

#1129 (comment) にある通りです。#1240 を利用した上で 詳細 タブにあるコマンドを打っています。別プロセスでエンジンを建てておく必要があり、それは #1240 で追加されているspeed/speaker.pyif __name__ == "__main__": 以下に手順が記載してあります。

おお、なるほどです!!! ありがとうございます!!

手元のmacOSでも試してみました!
結論は @tarepan さんの仰るとおりで、エンジン側は十分(ではないけど少なくとも思っていたよりはずっと)速いと感じました。

結果こんな感じでした。(fakeの方はRosettaをうまく動かせなかったので計測できませんでした。。)

`GET /speakers` localhost: 0.0328 sec
全話者 `GET /speaker_info` localhost: 0.910 sec
全話者 `GET /` localhost: 0.058 sec

エディタ側は/singer_infoも実行するので、合わせると倍ほどかかるとは思います。
それでもエディタの起動は、エンジンが起動してからデータ準備する部分だけでも10秒ほどかかっているので、でかいボトルネックはエンジンじゃないと感じました!

こちらのissueに関して、もしかしたら実装できることは何もないのかもとちょっと思いました。
というのももう十分に実装がもう早いのではないかなと・・・。

例えばbase64エンコードするのは多少時間かかってそうなので、それをファイルキャッシュする手はありえるかもしれません。
ただ本当にそれが必要かの議論がまだなので、もしかしたら実装者募集は早いかも・・・?

個人的には↓の議論を進めたい思いがちょっとあります!!

URL による取得が実装され、また従来式もベンチマークと高速化が完了しました。

@Hiroshiba
本 issue は resolve につき close 可能そうです。

そうですね!!

エディタが11秒くらいかかってた処理が、最終的に3秒くらいに縮まりました🎉
エンジンの速度はだいたい0.5秒〜1秒弱だった記憶で、締める時間の割合は上がったので将来キャラが増えてきたらまた見直しが必要になるかも。
(リソースマネージャーも入ってだいぶ早くなってると思うので、後々のためにresource_typeをurlにした状態でどれくらいの速度出るのか残しといても良いかも?
#1129 (comment)

非常に良い機能が実装できたと思います!
一連の調査・議論・実装お疲れ様でした!!