Skip to content

[Perf][MiniCPM-o] Fold HiFT's weight norm once at load instead of on every chunk - #6927

Open
Junbo2002 wants to merge 1 commit into
vllm-project:mainfrom
Junbo2002:pr/hift-fold-weight-norm-main
Open

Junbo2002 wants to merge 1 commit into
vllm-project:mainfrom
Junbo2002:pr/hift-fold-weight-norm-main

Conversation

@Junbo2002

Copy link
Copy Markdown

Purpose

MiniCPM-o 4.5's Code2Wav stage builds its HiFT vocoder with weight norm live on
all 82 convolution weights, and leaves it that way for the life of the
server. An inference weight never changes, so every streamed audio chunk
recomputes g * v / ||v|| for each of them — a norm reduction plus a broadcast
multiply, per convolution, per call — and gets the same tensor back every time.

parametrize.remove_parametrizations(..., leave_parametrized=True) stores the
weight the module is already using and drops the parametrization. The
arithmetic that produces the weight is unchanged; it simply happens once, at
load, instead of on the streaming path.

Why this has been sitting there

HiFT does ship a remove_weight_norm() of its own — but it calls the legacy
torch.nn.utils.remove_weight_norm, which only knows the forward-pre-hook
spelling, while flashcosyvoice selects
torch.nn.utils.parametrizations.weight_norm whenever torch provides it. On any
current torch that method raises

ValueError: weight_norm of 'weight' not found in ParametrizedConv1d(...)

rather than folding anything, and nothing in the serving path calls it anyway.
fold_weight_norm therefore handles both spellings and returns the names it
folded, so a no-op shows up in the log line instead of passing silently.

Scope and blast radius

  • Not Ascend-specific. The work it removes is the same on any accelerator,
    which is why the tests are plain CPU tests over both spellings. The numbers
    below are from an Atlas A3; no claim is made about the size of the win
    elsewhere.
  • It runs in _build_backend, after Token2wav(...) has loaded flow.pt and
    hift.pt inside its own constructor. The stage's load_weights puts nothing
    further into HiFT, so no later weight load can be surprised by it.
  • It does change HiFT's state_dict keys — parametrizations.weight.original0/1
    become weight. Nothing in the serving path reads that state dict after
    construction.
  • token2wav_hift_fold_weight_norm: false in the stage's extra config turns it
    off.

Test Plan

vLLM Version: 0.1.dev1+ge5588e49b (built from vllm-project/vllm@e5588e49b)

vLLM-Omni Commit: this branch is against main. The served numbers below
were taken on ecd9d99da instead, and here is why: the vLLM in the Ascend image
these machines run (0.1.dev1+ge5588e49b) has no vllm.v1.engine.utils.CoreEngineLaunch,
which vllm_omni/engine/stage_engine_startup.py imports on main, so main
cannot start a server there at all. The changed code is identical on both bases
— _build_backend in minicpmo_4_5_code2wav.py — and the unit tests below run
on main itself.

1. Unit tests

tests/model_executor/models/minicpmo_4_5/test_code2wav_batching.py, 4 new
cases, all pure CPU: the folded weight is bit-identical to the effective weight
the module was already using and the forward output is unchanged — parametrized
and legacy-hook spellings; a plain module folds nothing; the fold is
idempotent.

2. What the fold removes, on the real vocoder

Both arms in one process, on one loaded HiFT, alternating. Folding is
reversible for measurement purposes: re-applying weight_norm to a folded
convolution recovers exactly the same effective weight (g = ||v||, v = w), so
the two arms can be swapped as often as we like. That takes host load, die
identity and weight layout out of the comparison, which matters on a shared box.

3. In situ, during a ranked run

The same paired round, but with a time.perf_counter_ns() wrapper around the
HiFT call itself in both arms, so the change is attributed where it happens
rather than inferred. (A host-side timer is fair here because HiFT's own
_istft copies its window up from host memory, which drains the device queue —
so the call is effectively synchronous on both arms.)

4. End-to-end, paired and die-swapped

Two arms differing only by this diff, both on ecd9d99da, the baseline deploy
config, no environment variables. Both arms run at the same time, one per die,
and the round is repeated with the dies swapped — this box is shared and its two
dies are not equally fast, so only a same-round paired delta means anything.

ASCEND_RT_VISIBLE_DEVICES=<die> BENCHMARK_DIR=<out> PYTHONPATH=<tree> \
python3 -m pytest -s -vv tests/dfx/perf/scripts/run_benchmark.py \
  --test-config-file <case>   # Seed-TTS zh, openai-chat-omni, c1, 32 prompts

Test Result

1. Unit tests

tests/model_executor/models/minicpmo_4_5/, same machine, same interpreter:

tree result
main (e51fe6ec1) 389 passed, 14 skipped, 3 failed
this branch 393 passed, 14 skipped, 3 failed
ecd9d99da 91 passed, 3 skipped, 13 errors
the same change on ecd9d99da 95 passed, 3 skipped, 13 errors

The four new cases are the whole difference. The three failures are on main
itself, identical in both arms and unrelated to this change:
test_code2wav_batching.py::test_npu_patch_preserves_ragged_estimator_contract
and two test_cuda_graph_wrapper.py cases, which want a newer vLLM than the
Ascend image on this machine ships. The 13 errors in the ecd9d99da rows were a
missing pytest-mock, installed since.

2. Per vocoder call — Atlas A3 / 910C, one process, arms alternating

arm per call samples
weight norm live (upstream) 18.156 ms 17.906 17.928 18.156 18.164 18.277
folded (this PR) 13.122 ms 12.956 13.170 13.177 13.122 13.042
−5.033 ms, −27.7% no overlap

82 weights folded. The ranked configuration makes 5 vocoder calls per
request
, counted in the run in §4.

This is on an otherwise idle device. Two things say the live arm here is a
faithful stand-in for the as-loaded module: re-applying weight_norm to a folded
convolution recovers the same effective weight by construction, and its 18.16 ms
lands within 2% of the 17.80 ms that the untouched base spends per vocoder call
outside its own host-to-device copies (§4).

3. Numerically unchanged

max abs diff on the waveform
same arm, same seed, twice (the device's own noise floor) 7.26e-4
live vs folded, same seed 6.26e-4
waveform peak amplitude 0.233

The difference between the arms is smaller than the difference between two
runs of the same arm
, which is what "bit-exact arithmetic on a
non-deterministic device" looks like. The CPU unit tests assert exactness
directly.

4. In situ, ranked configuration

A time.perf_counter_ns() wrapper around the HiFT call itself, in both arms,
during a paired ranked round:

round arm die HiFT per call HiFT per request
1 ctl 1 66.44 ms 332.2 ms
1 this PR 0 65.39 ms 327.0 ms
−1.05 ms −5.2 ms
2 ctl 0 70.40 ms 352.0 ms
2 this PR 1 60.81 ms 304.1 ms
−9.59 ms −47.9 ms

160 calls in every arm — five per request, exactly. The per-round spread is
large because an in-situ HiFT call is mostly waiting, and how long it waits
depends on what the AR stages are doing on the same die; that is why the round is
run under both die assignments. Averaged over the two, −5.3 ms per call,
which is where the isolated −5.03 ms of §2 says it should be.

For context on what that 66 ms is: a separate probe on the untouched base times
HiFT's own three unpinned host-to-device copies (the harmonic multiplier, and the
STFT window twice) at 4.42 + 2 x 22.11 = 48.6 ms per call of blocking. So of
a 66.4 ms vocoder call, 48.6 ms is the thread waiting at those copies and
17.8 ms is everything else HiFT does — which is the 18.16 ms of §2, and the
5.03 ms this PR removes comes out of it. (Those copies are a separate problem and
not this PR's; removing them does not need this change and this change does not
need them removed.)

5. End-to-end

round arm die RTF e2el completed tokens audio
1 ctl 1 0.45594 1912.3 32/32 435 135.64 s
1 this PR 0 0.45767 1916.5 32/32 435 135.64 s
+0.38%
2 ctl 0 0.46569 1949.6 32/32 435 135.64 s
2 this PR 1 0.47101 1975.1 32/32 435 135.64 s
+1.14%

The two §4 rounds — same protocol, with the timer in both arms — read −1.18%
and −2.30% on the same column. So across four paired die-swapped rounds the
deltas are +0.38%, +1.14%, −1.18%, −2.30%: the sign does not settle, and this
change is not resolvable end-to-end on this base.
I would rather say that than
pick the two rounds I like.

That is expected rather than disappointing. On this base stage 2 runs behind
the Talker: a saving inside the vocoder is hidden by the AR stage in front of it,
and only the final chunk is on the critical path, so ~25 ms of vocoder time per
request turns into a few ms of e2el — well under the 1.35% two identical arms
have measured apart on these dies (ctl itself drifted 2.1% between rounds 1
and 2). It stops being hidden as soon as the AR stages get faster, which is what
most of the open performance PRs on this branch do, and on any deployment where
stage 2 is not sharing a die with them.

The arms are otherwise identical: 32/32 completed, 435 output tokens and 135.64 s
of audio in all four arms. The measurements that should carry this PR are §2
and §4.

…every chunk

MiniCPM-o 4.5's Code2Wav stage builds its HiFT vocoder with weight norm live on
all 82 convolution weights and leaves it that way for the life of the server.
An inference weight never changes, so every streamed audio chunk recomputes
`g * v / ||v||` for each of them -- a norm reduction and a broadcast multiply per
convolution -- and gets the same tensor back every time.

`parametrize.remove_parametrizations(..., leave_parametrized=True)` stores the
weight the module is already using and drops the parametrization, so the
arithmetic is unchanged; it simply happens once, at load, rather than on the
streaming path.

Measured on an Atlas A3 inside one process on one loaded HiFT, arms alternating:
18.16 ms per vocoder call with the parametrization live against 13.12 ms folded
-- -5.03 ms, -27.7% -- and the five samples per arm do not overlap. Output is
unchanged to 7e-4 absolute against a 0.23 peak, which is the run-to-run noise of
the vocoder itself: the same arm re-run differs by 7.3e-4, the two arms by 6.3e-4.
In situ, timed around the call itself during two paired rounds with the dies
swapped, -1.05 and -9.59 ms per call; the spread is contention, since an in-situ
call is mostly spent waiting at HiFT's own host-to-device copies, and the mean of
-5.3 ms is where the isolated figure says it should be. Five vocoder calls per
request, counted in the same runs.

End-to-end latency does not resolve it, and the honest reason is structural
rather than a measurement failure: on an unmodified tree stage 2 runs behind the
autoregressive stages, so those ~25 ms per request sit off the critical path and
four paired die-swapped rounds read +0.38%, +1.14%, -1.18%, -2.30%. The saving
becomes visible exactly when the AR stages get faster, which is the direction
every open performance change on this model is pushing. So the claim here is the
per-call figure, and the end-to-end rounds are reported only as a
no-regression check.

HiFT does ship a `remove_weight_norm()` of its own, but it calls the legacy
`torch.nn.utils.remove_weight_norm`, which only knows the forward-pre-hook form.
`flashcosyvoice` selects `torch.nn.utils.parametrizations.weight_norm` whenever
torch provides it, so on any current torch that method raises
`ValueError: weight_norm of 'weight' not found` rather than folding anything --
and nothing in the serving path calls it. `fold_weight_norm` handles both
spellings and returns the names it folded, so a no-op shows up in the log line
instead of passing silently.

The fold is on by default; `token2wav_hift_fold_weight_norm: false` in the
stage's `extra` config turns it off. It is not accelerator-specific -- the work
it removes is the same everywhere -- and that is why the unit tests are plain
CPU tests over both spellings.

Signed-off-by: junbo <88826518+Junbo2002@users.noreply.github.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

@Junbo2002

Copy link
Copy Markdown
Author

Self-review, as vllm-omni-review-bot asks for before reviewers are assigned.

Blast radius. One new module-level function, four lines calling it in
MiniCPMO45Code2Wav._build_backend, and four CPU tests. The call site is inside
the MiniCPM-o 4.5 Code2Wav backend build, after Token2wav(...) has finished
loading hift.pt, and it touches only token2wav.hift. No other model, stage or
accelerator can reach it. It is on by default and
token2wav_hift_fold_weight_norm: false in the stage's extra turns it off.

The four things I would attack first if this landed in my queue:

  1. You are changing the weights of a loaded model. Only in the sense that
    leave_parametrized=True stores the tensor the module was already computing
    and returning on every forward. The arithmetic that produced it is unchanged
    — it now runs once at load instead of once per streamed chunk. The CPU tests
    assert the folded weight is bit-identical to the effective weight and that
    the forward output is unchanged, for both the parametrized and the legacy
    forward-pre-hook spelling.

  2. It mutates the module's state_dict keys.
    parametrizations.weight.original0/1 become weight. That is real and I
    flagged it in the body rather than leaving it for a reviewer. Nothing in the
    serving path reads HiFT's state dict after construction: the stage's
    load_weights puts nothing further into HiFT, and the fold runs after
    Token2wav's own constructor has loaded the checkpoint. If you would still
    rather it were opt-in, flipping the default is a one-character change and I
    have no objection.

  3. Your end-to-end table shows nothing. Correct, and I reported all four
    paired rounds including the two that went the wrong way rather than the two I
    liked. The reason is structural and I would rather state it than hide it: on
    an unmodified tree stage 2 runs behind the autoregressive stages, so ~25 ms
    per request of vocoder time is off the critical path. The claim this PR makes
    is the per-call figure in §2 (one process, one loaded HiFT, arms alternating,
    samples that do not overlap) corroborated by the in-situ timing in §4. The
    end-to-end rounds are in the body as a no-regression check, nothing more.

  4. You are filing against main but measured on ecd9d99da. Also stated in
    the body. The vLLM in the Ascend image these machines run has no
    vllm.v1.engine.utils.CoreEngineLaunch, which main's stage startup imports,
    so main cannot serve there at all. The changed function is byte-identical on
    both bases, and the unit-test rows for main in the Test Result section were
    run on main itself on the same machine (389 → 393 passed; the three
    failures are on main unmodified and want a newer vLLM).

Why it has not been done already. HiFT ships its own remove_weight_norm(),
so the model author clearly intended this fold — but it calls the legacy
torch.nn.utils.remove_weight_norm, which raises
ValueError: weight_norm of 'weight' not found against the
torch.nn.utils.parametrizations.weight_norm that flashcosyvoice selects on
any current torch, and nothing in the serving path calls it anyway.

@vllm-omni-review-bot

Copy link
Copy Markdown

This PR appears to be related to model: minicpm.

Model owners: @y-null

@Junbo2002, please review your own changes and leave a short self-review comment describing what you checked. PRs without author self-review may not be assigned a reviewer.

Please take a look when you have a chance. If you would like an automated review, mention @vllm-omni-review-bot in a comment.

@vllm-omni-review-bot

Copy link
Copy Markdown

Omni ReviewBot: no human activity for 21 days

@Junbo2002 this pull request has had no human commit, comment or review since 2026-09-02. Please consider marking this PR as draft until work can resume. The author or a maintainer decides whether to change the PR state.

To keep it moving, any one of these is enough: push an update, reply to the open blocker, or post the current plan and timeline.

@hsliuustc0106 hsliuustc0106 added the enhancement New feature or request label Sep 24, 2026
@vllm-omni-review-bot

Copy link
Copy Markdown

Omni ReviewBot: no human activity for 30 days

@Junbo2002 this pull request has had no human commit, comment or review since 2026-09-02. Please consider closing this PR if the work is no longer planned. The author or a maintainer decides whether to change the PR state.

To keep it moving, any one of these is enough: push an update, reply to the open blocker, or post the current plan and timeline.

@vllm-omni-review-bot

Copy link
Copy Markdown
Omni ReviewBot routing record

Assigned Strict on cursor (cursor-grok-4.6-high) under experiment fleet-strict-cursor-grok46-zcode-glm53flash-5050-c5-z10-20261002.

@vllm-omni-review-bot vllm-omni-review-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Omni ReviewBot review

1 actionable finding(s).

CI at 0b56999c4bf5 (2026-10-10T07:56:11.319404+00:00): required check(s) blocking: buildkite/vllm-omni (missing).

Full review analysis

Scan:

Category Result
Tests / verification 1 finding(s) below
Security no finding reported
Docs / comments no finding reported
Behavior / compatibility no finding reported
Correctness no finding reported

Validated:

  • [resolved] 'changing loaded weights' / state_dict key rewrite: fold is after hift.pt load; load_weights returns named_parameters() post-fold. Residual: no consumer of HiFT state_dict after construction found in serving, but tests never load a real hift.pt.
  • [resolved] HiFTGenerator.remove_weight_norm (hifigan.py:478) uses legacy remove_weight_norm and calls m_source.remove_weight_norm which SourceModuleHnNSF does not define; PR does not call it. Residual: name-gate skip still no-ops.
  • [resolved] leave_parametrized=True bit-identity: tests assert_close rtol=0 atol=0 at test_code2wav_batching.py:1362; residual: fixture has no ConvTranspose1d/f0_predictor (round 1)
  • [resolved] production CUDA/NPU graphs capture after fold (batched_token2wav.py:234; npu minicpmo_4_5_code2wav.py:230); residual: test_cuda_graph_wrapper.py:59 still unfolds (round 1)
  • [claim-verified] 82 HiFT convs off-fixture: _build_hift upsample_rates=[8,5,3] → conv_pre+3 ups+conv_post+96 res+36 source_res+5 f0 = 82; source_downs are plain Conv1d and correctly skipped.
  • [claim-verified] only product caller of fold_weight_norm is _build_backend on token2wav.hift (code2wav.py:906); StepAudio2/GLM-TTS/CosyVoice3 build separate HiFTGenerator instances

1 actionable finding(s).

Verdict: COMMENT

See inline comments below.


🤖 This review was generated by InferMatrix Copilot, an open-source repo-maintenance agent for PR review, CI debugging and issue triage. Try it on your own repo, and ⭐ star it if it helped!

else:
from torch.nn.utils.parametrizations import weight_norm

self.conv_pre = weight_norm(nn.Conv1d(4, 4, 3, padding=1))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] The new fold tests only build _WeightNormedStack with weight-normed Conv1ds…

Evidence and suggested fix

The new fold tests only build _WeightNormedStack with weight-normed Conv1ds and call fold_weight_norm directly (test_code2wav_batching.py:1337-1357). They never enter MiniCPMO45Code2Wav._build_backend (the only backend test monkeypatches it away at test_code2wav_batching.py:1140) and never set token2wav_hift_fold_weight_norm to false — that extra exists only at minicpmo_4_5_code2wav.py:905-907. Production HiFT ups are weight_norm(ConvTranspose1d(...)) (hifigan.py:432-441, via upsample_rates=[8,5,3] in step_audio2_token2wav.py:99-106). Deleting those four dispatcher lines, or a fold that skipped ups, still leaves pytest -sv tests/model_executor -m 'core_model and cpu' green. Drive a backend-level case that the extra flag actually gates the call, and put a weight-normed ConvTranspose1d on the fixture (assert its .weight is in folded).

Evidence: Trigger: the PR’s new CPU tests construct _WeightNormedStack and call fold_weight_norm directly; they never run MiniCPMO45Code2Wav._build_backend or pass token2wav_hift_fold_weight_norm. Unmet requirement: those tests stay green if the production dispatcher is deleted, and they never assert a folded ConvTranspose1d even though production HiFT ups are that type. tests/model_executor/models/minicpmo_4_5/test_code2wav_batching.py:1337 self.conv_pre = weight_norm(nn.Conv1d(4, 4, 3, padding=1)) ; test_code2wav_batching.py:1338 self.body = nn.Sequential(weight_norm(nn.Conv1d(4, 4, 3, padding=1))) ; test_code2wav_batching.py:1357 folded = fold_weight_norm(model) ; vllm_omni/model_executor/models/minicpmo_4_5/minicpmo_4_5_code2wav.py:905 if bool(extra.get("token2wav_hift_fold_weight_norm", True)): ; minicpmo_4_5_code2wav.py:906 folded = fold_weight_norm(token2wav.hift) . Unchanged by this diff, present in the PR-time tree: test_code2wav_batching.py:1140 monkeypatch.setattr(model, "_build_backend", build_backend) ; vllm_omni/model_executor/models/step_audio2/step_audio2_token2wav.py:101 upsample_rates=[8, 5, 3], ; vllm_omni/model_executor/models/cosyvoice3/code2wav_core/hifigan.py:433-435 weight_norm( / ConvTranspose1d( . Grep: token2wav_hift_fold_weight_norm has no test-file hits; only code2wav.py:905.

Suggestion: self.conv_pre = weight_norm(nn.Conv1d(4, 4, 3, padding=1))
self.up = weight_norm(nn.ConvTranspose1d(4, 4, 4, stride=2, padding=1))
self.body = nn.Sequential(weight_norm(nn.Conv1d(4, 4, 3, padding=1)))

@amy-why-3459 amy-why-3459 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed commit 0b56999c4bf50a6b2f9605ff84954f7a2632e0ac.

No actionable bugs were found in this static review. This does not prove the code is correct.

No actionable issues found in this static review.

Validation: Verified the exact head and inspected the full diff against the specified merge base, both changed files, backend construction and weight loading, the vendored HiFT implementation, batching and graph initialization, NPU integration, and relevant tests. Runtime/GPU tests were not run.

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

Labels

enhancement New feature or request Kernel optimization Codes related to optimize kernel execution to improve hardware utilization omni code related to omni models

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants