AetherEchoesEngineering
Engineering#021724 min2,488 字12 view

VOICEVOX の CPU 版 Docker、並列 4 でも逐次と同じ 92 秒だった

VOICEVOX の CPU 版 Docker に同じ 12 段落を逐次 / 並列 2 / 並列 4 で投げても 91.5 / 91.7 / 92.6 秒と差が無く、wav も一致した。audio_query の待ちと docker stats の 196% から原因を突き止め、動画 1 本の所要を見積もる式を出す。

SoSoraEndo2026年9月14日 09:3024 min2,488 字

動画で読む

4 分の動画 1 本に 11 分半。最初に疑ったのは音声合成の逐次ループだった

2026-09-13 の日曜、随筆 1 本(本文 1,531 字)を 4 章 12 段落の台本にして、4 分 00 秒の解説動画を作って YouTube に上げるまでの内訳は、音声合成 115 秒、Remotion の render 410 秒、upload とサムネイル 15 秒。合計で約 11 分 30 秒だった。

音声側のコードは tools/aether-video/scripts/build-all.ts で、章ごとの段落を for ループで順に await synthesize(...) している。VOICEVOX は audio_query と synthesis の 2 段階 API なので、段落 1 つにつき HTTP を 2 回叩く。ここを Promise.all で 2 本、4 本と並べれば 115 秒が半分になるんじゃないか。それが最初の読みだった。

ならなかった。1 秒も。

条件: 同じ 12 段落、同じ話者、4 日間動きっぱなしの同じコンテナ

比べたのは 3 条件。逐次(並列 1)、並列 2、並列 4。それぞれ 2 周して 2 周目を採用する予定だったが、結果が揃いすぎたので両方載せる。

対象は昨日の動画の台本 12 段落(1,150 字)。build-all.ts と同じく各章の最初の段落には見出しを頭に付け、話者は engineering 用の No.7(speaker 29)、speedScale 1.15 / volumeScale 1.2 も本番の設定のまま。計測スクリプトは /tmp に置いた使い捨ての TypeScript で、段落ごとに audio_query の所要 ms、synthesis の所要 ms、wav のバイト数と sha256 を記録し、開始 4 秒後に docker stats を 1 行だけ取る。

相手のコンテナはこれ。

$ docker ps --filter name=voicevox --format "{{.Names}}\t{{.Image}}\t{{.Status}}"
aether-voicevox	voicevox/voicevox_engine:cpu-latest	Up 4 days
$ curl -sS localhost:50021/version
"0.25.2"
$ docker inspect aether-voicevox --format "{{.State.StartedAt}} restart={{.HostConfig.RestartPolicy.Name}} NanoCpus={{.HostConfig.NanoCpus}} Memory={{.HostConfig.Memory}}"
2026-09-09T03:21:29.739270052Z restart=unless-stopped NanoCpus=0 Memory=0
$ docker image inspect voicevox/voicevox_engine:cpu-latest --format "{{.Architecture}} {{.Os}}"
amd64 linux
$ docker info --format "{{.NCPU}} cpu / {{.MemTotal}} bytes / {{.OperatingSystem}}"
4 cpu / 4127531008 bytes / Docker Desktop
$ /usr/sbin/sysctl -n hw.ncpu machdep.cpu.brand_string
8
Intel(R) Core(TM) i7-8557U CPU @ 1.70GHz

コンテナ側に CPU / メモリの制限は掛けていない。イメージは amd64、ホストも x86_64 の Intel Mac なのでエミュレーションは挟まらない。ただし Docker Desktop の VM に割り当てているのは 4 CPU / 約 3.8 GiB で、ホストの論理コア 8 のうち半分しか見えていない。ここが後で効いてくる。

手で 1 段落叩くと 8 秒。読者が追える最小の再現

スクリプトを書かなくても、curl 2 回で同じ数字が出る。第 1 章の最初の段落(見出し込み 98 字)で試したのがこれ。

$ time curl -sS -X POST "localhost:50021/audio_query?speaker=29" --get --data-urlencode "text=$TEXT" -o /tmp/vv_query.json
0.903 total
$ jq -c "{speedScale, outputSamplingRate, accent_phrases: (.accent_phrases|length)}" /tmp/vv_query.json
{"speedScale":1.0,"outputSamplingRate":24000,"accent_phrases":32}
$ jq ".speedScale = 1.15 | .volumeScale = 1.2" /tmp/vv_query.json > /tmp/vv_query115.json
$ time curl -sS -X POST "localhost:50021/synthesis?speaker=29" -H "Content-Type: application/json" -d @/tmp/vv_query115.json -o /tmp/vv_p1.wav
7.270 total
$ ls -l /tmp/vv_p1.wav
-rw-r--r--  1 soraendo  wheel  706092 Sep 14 09:05 /tmp/vv_p1.wav

audio_query が 0.9 秒なのは、この日最初のリクエストだったから。スクリプトで続けて叩くと 20 ミリ秒台に落ちる。synthesis の 7.27 秒と wav の 706,092 バイトは、スクリプトの逐次 1 周目が出した 7,336 ミリ秒 / 706,092 バイトと同じで、sha256 も一致する。

ついでに speedScale を 1.0 のままにした synthesis も投げた。8.766 秒で 818,732 バイト。1.15 倍速にすると音声が 14% 短くなり、合成時間もほぼ同じ比率で縮む。合成時間は「出力する音声の長さ」に比例していて、入力の文字数はその手前の変数に過ぎない。

逐次 91.5 秒、並列 2 で 91.7 秒、並列 4 で 92.6 秒

条件1 周目 wall2 周目 wall生成した音声の尺wall ÷ 音声尺wav の sha256
逐次(1)91.5 s93.5 s171.2 s0.53 / 0.5512 本とも一致
並列 291.7 s93.7 s171.2 s0.54 / 0.5512 本とも一致
並列 492.6 s94.0 s171.2 s0.54 / 0.5512 本とも一致

6 周で得た 72 本の wav は、段落ごとに見ると全部同じ sha256 だった。並列にしても音が変わらない代わりに、速くもならない。2 周目が 1 周目より 2 秒遅いのは、条件に関係なく全部そうなので、マシンの温度か裏の何かだと思う。追ってはいない。

読み替えると、この環境では音声 1 秒を作るのに 0.53〜0.55 秒かかる。段落の長さとは綺麗に比例していて、73 字の段落が 5.9 秒、120 字の段落が 9.0 秒だった。

並列にすると audio_query が 20 ミリ秒から 9 秒に化ける

wall が変わらない理由は、段落ごとの内訳に出ていた。逐次のときの audio_query は 20 ミリ秒前後。並列 2 にした途端、こうなる。

mode=par2 run=1 concurrency=2 paragraphs=12 speaker=29 speedScale=1.15
name          chars  query_ms  synth_ms   bytes   sha256[0:12]   start_ms  end_ms
chapter1-p1      98        87     14866   706092  17fe210256af         1   14958
chapter1-p2      92        46      6896   651308  a534820b99ec        47    6991
chapter1-p3      99      7943      8291   728620  0f84ba1bca52      6991   23228
chapter2-p1     120      8241      9036   810540  7238f247b091     14958   32237
chapter2-p2      73      8953      6027   539180  d8c802daf3fd     23228   38210
...
sum: chars=1150 query_ms=76079 synth_ms=98773 bytes=8219664
wall_ms=91696 (91.7s)  sum_of_per_item_ms=174852

chapter1-p3 の audio_query が 7,943 ミリ秒。テキストを形態素解析して読みを返すだけの処理に 8 秒はかからない。前の段落の synthesis が終わるまで待たされている。最初の 2 本も、p1 の synthesis が 14,866 ミリ秒で p2 の 6,896 ミリ秒の倍以上になっていて、2 本が交互に処理されているというより、片方が終わるまでもう片方が止まっている見え方だ。

並列 4 では audio_query の待ちが最大 25 秒まで伸びた。クライアント側で何本投げても、エンジンは 1 リクエストずつ順に片付けている。Promise.all は「並列に合成する」のではなく「エンジンの前に列を作る」だけだった。

エンジンが内部でロックを取っているのか、推論の実装が同時実行を許さないのかは、コードを読んでいないので分からない。README にも同時リクエストについての記述は無い。分かっているのは、この版(0.25.2)の CPU イメージに対して、HTTP を並べる意味が無いことまでだ。

CPU は何をしても 196% で止まっている

もう 1 つの手掛かりが docker stats の行で、6 周とも同じ値だった。

docker_stats@4s: aether-voicevox cpu=197.59% mem=2.039GiB / 3.844GiB   (seq run1)
docker_stats@4s: aether-voicevox cpu=195.83% mem=2.067GiB / 3.844GiB   (par2 run1)
docker_stats@4s: aether-voicevox cpu=196.54% mem=2.072GiB / 3.844GiB   (par4 run1)

コンテナには 4 CPU 見えているのに、使っているのは 2 コア分。これは voicevox_engine の README にそのまま書いてあった。

--cpu_num_threads CPU_NUM_THREADS : 音声合成を行うスレッド数です。 CPU スレッド数が未指定の場合は、論理コア数の半分が使われます。

コンテナの起動コマンドは docker inspect で見ると gosu user /opt/voicevox_engine/run --host 0.0.0.0 で、--cpu_num_threads を渡していない。docker exec aether-voicevox nproc は 4 を返す。4 の半分で 2 スレッド。196% はぴったりその数字だ。

つまり並列化で埋まる余白は最初から無かった。埋まっていないのは残りの 2 コアと、VM の外にいるホストの 4 論理コアで、そちらは --cpu_num_threads 4 を渡すか Docker Desktop の CPU 割り当てを増やさないと届かない。

ここは試していない。試すには 2 つ目のコンテナを別ポートで立てる必要があって、今のコンテナが 3.84 GiB の VM のうち 2.08 GiB を使っている。同じ物をもう 1 つ載せると入らない可能性が高く、動画生成の本番で使っているコンテナを巻き込みたくなかった。動かしたまま数字が取れる方法を考えてから、別の日にやる。

昨日の 115 秒は wav の mtime からも同じ数字が出る

計測とは別に、動画生成が残した wav の更新時刻を秒まで見た。

$ stat -f "%Sm %z %N" -t "%H:%M:%S" public/audio/sunset-1754-light-goes-autumn-first-long/*.wav | sort
10:12:14 231980 title.wav
10:12:24 1030188 lead.wav
10:12:32 812588 chapter1-p1.wav
...
10:13:58 873516 chapter4-p3.wav
10:14:05 666156 outro.wav
$ stat -f "%Sm %z %N" -t "%H:%M:%S" public/long-script.json out/sunset-1754-light-goes-autumn-first-long.mp4
10:14:05 11346 public/long-script.json
10:20:59 40831380 out/sunset-1754-light-goes-autumn-first-long.mp4

title.wav から outro.wav まで 111 秒で 15 本。合成した音声の合計は long-script.json の durations を足すと 239.86 秒なので、比率は 0.46。今日の No.7 の 0.53〜0.55 より少し速いが、昨日の話者はずんだもん(speaker 3)で条件が違う。話者で合成速度が変わるかどうかは、今日は測っていない。

render 側は long-script.json の 10:14

から mp4 の 10:20
まで 414 秒。remotion.config.ts の setConcurrency(Math.floor(cpus().length / 2)) はホストの 8 論理コアを見るので 4 で走る。240 秒の動画に 414 秒、比率 1.7。VOICEVOX が VM の 4 コアの半分しか使わない一方で、Remotion はホスト側の 8 コアの半分を使う。同じ「半分」でも母数が違う。

これで動画 1 本の見積もりは式になる。この Mac では、音声合成 ≒ 台本の読み上げ秒数 × 0.5、render ≒ 動画の秒数 × 1.7。4 分の動画なら 2 分 + 7 分で、upload を足して 10 分強。昨日の 11 分 30 秒はこの式の範囲に収まる。

compose ではなく docker run で立てている理由は、今日叩いても再現した

コンテナに compose のラベルが無いのは、7 月 23 日にこの Mac で動画生成を動かし始めたとき、docker compose up voicevox が失敗して docker run に切り替えたからだ。今も同じかを確かめた。

$ docker compose config --services
The new 'docker compose' command is currently experimental. To provide feedback or request new features please open issues at https://github.com/docker/compose-cli
open ~/projects/main/local/AetherEchoes/backend/.env: no such file or directory
$ for i in 1 2 3 4 5; do docker compose config --services 2>&1 | grep -v experimental; done
open ~/projects/main/local/AetherEchoes/backend/.env: no such file or directory
open ~/projects/main/local/AetherEchoes/backend/.env: no such file or directory
open ~/projects/main/local/AetherEchoes/backend/.env: no such file or directory
open ~/projects/main/local/AetherEchoes/frontend/.env: no such file or directory
open ~/projects/main/local/AetherEchoes/backend/.env: no such file or directory
$ ls -la .env ./backend/.env ./frontend/.env
ls: ./backend/.env: No such file or directory
ls: ./frontend/.env: No such file or directory
ls: .env: No such file or directory

voicevox service 自体は image と ports と restart の 3 行で env_file を持っていない。ただ同じ compose ファイルの backend と sidekiq が ./backend/.env、frontend が ./frontend/.env、db が ./.env を env_file に指定していて、compose は service を絞る前にファイル全体を検証して止まる。

どの .env で止まるかは実行ごとに変わった。上の 6 回では backend/.env が 5 回、frontend/.env が 1 回、別のタイミングで叩いた 1 回は ./.env だった。3 つとも置いていないので、どれで止まっても結果は同じだ。動画生成しかしないこの Mac には backend / frontend / db の .env をどれも置いていないので、voicevox だけ上げたくても上がらない。

逃げ方は docker run 単体で立てること。今動いているコンテナの設定を docker inspect の name / restart / port から書き戻すと docker run -d --name aether-voicevox --restart unless-stopped -p 50021:50021 voicevox/voicevox_engine:cpu-latest になる。restart と port は compose の voicevox service に書いてある 3 行と同じ値で、voicevox_engine の README の例 docker run --rm -p '127.0.0.1:50021:50021' voicevox/voicevox_engine:cpu-latest とは --rm の有無と 127.0.0.1 バインドの点で違う。compose の profile で切り分ける手もあるが、この Mac で必要なのはこのコンテナ 1 つなので、そこまでしていない。compose の設計そのものは 個人開発の docker-compose.yml をテンプレ化する に書いた形のままだ。

自分の条件での選択: for ループは触らない

比べた 3 案のうち、コードを変える価値があるものは無かった。build-all.ts の逐次 await はそのままにする。

代わりに手を付けるなら順番はこうなる。まず --cpu_num_threads 4 を渡して 2 スレッドの壁を外す(メモリの都合で未検証、効いても上限は 2 倍)。次に Docker Desktop の CPU 割り当てを 4 から 8 に上げる。どちらも音声側の 2 分を縮める話で、7 分かかっている render には効かない。動画 1 本を 11 分から縮めたければ、削る場所は音声ではなく render の方だ。

並列化して速くなると思っていた読みは、docker stats の 1 行で崩れた。測る前に直していたら、Promise.all を入れて「速くなった気がする」で終わっていたと思う。

Tags

参考文献

  1. VOICEVOX ENGINE README(--cpu_num_threads の既定値、docker run 例、audio_query / synthesis の curl 例)
  2. voicevox/voicevox_engine on Docker Hub(cpu-latest イメージ)
  3. Remotion Docs: Concurrency(render 時の並列度の定義)
  4. Docker Desktop settings: Resources(VM への CPU / メモリ割り当て)
  5. 個人開発の docker-compose.yml をテンプレ化する(AetherEchoes)

Reaction

Share

X (Twitter)