Skip to content

fix(timing): C++ の timing walk を本番検査可能にし parity gate の空振りを塞ぐ - #707

Open
ayutaz wants to merge 1 commit into
devfrom
fix/timing-parity-detectors
Open

ayutaz wants to merge 1 commit into
devfrom
fix/timing-parity-detectors

Conversation

@ayutaz

@ayutaz ayutaz commented Sep 20, 2026 •

Copy link
Copy Markdown
Owner

Summary

#653 (ONNX の durations が torch.ceil 前の値) に着手する前に、それを検出できるはずの仕組みが 3 か所とも空振りしていることが分かったので先に塞ぐ。実装より先に検出器を直さないと、ceil を入れても入れなくても CI は緑のままになる。

Affected Components

  • C++
  • Python
  • CI/CD
  • Rust
  • C#
  • Go
  • WASM-npm
  • Docker
  • Documentation

Type

  • Bug fix
  • CI/CD
  • New feature
  • Refactoring
  • Documentation
  • Dependencies

Risk Level

  • patch
  • minor
  • major

Contract Impact

  • None — docs/spec/*.toml は変更していない

spec_version は据え置き。本 PR は既存 spec の記述を実装側で検査可能にするだけで、規定そのものは変えていない。ceil の導入と spec_version bump は #653 本体で行う。

変更内容

機能名 動作 これがないと起こること
C++ cursor walk のヘッダ抽出 piper.cpp の walk を src/cpp/timing_helpers.hpp (ORT 非依存) に切り出し、extractTimingsFromDurations は委譲のみ C++ の parity テストは spec を自前で再実装しており、本番コードを 1 行も通っていなかった。他 5 ランタイムは本番関数を呼んでいるので、C++ だけが出荷挙動について何も検査していない
test_timing_helpers.cpp 新設 本番実体を直接検査する 16 ケース 上記により C++ の timing 算術にはテストが 1 件も存在しなかった
cursor の accumulator を double 化 格納は float のまま (PhonemeInfo は ABI 公開) 1153 frame で float32 累積は float64 基準から 2.1e-3 ms ずれ、parity テストが宣言する許容誤差 1e-6 ms を超える。レプリカが double だったため露見していなかった
timing-parity.yml の trigger 拡張 paths: を 5 → 18 件に per-runtime-presence job は「parity テストを削除したら fail する」と宣言しているのに、守る 6 ファイルが 1 つも trigger に無く、削除するだけの PR では workflow が起動しなかった。他 5 ランタイムの timing 実装も未登録
fixture に判別力のあるケース追加 小数 2 件 + 60 エントリ長文 1 件 旧 7 ケースは全て整数 durations。ceil(整数) == 整数 なので frame 量子化を変えても fixture は 1 バイトも変わらない。実 ONNX 出力は常に小数
fixture の anti-vacuity guard --check が「小数 2 件以上 / 最長 50 エントリ以上」を検査 ケースが後から整数化・短縮されても誰も気付かない。失敗モードが無言 (緑のまま) のため reviewer の注意力に任せられない

設計判断

  • PhonemeInfo の幅は変えない。 accumulator だけ double にし、格納は float のまま。PhonemeInfo は SynthesisResult 経由で shared library から公開されており、幅を変えると struct レイアウトが変わって ABI diff (libpiper_plus.so head vs base) gate が abi-dumper の DWARF 情報で検出する。丸めは「1 エントリにつき 1 回」になり、発話全体にわたる累積が消えるので、実用上の精度問題はこれで解消する。
  • fixture をそのまま本番 C++ に流すことはできない。 fixture は token ベース (^, k, ..., $)、本番は id ベースで PAD/BOS/EOS (id 0/1/2) を skip する。fixture の basic_konnichiwa は ^ と $ を含み、実モデルではそれぞれ id 1 / 2 になる。Python canonical はエントリを出し、C++ 本番は出さない。この 4 対 2 の分裂は Rust / Go の timing が padding 込みの全エントリを出し total_duration_ms が実 WAV を超過する #697 であり、本 PR で糊塗せずテストのコメントに記録した。
  • 「音声不変」はバイト単位では検証できない。 commit message に「音声出力は不変」と書いたが、これは厳密には measurable ではない。デコーダが確率的で seed が無いため、同一バイナリを 2 回走らせてもバイトは一致しない (対照実験で確認)。一方でサンプル数は常に一致するので、検証可能な不変量は「出力サンプル数の一致」であり、それは 5/5 で成立している。timing 側は durations が決定的なので直接比較でき、差は accumulator 幅の変更に起因する 3.58e-4 ms 以下のみだった。
  • 抽出では算術を一切変えない。 ceil を入れたい誘惑があるが、リファクタに混ぜると phoneme timing が実音声に対し系統的に短い — ONNX durations は ceil 前の値(全ランタイム、spec 改訂を伴う) #653 の修正が「fixture の diff」として見えなくなる。本 PR の fixture 値は現行実装のまま。
  • 既知の欠陥は「直す」ではなく「pin する」。 抽出の過程で 3 件見つけたが、いずれも本 PR の主題ではないのでテストで現状を固定し、修正が将来の意図的な差分になるようにした: (a) buildPhonemeIdToStringMap がキーの最初の ID しか登録しない (C# では Rust / Go の phoneme timing が音素名ではなくプレースホルダ(ph_0 / p0)を出力 — spec の reverse_map 未実装 #656 で修正済み)、(b) C++ だけ負の duration を 0 に clamp せず cursor が後退する (spec [calculation.negative_handling] 違反、他 5 ランタイムは clamp)、(c) 2 < id < 128 の ASCII フォールバックが ? より先に効き、未マップ ID が制御文字になる。
  • ゲートの検査項目は代入式ごと pin する。 fixture guard は「小数が 2 件以上あるか」を実データから数えており、文字列マッチではないので空振りしない。変異 2 種で 2/2 検出を確認済み。

Test Plan

  • C++ 新規: cmake -B build -DBUILD_TESTS=ON && cmake --build build --target test_timing_helpers -j8 && ./build/src/cpp/tests/test_timing_helpers → 16 passed
  • C++ 本番への mutation 5 種が検出されること: (1) PAD/BOS/EOS skip を削除 (2) skip 時に cursor を進めない (3) accumulator を float に戻す (4) 促音 overlap 比率 0.3 → 0.4 (5) frame の truncate を round に — src/cpp/timing_helpers.hpp を書き換えて再ビルドすると 5/5 で FAILED が出る
  • C++ 全体: ctest --test-dir build --timeout 300 → 51/51 passed (新規 1 件込み)
  • fixture の判別力: src/go/piperplus/timing.go の durationMs に math.Ceil を入れて cd src/go && go test ./piperplus/ -run Parity -count=1 → FAIL。同じ変異を旧 fixture (git stash で golden_matrix.json と regenerate_timing_fixture.py を戻す) に対して行うと ok で素通りする
  • Rust でも同様: dur_frames * frame_time_ms を dur_frames.ceil() * frame_time_ms に → cargo test -p piper-plus --test test_phoneme_timing_parity が FAILED
  • fixture guard の mutation 2 種: 小数ケースを整数化 / 長文ケースを 60 → 10 エントリに短縮 → uv run python scripts/regenerate_timing_fixture.py --check が 2/2 で rc=1 かつ理由を出力
  • 6 ランタイムの parity テストが新ケースで通ること: Python pytest tests/test_phoneme_timing_parity.py (42 passed) / Rust cargo test --test test_phoneme_timing_parity (3) / Go go test ./piperplus/ -run Parity / C++ ./build/src/cpp/tests/test_phoneme_timing_parity (4) / C# dotnet test --filter TimingWriterParity (9) / JS node --test test/js/test-phoneme-timing-parity.js (11)
  • workflow trigger の実測: uv run python -c "import yaml; d=yaml.safe_load(open('.github/workflows/timing-parity.yml')); on=d[True]; print(len(on['pull_request']['paths']), len(on['push']['paths']))" → 18 18
  • dev バイナリとの比較 (worktree で dev をビルドして実測):
    • 音声: 5 入力すべてで出力サンプル数が一致 (54432 / 17414 / 85152 / 81568 / 36000 バイト)。バイト比較は不可能 — 対照として dev バイナリを同一入力で 2 回走らせてもバイトは一致しない (確率的デコーダ、noise_scale に seed 無し)。サイズは常に一致するので durations 自体は決定的
    • timing: エントリ数・音素名がすべて一致、total_duration_ms の差は 0.000000 ms (この値は emittedSamples 由来で cursor を通らないため)
    • 変わったのは per-entry の時刻のみで、差は最大 3.58e-4 ms (24 entry で 6.0e-5 / 42 entry で 1.79e-4 / 22 entry の日本語で 3.58e-4)。これは float32 accumulator が持っていた累積誤差そのもので、修正後が正しい側
  • total_duration_ms が WAV 長と一致すること: 実測 1233.288 ms / 27194 samples
  • pre-commit: pre-commit run --files <変更 8 ファイル> → rc=0

Checklist

  • Tests pass locally
  • No GPL/LGPL dependencies added (License Policy)
  • Documentation updated (if applicable)

Related Issues

#653 の前提。本 PR 単体では ceil は入れない。

対応中に発見し分離:

#653 (durations が ceil 前) に着手する前に、それを検出できるはずの
仕組みが 3 か所とも空振りしていることが分かったので先に塞ぐ。

1. C++ の parity テストが本番コードを 1 行も通っていなかった。
   test_phoneme_timing_parity.cpp は golden fixture を読みながら spec
   アルゴリズムを自身の翻訳単位で再実装しており、コメントにもそう
   書いてあった (piper.cpp が onnxruntime を引き込むためリンクできない)。
   他 5 ランタイムは本番関数を呼んでいるので、C++ だけが出荷される
   挙動について何も検査していなかった。cursor walk を ORT 非依存の
   timing_helpers.hpp に切り出し、本番実体を直接検査する
   test_timing_helpers.cpp (16 ケース) を新設した。変異 5 種が 5/5 検出
   されることを実測確認している。

   fixture をそのまま本番に流すことはできない。fixture は token ベース
   (`^`, `k`, ..., `$`)、本番は id ベースで PAD/BOS/EOS を skip するため
   で、これ自体が #697 の内容。糊塗せずテストに記録した。

2. timing-parity.yml の paths: が守る対象を含んでいなかった。
   per-runtime-presence job は「parity テストを削除したら fail する —
   that is intentional」と宣言しているのに、守る 6 ファイルが 1 つも
   trigger に無く、削除するだけの PR では workflow が起動しなかった。
   他 5 ランタイムの timing 実装も未登録だった。5 件 → 18 件に拡張。
   同型の穴が他 7 workflow にもあることを走査で確認し #704 に起票。

3. golden fixture の 7 ケースが全て整数 durations だった。実 ONNX 出力
   は常に小数なのに代表しておらず、ceil(整数) == 整数 なので frame 量子化
   を変えても fixture は 1 バイトも変わらない。実測: Go に math.Ceil を
   入れる変異は旧 fixture では ok で素通りし、新 fixture では FAIL する。
   小数 2 ケースと 60 エントリの長文ケースを追加し、後から整数化・短縮
   されたら --check が fail する anti-vacuity guard も入れた。

併せて cursor の accumulator を float から double にした。格納は float の
まま (PhonemeInfo は ABI 公開されており幅を変えると ABI gate が検出する)
なので丸めは 1 エントリ 1 回になり累積が消える。1153 frame で float32 は
float64 基準から 2.1e-3 ms ずれており、parity テストが宣言する許容誤差
1e-6 ms を大きく超えていた — レプリカが double だったため露見していな
かった。

C++ 51/51、6 ランタイムの parity テスト全通過、音声出力は不変。
@github-actions

Copy link
Copy Markdown
Contributor

Required status-check gate (deferred)

Head SHA 6321159 is no longer the branch tip (latest: 65db329). Waiting for the new commit's spoke runs to complete before re-evaluating.

@github-actions

Copy link
Copy Markdown
Contributor

Public ABI snapshot diff

Bootstrap mode: baseline is empty, recording first observation.

No ABI changes detected.

@github-actions

Copy link
Copy Markdown
Contributor

Action SHA drift report

Collected pins (3 actions) — expected_total_pins=3

Action Pinned SHA Resolved Status
dawidd6/action-download-artifact 8a33849 (commit-only) OK
dawidd6/action-download-artifact 8a33849 (commit-only) OK
mymindstorm/setup-emsdk 6ab9eb1 (commit-only) OK

Summary: total=3, ok=3

@github-actions

Copy link
Copy Markdown
Contributor

Distroless trial: webui / cpp-inference

Each canonical Dockerfile is UNCHANGED; the trial Dockerfile sits beside it as Dockerfile.distroless so docker-compose and existing CI matrices keep using the proven image.

(cpp-dev distroless was deferred — chainguard/wolfi-base does not ship the OpenJTalk / mecab build toolchain the canonical image depends on. T-016 needs a fresh design and ships in a separate PR.)

cpp-inference

Trial Dockerfile: docker/cpp-inference/Dockerfile.distroless. Canonical docker/cpp-inference/Dockerfile is UNCHANGED.

metric canonical distroless trial delta
image size (linux/amd64) 237MB 232MB -2.2%

Smoke entrypoint: /usr/local/bin/piper-plus — passed.

webui

Trial Dockerfile: docker/webui/Dockerfile.distroless. Canonical docker/webui/Dockerfile is UNCHANGED.

metric canonical distroless trial delta
image size (linux/amd64) 1.2GB 807MB -32.4%

Smoke entrypoint: /usr/bin/python3 — passed.

Not in these trials (follow-up promotion PRs)

  • linux/arm64 buildx coverage is handled by docker-build.yml (build-distroless-trials matrix).
  • CVE Trivy diff lives in trivy-container-scan.yml (per-image SARIF upload).
  • For webui: full Gradio cold-start under distroless (a real docker run -p 7860:7860 from webui-test.yml) is the promotion gate.
  • For cpp-inference: end-to-end piper-plus --model ... --output_file ... with a real model fixture is the promotion gate.

@github-actions

Copy link
Copy Markdown
Contributor

Runtime Parity Deep — audio (informational tier)

Pairs compared: 15, failing: 15, runtimes skipped: 0.

A B Tier Result Detail
cpp csharp sha256 ⚠️ b5b62d9811c6 vs 8ec7e889420b
cpp csharp peak_rms ⚠️ Δrms=0.12282 (≤ 0.005)
cpp csharp snr ⚠️ frame count differs: 3129 vs 6970
cpp go sha256 ⚠️ b5b62d9811c6 vs 92be6819f375
cpp go peak_rms ⚠️ Δrms=0.33492 (≤ 0.005)
cpp go snr ⚠️ SNR=-0.45 dB (≥ 60.0)
cpp python sha256 ⚠️ b5b62d9811c6 vs 3ee3829685d6
cpp python peak_rms ⚠️ Δrms=0.08667 (≤ 0.005)
cpp python snr ⚠️ SNR=-3.85 dB (≥ 60.0)
cpp rust sha256 ⚠️ b5b62d9811c6 vs 3a24543c2d7b
cpp rust peak_rms ⚠️ Δrms=0.14615 (≤ 0.005)
cpp rust snr ⚠️ frame count differs: 3129 vs 7539
cpp wasm sha256 ⚠️ b5b62d9811c6 vs 2c379a1b75cd
cpp wasm peak_rms ⚠️ Δrms=0.46078 (≤ 0.005)
cpp wasm snr ⚠️ frame count differs: 3129 vs 3328
csharp go sha256 ⚠️ 8ec7e889420b vs 92be6819f375
csharp go peak_rms ⚠️ Δrms=0.21210 (≤ 0.005)
csharp go snr ⚠️ frame count differs: 6970 vs 3129
csharp python sha256 ⚠️ 8ec7e889420b vs 3ee3829685d6
csharp python peak_rms ⚠️ Δrms=0.03615 (≤ 0.005)
csharp python snr ⚠️ frame count differs: 6970 vs 3129
csharp rust sha256 ⚠️ 8ec7e889420b vs 3a24543c2d7b
csharp rust peak_rms ⚠️ Δrms=0.02333 (≤ 0.005)
csharp rust snr ⚠️ frame count differs: 6970 vs 7539
csharp wasm sha256 ⚠️ 8ec7e889420b vs 2c379a1b75cd
csharp wasm peak_rms ⚠️ Δrms=0.33796 (≤ 0.005)
csharp wasm snr ⚠️ frame count differs: 6970 vs 3328
go python sha256 ⚠️ 92be6819f375 vs 3ee3829685d6
go python peak_rms ⚠️ Δrms=0.24825 (≤ 0.005)
go python snr ⚠️ SNR=-9.91 dB (≥ 60.0)
go rust sha256 ⚠️ 92be6819f375 vs 3a24543c2d7b
go rust peak_rms ⚠️ Δrms=0.18877 (≤ 0.005)
go rust snr ⚠️ frame count differs: 3129 vs 7539
go wasm sha256 ⚠️ 92be6819f375 vs 2c379a1b75cd
go wasm peak_rms ⚠️ Δrms=0.12585 (≤ 0.005)
go wasm snr ⚠️ frame count differs: 3129 vs 3328
python rust sha256 ⚠️ 3ee3829685d6 vs 3a24543c2d7b
python rust peak_rms ⚠️ Δrms=0.05947 (≤ 0.005)
python rust snr ⚠️ frame count differs: 3129 vs 7539
python wasm sha256 ⚠️ 3ee3829685d6 vs 2c379a1b75cd
python wasm peak_rms ⚠️ Δrms=0.37410 (≤ 0.005)
python wasm snr ⚠️ frame count differs: 3129 vs 3328
rust wasm sha256 ⚠️ 3a24543c2d7b vs 2c379a1b75cd
rust wasm peak_rms ⚠️ Δrms=0.31463 (≤ 0.005)
rust wasm snr ⚠️ frame count differs: 7539 vs 3328

@github-actions

Copy link
Copy Markdown
Contributor

Multi-Runtime RTF Benchmark

Policy: warn-only (thresholds: RTF +/-10%, P50 +/-10%, P95 +/-15%)

Model: test/models/multilingual-test-medium.onnx (warmup=5, runs=30)

Runtime Text RTF P50 (ms) P95 (ms) Baseline RTF RTF Δ Baseline P50 P50 Δ Baseline P95 P95 Δ
python short 4.0365 5477.4 5648.4 4.4317 -8.9% 6324.8 -13.4% 6757.1 -16.4%
python medium 1.1789 6517.5 6679.7 1.0438 +12.9% ⚠️ 5882.0 +10.8% ⚠️ 6208.1 +7.6%
python long 0.2409 8283.2 8521.1 0.2251 +7.0% 7755.6 +6.8% 8124.9 +4.9%
rust short 0.8218 1239.1 1263.3 0.8309 -1.1% 1310.9 -5.5% 1359.6 -7.1%
rust medium 0.1232 780.6 871.4 0.2501 -50.7% 1614.3 -51.6% 1636.8 -46.8%
rust long 0.0985 3477.2 3514.2 0.1039 -5.2% 3673.8 -5.4% 3697.7 -5.0%
go short 1.8731 2417.1 2459.4 2.3051 -18.7% 3125.1 -22.7% 3216.7 -23.5%
go medium 0.5179 3184.3 3373.6 0.5123 +1.1% 3158.6 +0.8% 3358.1 +0.5%
go long 0.1412 5185.2 5237.2 0.1464 -3.6% 5374.6 -3.5% 5467.3 -4.2%
csharp short 2.8661 1560.7 1658.0 2.8462 +0.7% 1542.0 +1.2% 1627.3 +1.9%
csharp medium 0.9062 1638.7 1734.7 0.8929 +1.5% 1608.6 +1.9% 1811.7 -4.3%
csharp long 0.1819 2323.4 2427.9 0.1413 +28.7% ⚠️ 1813.8 +28.1% ⚠️ 1913.0 +26.9% ⚠️
cpp short 0.6557 986.4 1012.5 0.8694 -24.6% 1402.4 -29.7% 1431.6 -29.3%
cpp medium 0.1837 1207.6 1250.0 0.2529 -27.4% 1686.2 -28.4% 1724.0 -27.5%
cpp long 0.1028 3710.7 3789.3 0.0988 +4.0% 3573.3 +3.8% 3641.5 +4.1%
wasm short (missing) - - n/a - n/a - n/a -
wasm medium (missing) - - n/a - n/a - n/a -
wasm long (missing) - - n/a - n/a - n/a -

⚠️ One or more cells regressed beyond the RTF, P50, or P95 threshold. Warn-only while the baseline is being calibrated; this will become a hard gate once we have ~2-3 weeks of variance data.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant