Skip to content

Derive Qwen3-TTS codec conv encoder from config instead of hardcoding widths - #552

Merged
justinchuby merged 3 commits into
mainfrom
justinchuby-fix-codec-encoder-width-mismatch
Aug 23, 2026
Merged

Derive Qwen3-TTS codec conv encoder from config instead of hardcoding widths#552
justinchuby merged 3 commits into
mainfrom
justinchuby-fix-codec-encoder-width-mismatch

Conversation

@justinchuby

@justinchuby justinchuby commented Aug 22, 2026

Copy link
Copy Markdown
Member

The bug

_MimiConvEncoder in src/mobius/models/qwen3_tts_tokenizer.py took no config at all and hardcoded its channel widths and kernels (1→64→128→256→512→1024→512). Its output fed CodecEncoderTransformerModel(hidden_size=config.codec_encoder.hidden_size), which has no input projection — so the conv output width and hidden_size had to agree, and nothing checked that they did.

When they disagreed, the builder happily emitted a malformed graph:

Conv encoder/encoder/layers/14/conv  weight [512,1024,3]  -> [batch, T, 512]
LayerNormalization  weight [32] bias [32]  over a 512-wide tensor   <-- malformed
MatMul  q_proj [32,32] vs 512 -> "Incompatible MatMul contraction dimensions: 512 vs 32"

Why it was invisible

SymbolicShapeInferencePass catches inference errors and logs "Symbolic shape inference failed (upstream bug); skipping" — a deliberate generic guard. The tiny config in TestBuildCodecGraph._codec_config() set codec_encoder.hidden_size=32, so the codec tests were building a broken graph and passing.

The fix

The hardcoded widths were not dictated by the checkpoint — HF's MimiEncoder.__init__ derives the entire stack from config, and mobius's literals were just that derivation inlined with num_filters=64, upsampling_ratios=[8,6,5,4], num_residual_layers=1, kernel_size=7, last_kernel_size=3. So rather than adding a validation guard, this parameterizes the stack and eliminates the mismatch class:

  • CodecEncoderConfig: added the missing HF fields (audio_channels, num_filters, num_residual_layers, kernel_size, last_kernel_size, residual_kernel_size, compress, upsampling_ratios) with HF's own defaults, extracted in ArchitectureConfig.from_transformers.
  • _MimiConvEncoder: builds by HF's derivation. The parameterless ELU slots are preserved so encoder.encoder.layers.* numbering still matches the checkpoint exactly. kernel = ratio * 2 and the trailing conv's out-channels is hidden_size, both derived.
  • _EncoderResBlock: inner width is now dim // compress with a configurable kernel.

Also fixed: the encoder RVQ could not load the real checkpoint

This one is a weight-loading correctness fix, not just a shape-consistency fix, which is why it belongs here despite being adjacent to the original scope.

_EncoderSplitRVQ used input_dim=codebook_dim and dim=codebook_dim // 2. The real checkpoint has encoder.quantizer.*.input_proj.weight [256, 512, 1] and codebook.embed_sum [2048, 256], with encoder_config.codebook_dim=256. So the old code built Conv1d(256→128) projections and 128-wide codebooks: the encoder could never have loaded the real checkpoint's weights, independently of the malformed-graph issue. It only happened to be self-consistent on the enc is None fallback path, where codebook_dim defaulted to 512.

Now it projects hidden_size → codebook_dim, matching HF MimiResidualVectorQuantizer.

The encoder/decoder asymmetry is intentional — please don't unify these paths

The two sides interpret codebook_dim differently, and both are correct:

config value RVQ input codebook dim checkpoint input_proj
encoder codebook_dim=256 (Mimi semantics: the codebook dim itself) hidden_size=512 256 [256, 512, 1]
decoder codebook_dim=512 (the RVQ input dim) 512 512 // 2 = 256 [256, 512, 1]

So the decoder's // 2 convention in SplitResidualVectorQuantizer is right and is left untouched; only the encoder path was misreading its field. A future "cleanup" that unifies the two would reintroduce this bug.

Verification

Symbolic-shape-inference failures across TestBuildCodecGraph (measured by temporarily instrumenting infer_symbolic_shapes, since the pass swallows them — instrumentation not committed):

failures
before 6
after 0

Default config reproduces the real checkpoint exactly — verified against the safetensors header of Qwen/Qwen3-TTS-Tokenizer-12Hz: all 14 conv-stack weights match by name and shape, including the ResBlock layers.N.block.1/.3 names and the 0,1,3,4,6,7,9,10,12,14 numbering, as do both RVQ input_proj/output_proj pairs and the codebooks. This is asserted by tests, so indexing or width drift (which would otherwise surface far away at weight-load time) fails loudly.

Tiny test config keeps the real depth and only shrinks widths (num_filters=4, hidden_size=321→4→8→16→32→64→32), so it exercises the same weight-name structure the real model depends on. Codec test runtime: 2.05s (4 tests) → ~2.1s (9 tests).

Suite: pytest tests/build_graph_test.py tests/cli_test.py src/ -k "not phi4mm and not apply_weights_unknown" -n auto → 8 failed, 4613 passed — the same 8 pre-existing failures on main (onnx_genai jsonschema drift + one qwen_image golden). lintrunner clean.

New tests

  • test_default_config_conv_stack_matches_checkpoint — asserts derived parameter names and shapes against the real checkpoint.
  • test_tiny_config_keeps_checkpoint_layer_names — tiny config has identical naming, narrower tensors.
  • test_conv_stack_indices_follow_config — 2 ratios → final conv at layers.8; 4 ratios × 2 residual layers → layers.18.
  • test_hidden_size_drives_conv_output_width — direct regression guard for the original bug.
  • test_default_config_rvq_projections_match_checkpoint — asserts input_proj (256, 512, 1), output_proj (512, 256, 1) and (2048, 256) codebooks, guarding the weight-loading fix.

Decoder conv stack

Checked: the codec decoder does not have the mirrored conv problem. Qwen3TTSCodecDecoderModel already derives everything from config (latent_dim, decoder_dim, upsample_rates, upsampling_ratios), and CodecDecoderTransformerModel has an input_proj/output_proj pair that adapts latent_dim ↔ hidden_size, so no width can be stranded. No change needed there.

Scope

Limited to the codec tokenizer model, its config, and its tests. Does not touch reuse.py, _ssm.py, _mamba_block.py, the onnx_genai integration (covered by #551), or SymbolicShapeInferencePass.

The Mimi-style conv encoder in the Qwen3-TTS codec tokenizer hardcoded
its channel widths (1->64->128->256->512->1024->512) and kernels, while
the transformer that consumes its output was sized from
`codec_encoder.hidden_size`. Nothing tied the two together, so any config
whose `hidden_size` was not 512 built a structurally malformed graph: a
LayerNormalization with `hidden_size`-wide weights applied to a 512-wide
tensor, then `Incompatible MatMul contraction dimensions: 512 vs 32` at
the first q_proj.

This was invisible because `SymbolicShapeInferencePass` deliberately
swallows inference errors, and the tiny test config in
`TestBuildCodecGraph` used `hidden_size=32` — so the codec tests were
building a broken graph and passing.

HF's `MimiEncoder.__init__` derives the whole stack from config, so the
hardcoded widths were an inlined specialization rather than a fixed
property of the checkpoint. Parameterize it the same way:

- Add the missing HF fields to `CodecEncoderConfig` (`audio_channels`,
  `num_filters`, `num_residual_layers`, `kernel_size`, `last_kernel_size`,
  `residual_kernel_size`, `compress`, `upsampling_ratios`) and read them
  in `ArchitectureConfig.from_transformers`.
- Build `_MimiConvEncoder` by HF's derivation, keeping the parameterless
  ELU slots so `encoder.encoder.layers.*` numbering still matches the
  checkpoint.

Also fixes the encoder RVQ widths, which had the same root cause: it
projected from `codebook_dim` instead of `hidden_size` and halved the
codebook dim. The real checkpoint has `input_proj [256, 512, 1]` and
`embed_sum [2048, 256]` with `encoder_config.codebook_dim=256`, so the
previous code built `Conv1d(256 -> 128)` on a 512-wide tensor and could
not have loaded those weights.

The tiny test config now shrinks only widths (`num_filters=4`), keeping
the real depth so layer numbering is exercised. New tests assert the
default config reproduces the checkpoint's conv weight *names* and
shapes, that the tiny config keeps the same names, and that indices track
`upsampling_ratios`/`num_residual_layers`.

Symbolic-shape-inference failures across the codec tests: 6 before, 0
after (measured by instrumenting `infer_symbolic_shapes`, since the pass
swallows them).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchuby@users.noreply.github.com>
@justinchuby
justinchuby requested review from a team and a lite review from Copilot August 22, 2026 23:28
@github-actions

github-actions Bot commented Aug 22, 2026

Copy link
Copy Markdown

Performance Comparison

Comparing e5d337be444b94

Model Metric Baseline Current Delta
bert (feature-extraction) model_size_bytes 359 KB 359 KB +0.0%
bert (feature-extraction) num_nodes 68 68 +0.0%
falcon model_size_bytes 364 KB 364 KB +0.0%
falcon num_nodes 66 66 +0.0%
gemma2 model_size_bytes 428 KB 428 KB +0.0%
gemma2 num_nodes 105 105 +0.0%
gpt2 model_size_bytes 388 KB 388 KB +0.0%
gpt2 num_nodes 54 54 +0.0%
llama model_size_bytes 425 KB 425 KB +0.0%
llama num_nodes 60 60 +0.0%
llama (static-cache) model_size_bytes 425 KB 425 KB +0.0%
llama (static-cache) num_nodes 56 56 +0.0%
mamba (ssm-text-generation) model_size_bytes 296 KB 296 KB +0.0%
mamba (ssm-text-generation) num_nodes 94 94 +0.0%
phi3 model_size_bytes 421 KB 421 KB +0.0%
phi3 num_nodes 58 58 +0.0%
phi3 (static-cache) model_size_bytes 421 KB 421 KB +0.0%
phi3 (static-cache) num_nodes 54 54 +0.0%
qwen2 model_size_bytes 425 KB 425 KB +0.0%
qwen2 num_nodes 60 60 +0.0%
qwen2 (static-cache) model_size_bytes 425 KB 425 KB +0.0%
qwen2 (static-cache) num_nodes 56 56 +0.0%
qwen3_5_moe (hybrid-text-generation) model_size_bytes 506 KB 506 KB +0.0%
qwen3_5_moe (hybrid-text-generation) num_nodes 264 264 +0.0%
qwen3_5_text (hybrid-text-generation) model_size_bytes 458 KB 458 KB +0.0%
qwen3_5_text (hybrid-text-generation) num_nodes 126 126 +0.0%
qwen3_5_vl (hybrid-qwen-vl) model_size_bytes 977 KB 977 KB +0.0%
qwen3_5_vl (hybrid-qwen-vl) num_nodes 428 428 +0.0%
t5 (seq2seq) model_size_bytes 836 KB 836 KB +0.0%
t5 (seq2seq) num_nodes 176 176 +0.0%
whisper (speech-to-text) model_size_bytes 1008 KB 1008 KB +0.0%
whisper (speech-to-text) num_nodes 128 128 +0.0%

No performance regressions.

@github-actions

github-actions Bot commented Aug 22, 2026

Copy link
Copy Markdown

🏗️ Architecture Diff

Comparing e5d337be444b94

Model Sub-model Changes Status
bert (feature-extraction) model 0
falcon model 0
gemma2 model 0
gemma4 (gemma4) decoder 0
gemma4 (gemma4) embedding 0
gemma4 (gemma4) vision_encoder 0
gemma4_text model 0
gpt2 model 0
llama model 0
llama (static-cache) model 0
mamba (ssm-text-generation) model 0
phi3 model 0
phi3 (static-cache) model 0
qwen model 0
qwen (static-cache) model 0
qwen2 model 0
qwen2 (static-cache) model 0
qwen2_moe model 0
qwen2_moe (static-cache) model 0
qwen3 model 0
qwen3 (static-cache) model 0
qwen3_5_moe (hybrid-text-generation) model 0
qwen3_5_text (hybrid-text-generation) model 0
qwen3_5_vl (hybrid-qwen-vl) decoder 0
qwen3_5_vl (hybrid-qwen-vl) embedding 0
qwen3_5_vl (hybrid-qwen-vl) vision_encoder 0
qwen3_moe model 0
qwen3_moe (static-cache) model 0
qwen3_next (hybrid-text-generation) model 0
t5 (seq2seq) decoder 0
t5 (seq2seq) encoder 0
whisper (speech-to-text) decoder 0
whisper (speech-to-text) encoder 0

No architecture changes detected.


Legend: ⚪ No change · 🔵 Minor (attrs/inits) · 🟡 Moderate (nodes added/removed) · 🔴 Major (interface changed)

The RVQ width fix is a weight-loading correctness fix: before it, the
encoder built Conv1d(256->128) projections while the checkpoint has
input_proj [256, 512, 1] and embed_sum [2048, 256], so the real weights
could not have loaded at all. Assert the derived projection and codebook
shapes for the default config, the same way the conv-stack test guards
layer naming.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchuby@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR makes the Qwen3-TTS codec encoder derive convolutional and RVQ dimensions from configuration, improving checkpoint compatibility and preventing malformed graphs.

Changes:

  • Added Mimi encoder configuration fields and extraction.
  • Parameterized convolutional layers, kernels, residual blocks, and output widths.
  • Corrected RVQ projection dimensions.
  • Added regression tests for shapes and layer naming.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Summary and review status
tests/build_graph_test.py Adds regression coverage for derived stack shapes, indices, and output widths.
src/mobius/models/qwen3_tts_tokenizer.py Implements configurable convolution and RVQ dimensions. Moderate follow-ups: assert RVQ projection shapes and handle non-mono audio_channels in CodecTask.
src/mobius/_configs/_sub_configs.py Adds Mimi encoder configuration fields and defaults.
src/mobius/_configs/_base.py Extracts the new fields from Transformers configs. Moderate follow-up: add coverage using non-default nested encoder values, including upsampling_ratios.
Suppressed comments (1)

src/mobius/_configs/_sub_configs.py:155

  • audio_channels is now used to size the first convolution, but CodecTask._build_encoder still declares waveform as [batch, 1, audio_len] (src/mobius/tasks/_codec.py:91-93). For any non-mono CodecEncoderConfig, the emitted Conv expects audio_channels inputs while the graph supplies one, so this configuration path builds an invalid encoder. Either thread this field into the task input shape or reject values other than 1.
    audio_channels: int = 1

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/mobius/_configs/_base.py
Comment thread src/mobius/models/qwen3_tts_tokenizer.py
Comment thread src/mobius/models/qwen3_tts_tokenizer.py
Two review findings, both valid:

1. `audio_channels` sized `encoder.layers.0.conv` but `CodecTask.
   _build_encoder` always declared `waveform` as `[batch, 1, audio_len]`,
   so any non-mono config fed a one-channel graph input into a conv
   expecting more. Thread the configured channel count into the task
   input shape (falling back to 1 when there is no codec_encoder, as for
   Mimi), and add a build test asserting the input channel dim follows
   the config.

2. The new nested-config extraction was untested — the existing tests
   construct `CodecEncoderConfig` directly, so a wrong getattr key or
   default would still pass. Add extraction tests covering the default
   values, non-default values including `upsampling_ratios`, and the
   fallback path where a config omits the conv fields entirely.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchuby@users.noreply.github.com>
@justinchuby
justinchuby requested a lite review from Copilot August 23, 2026 00:45
@justinchuby
justinchuby merged commit 4f7e5c8 into main Aug 23, 2026
21 of 24 checks passed
@justinchuby
justinchuby deleted the justinchuby-fix-codec-encoder-width-mismatch branch August 23, 2026 00:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

src/mobius/models/qwen3_tts_tokenizer.py:360

  • The new config-driven stack is covered only through named_parameters() shape/name assertions; none of the added tests executes the exported encoder. Because the original malformed graph was hidden by swallowed symbolic-inference errors, a graph can still build in these tests yet fail at ORT load/run. Add a tiny OnnxModelSession test using the generated package (including a non-default ratio/width configuration) to verify that the derived padding, strides, and full encoder path are executable.
        for ratio in reversed(upsampling_ratios):
            current_scale = scaling * num_filters
            for _ in range(num_residual_layers):
                layers.append(
                    _EncoderResBlock(

codes: (B, 16, T) int64 audio codes.
"""
# 1. Conv encoder: (B, 1, samples) -> (B, 512, T')
# 1. Conv encoder: (B, 1, samples) -> (B, hidden_size, T')
justinchuby added a commit that referenced this pull request Aug 23, 2026
…ive contract (#554)

Fixes six long-standing schema-conformance failures caused by mobius's
legacy onnx-genai emitters drifting from the runtime's published JSON
schema.

## Why the drift was invisible

The conformance tests searched a couple of hard-coded local onnx-genai
checkout paths and `pytest.skip`ped when none was found. CI has no such
checkout, so **six tests never ran there**; on the machines that did
have one, the result depended on whatever revision that clone happened
to sit on. Two upstream contract redesigns accumulated unnoticed.

## The two contract redesigns

1. **`pipeline` is now `PipelineSpec`** — a single property, `workflow`
(a typed SSA graph), with `additionalProperties: false`. The legacy
emitters produced `{models, dataflow, strategy, phases}`, all of which
the schema now rejects.
2. **`speculative` is now `SpeculativeContract`** — requires `{proposer,
target, vocabulary, max_proposal_width}` and forbids every field of the
old flat MTP block. The flat `SpeculatorConfig` that block was modelled
on still exists upstream, but it describes a HuggingFace `config.json`
section, not `InferenceMetadata.speculative`.

## What was migrated

### Diffusion — `build_diffusion_pipeline_metadata`
Now emits the denoise loop explicitly: the solver, sigma/alpha schedule,
timestep table, guidance combine and output clamp are real ONNX
components built from mobius's policy library and shipped with the
document.

It **cannot** delegate to `build_diffusion_workflow_metadata`: the
ComfyUI conversion path has no component graphs at all (it deliberately
does not build or export them), and that builder derives everything from
live `ir.Model`s. So the workflow is built directly, reusing
`workflow_metadata`'s `_invoke` / `_publish_workflow_v1` and
`mobius.generation`'s solver builders. The schedule is derived from the
scheduler's own betas through the same diffusers-compatible helpers the
package exporter uses, so a ComfyUI conversion and a package export of
the same checkpoint describe the same dynamics. img2img's `start_step`
lowers to a sliced schedule; the VAE `scaling_factor` and the
sigma-space initial-state scale are emitted as explicit components.

Three cases now **fail closed** instead of being silently mis-described:
- an ancestral sampler (mobius ships no stochastic solver),
- Karras/exponential sigma spacing (the workflow ships the sigma table
as a constant, so a `use_karras_sigmas` hint is no longer sufficient),
- a latent-only graph with no VAE decode.

### MTP — `write_mtp_speculator_metadata`
Emits a `SpeculativeContract` anchored to the backbone's workflow: it
registers the head as a workflow component, names the target by its
declared `logits` port role, states the hidden handoff as
`port_bindings.target_hidden_context` plus a `hidden_states` role on the
target output, and completes the rollback capacity its own claim
requires.

It is declared `block`, not `chained`: a chained proposer must expose a
`logits_output` carrying the next-token distribution, and this sidecar
emits only `mtp_hidden` — the runtime decodes it through the target's
shared LM head, which is why that initializer is listed in
`shared_weights`.

### VLM
`build_native_vlm_package_metadata` produces mobius's **internal**
structural descriptor, not a publishable document —
`build_vlm_workflow_metadata` already consumes it and republishes only
`preprocessing` under a real `pipeline.workflow`. The actual bug was
that `write_native_vlm_package_metadata` wrote that descriptor to
`inference_metadata.yaml`. It now writes the workflow document, and the
tests validate the published document instead of the descriptor.

### CI visibility
The upstream schema is vendored under
`src/mobius/integrations/onnx_genai/_schema/` and is the default, so
conformance never skips and drift becomes a test failure. A local
checkout is no longer consulted implicitly — one that is ahead of or
behind `main` reintroduces exactly the machine-dependent result that hid
this bug. Set `ONNX_GENAI_SCHEMA` to validate against a specific
revision.

Three further conformance tests (codec, speech-to-text, duplex
workflows) were skipping for the same reason and now run.

## Tests

Migrated in the same change rather than loosened:
- `TestMtpSpeculatorMetadata::test_exact_schema_keys_and_values` asserts
the new contract; `test_no_legacy_field_names` is repointed at the
now-legacy field names.
- A new MTP test anchors against a workflow
`write_decoder_workflow_metadata` actually emits (which ships ~11
generated policy components), so target selection can't regress to "the
only ONNX component".
- New tests pin that the solver schedule comes from the scheduler's
betas rather than a placeholder ramp.
- Diffusion/ComfyUI tests assert workflow structure instead of the
removed `strategy` block; scheduler facts that were duplicated into the
document are asserted on the parsed `ComfyUIWorkflow` where they
actually live.

## Verification

Rebased onto current `main` (includes #552 and #553).

```
python -m pytest tests/build_graph_test.py tests/cli_test.py src/ -q \
  -k "not phi4mm and not apply_weights_unknown" -n auto
```

| | before (`main` at branch point) | after |
|---|---|---|
| full suite | 8 failed / 4613 passed | **0 failed / 4641 passed** |
| `src/mobius/integrations/onnx_genai/` with `ONNX_GENAI_SCHEMA` at
upstream `main` | 7 failed / 240 passed | **258 passed / 1 skipped** |

Six of those eight failures are the schema-drift ones fixed here. The
other two —
`qwen_image_test.py::test_deterministic_l4_l5_image_edit_golden` and
`TestNativeVlmPackageMetadata::test_cached_gemma_processor_matches_emitted_patch_budget`
— were fixed by #553, which is now merged; this branch preserves both of
its changes through the rebase.

`lintrunner` is clean.

## Sequencing note

`workflow_metadata.py` and `auto_export.py` are **not modified** (both
are read from only), so this does not conflict with #551.

---------

Signed-off-by: Justin Chu <justinchuby@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
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.

2 participants