Skip to content

Fix GGUF config mapping for scaled RoPE and hybrid attention - #458

Closed
justinchuby wants to merge 46 commits into
mainfrom
pr-412
Closed

justinchuby wants to merge 46 commits into
mainfrom
pr-412

Conversation

@justinchuby

Copy link
Copy Markdown
Member

Summary

Fix GGUF -> ArchitectureConfig gaps that surface as deep model-construction errors instead of diagnosable GGUF metadata issues. These are general GGUF mapping bugs, not one-checkpoint quirks: any scaled-RoPE GGUF can set rope_type to linear/yarn/longrope while leaving config.rope_scaling unset, and any hybrid/sliding-window architecture needs the layer schedule and local RoPE fields populated before graph construction.

Changes:

  • Populate rope_scaling (including factor) when GGUF declares rope.scaling.type as linear, yarn, or longrope; warn and use identity factor 1.0 if the factor key is absent instead of failing later.
  • Populate local/sliding-window RoPE base frequency from rope.freq_base_swa, or default to 10000.0 for sliding-window GGUFs (matching HF Gemma3 defaults).
  • Preserve GGUF attention.sliding_window and derive Gemma3 layer_types from HF's architectural default sliding_window_pattern=6 (five sliding-window layers, then one full-attention layer).
  • Fill other GGUF metadata/model gaps encountered after that: Gemma3 q/k norm config, Gemma3 default activation, Gemma3 GGUF norm tensor mappings, and quantized Gemma3 graph construction so --keep-quantized produces MatMulNBits instead of expanding projections to f16.

Errors this prevents

These failures previously occurred far from GGUF parsing and did not name the missing metadata/config field:

  • TypeError: 'NoneType' object is not subscriptable from LinearRope.__init__ when rope_type was scaled but config.rope_scaling was None.
  • TypeError: unsupported operand type(s) for **: 'NoneType' and 'float' from _get_default_inv_freq when sliding-window local RoPE theta was missing.
  • TypeError: 'NoneType' object is not iterable from gemma3_text.py when config.layer_types was missing.
  • ValueError: Component 'model' has 124 initializer(s) without weights... after graph construction when Gemma3 GGUF norm tensors were skipped/mis-mapped.
  • Silent quantization loss: --keep-quantized built a ~53 GiB f16 model with MatMulNBits=0; after this change the same build is ~16 GiB with MatMulNBits=434.

I considered raising explicit config errors for absent rope.scaling.factor, but kept the warning + identity fallback because 1.0 is a safe no-scaling factor and lets partially specified GGUF metadata remain buildable. The missing local RoPE and layer schedule are now populated before the model code can fail with generic TypeErrors.

Regression tests

Added synthetic metadata/unit tests; no large GGUF is required. I also verified they fail without their corresponding implementation changes:

  • Reverting _config_mapping.py and running python -m pytest src\mobius\integrations\gguf\_config_mapping_test.py -q fails all 5 new config tests (rope_scaling, local RoPE, sliding window/layer types).
  • Reverting _tensor_mapping.py and running python -m pytest src\mobius\integrations\gguf\_tensor_mapping_test.py -q fails the Gemma3 norm mapping regression.
  • Reverting gemma3_text.py and running python -m pytest src\mobius\models\gemma3_text_test.py -q fails the quantized-component and retied-head regressions.

Validation

  • python -m ruff check src\mobius\integrations\gguf\_config_mapping.py src\mobius\integrations\gguf\_config_mapping_test.py src\mobius\integrations\gguf\_tensor_mapping.py src\mobius\integrations\gguf\_tensor_mapping_test.py src\mobius\models\gemma3_text.py src\mobius\models\gemma3_text_test.py
  • python -m ruff format --check src\mobius\integrations\gguf\_config_mapping.py src\mobius\integrations\gguf\_config_mapping_test.py src\mobius\integrations\gguf\_tensor_mapping.py src\mobius\integrations\gguf\_tensor_mapping_test.py src\mobius\models\gemma3_text.py src\mobius\models\gemma3_text_test.py
  • python -m pytest src\mobius\integrations\gguf\ src\mobius\_configs\ src\mobius\models\gemma3_text_test.py -> 182 passed
  • Built the target model:
    python -m mobius build-gguf C:\Users\justinchu\dev\models-gguf\gemma-3-27b-it-Q4_0.gguf --output C:\Users\justinchu\dev\models\gemma-3-27b-onnx --keep-quantized --dtype f16 --ep cuda
  • Verified output:
    • path: C:\Users\justinchu\dev\models\gemma-3-27b-onnx\model.onnx
    • total size: 16.079 GiB
    • onnx.checker.check_model(...): OK
    • MatMulNBits: 434
    • GatherBlockQuantized: 0 (token_embd.weight is Q6_K in this GGUF, so it does not match the graph's Q4_0 projection quantization config and remains f16)

Why this matters

This came up while preparing a 27B int4 model (~15-16 GiB) to test virtual-memory management, weight offloading, weight streaming, and prefetch in onnx-genai on an 8 GB RTX 4060. Prior measurements used qwen2.5-0.5b, whose weights fit comfortably on the card and therefore cannot exercise PCIe saturation or memory-manager pressure.

justinchuby and others added 30 commits July 19, 2026 08:56
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…pipelines

Mobius builds a diffusion model's neural components (denoiser transformer,
VAE) but carries no scheduler loop. onnx-genai's *iterative* pipeline
supplies that loop declaratively from an `inference_metadata` document.
This adds `mobius.integrations.onnx_genai` to emit that document so a
Mobius-built diffusion package is directly runnable by onnx-genai.

- `build_diffusion_pipeline_metadata(...)` / `write_diffusion_pipeline_metadata(...)`:
  produce a `kind: iterative` pipeline with the denoiser loop-carried
  self-edge (`noise_pred -> sample`), per-step `timestep_input`, a
  `scheduler_config` (DDIM: num_train_timesteps/beta_start/beta_end/
  prediction_type), optional VAE (`final_only`, decodes the final latent)
  and text encoder (`prompt_only`, feeds conditioning), and classifier-free
  guidance (`guidance_scale` + `cfg_conditioning_input`) when scale != 1.0.
- `SchedulerConfig` with `.from_diffusers(...)` to read a diffusers
  scheduler_config.json.
- Runtime-agnostic: reads only plain values, no torch/diffusers state.

Tests (7) cover denoiser-only, full VAE+text-encoder+CFG, guidance=1.0
(no CFG), diffusers scheduler parsing, zero-steps rejection, YAML
round-trip, and — importantly — validation of the emitted document against
onnx-genai's published JSON schema (schema/inference_metadata.schema.json).

Verified end-to-end against onnx-genai: a Mobius-built tiny DiT denoiser +
this emitted metadata runs through onnx-genai's DDIM-scheduled iterative
pipeline (onnx-genai PR #35, real_dit e2e test).
…steps

- SchedulerConfig gains beta_schedule (default scaled_linear, the SD schedule)
  and emits it; from_diffusers reads it.
- build_diffusion_pipeline_metadata gains a timesteps= param (explicit
  per-step schedule, length must == num_inference_steps) and defaults the VAE
  latent input to 'latent'.
- Verified end-to-end: onnx-genai runs a full text-encoder -> denoiser(DDIM+CFG)
  -> VAE pipeline from this emitted metadata, pixel-identical to diffusers
  (onnx-genai scripts/diffusion_e2e.py).
… + CLI runtime

Extends the onnx-genai integration beyond diffusion to language models and
wires it into the CLI, so `mobius build ... --runtime onnx-genai` emits a
runnable inference_metadata.yaml for both model families.

- decoder_metadata: build_decoder_metadata / decoder_metadata_from_config /
  write_decoder_metadata — map a Mobius BaseModelConfig/ArchitectureConfig
  (num_attention_heads, num_key_value_heads, head_dim, max_position_embeddings,
  sliding_window) onto onnx-genai's `model.attention` (multi_head/grouped_query)
  + `kv_cache.native_dtype`. Drops DEFAULT_INT sentinels; derives head_dim from
  hidden_size when absent.
- auto_export.write_onnx_genai_config: dispatches on the package — diffusion
  (denoiser/vae) -> iterative pipeline document; else -> decoder document.
- CLI: `--runtime` now accepts `onnx-genai` and writes inference_metadata.yaml.

Tests (17 total): decoder GQA/MHA/sliding-window/sink, from_config derivation +
DEFAULT_INT dropping, YAML round-trip, schema validation against onnx-genai's
published JSON schema; plus the dispatcher for both families.
SchedulerConfig.from_diffusers now maps the diffusers _class_name to the
right onnx-genai scheduler kind (DDIMScheduler->ddim,
EulerDiscreteScheduler->euler) instead of always emitting ddim, and rejects
ancestral/SDE and other unsupported scheduler classes with a clear error so a
built package never silently runs the wrong denoise dynamics.

Add load_diffusers_scheduler_config(source): best-effort read of a checkpoint's
scheduler/scheduler_config.json (local dir or HF hub); unsupported/absent ->
None (warn + caller falls back to DDIM), never fatal. Wire it through
write_onnx_genai_config(source=...) and the CLI (--runtime onnx-genai) so a
diffusion build auto-populates the scheduler from the source checkpoint.

Tests: euler mapping, ancestral/unsupported rejection, local-dir load,
absent->None, unsupported->None fallback, and end-to-end auto-read via
write_onnx_genai_config. 25 onnx_genai integration tests pass.
… metadata

Add mobius.integrations.onnx_genai.comfyui: a proof-of-concept translator that
maps a ComfyUI *API-format* workflow JSON onto onnx-genai's composite iterative
pipeline. The KSampler-centric txt2img graph maps directly:
  KSampler.steps       -> num_inference_steps
  KSampler.cfg         -> guidance_scale (CFG; 1.0 disables)
  KSampler.sampler_name-> scheduler kind (euler, ddim)
  CLIPTextEncode(+/-)  -> text_encoder component + CFG cond/uncond
  VAEDecode            -> vae component (final phase)
It reuses build_diffusion_pipeline_metadata, so the output is the same schema a
Mobius diffusion build emits (topology/params only; weights come from the ONNX
components). Unsupported samplers (dpmpp_2m, euler_ancestral, ...) and non-txt2img
graphs raise a clear ValueError rather than silently running wrong dynamics.

Tests (10, incl. schema conformance against onnx-genai's published
inference_metadata.schema.json): default txt2img, ddim mapping, cfg=1 disables
guidance, prompt-wrapper, unsupported/missing/multiple sampler rejection,
denoiser-only graph, file loader. 35 onnx_genai integration tests pass.
…xport+CLI)

Turn a ComfyUI API-format workflow into a runnable onnx-genai pipeline:

- comfyui.py: parse_comfyui_workflow walks the graph links to recover full run
  params (prompt/negative via CLIPTextEncode, seed, width/height via
  EmptyLatentImage, checkpoint traced through LoraLoader/etc.) plus
  steps/cfg/sampler; maps sampler_name->scheduler kind (euler/ddim), rejects
  unsupported/ancestral samplers.
- checkpoint_export.py: export_checkpoint bridges safetensors->ONNX via diffusers
  (from_single_file for ComfyUI original-SD single files, from_pretrained for
  diffusers dirs / HF ids), emitting text_encoder/denoiser/vae with the exact
  ports onnx-genai's validated diffusion_e2e contract expects (input_ids->
  last_hidden_state; sample/timestep/encoder_hidden_states->noise_pred;
  latent->image with 1/scaling baked in), dynamic spatial/seq axes.
- convert.py: convert_comfyui_workflow reconciles the ComfyUI sampler (kind/
  steps/cfg) with the checkpoint's own noise schedule (betas/num_train_timesteps),
  computes exact diffusers timesteps, writes inference_metadata.yaml + run.json.
- CLI: 'mobius convert-comfyui <workflow.json> --checkpoint <src> -o <dir>'.

Validated end-to-end: converting a real txt2img workflow against
OFA-Sys/small-stable-diffusion-v0 produces a complete composite pipeline
(euler+CFG, correct timesteps) + 3 ONNX components. 41 integration tests pass
(translator graph-resolution, checkpoint tracing, reconciliation, schema
conformance).
…eps)

Euler's linspace inference timesteps are fractional (e.g. 856.29); exporting the
denoiser with an int64 timestep port truncated them before the UNet time
embedding, causing localized pixel error. export_checkpoint gains timestep_dtype
(int64 default, float32 for fractional-timestep schedulers) and convert_comfyui_workflow
selects float32 for euler. End-to-end ComfyUI->onnx-genai render vs diffusers Euler
on OFA-Sys/small-stable-diffusion-v0 improved from max|diff| 0.43 -> 7.9e-3
(mean 2.5e-5); also validates onnx-genai's Euler scheduler on a real SD UNet.
ComfyUI sampler_name 'dpmpp_2m' and diffusers DPMSolverMultistepScheduler now
map to onnx-genai's new dpmpp_2m scheduler kind; convert.py computes the exact
DPM timesteps via diffusers (int64 timestep, unlike Euler's float32). DPM++ 2M is
the default sampler in most SD/ComfyUI workflows, so this substantially widens
ComfyUI coverage. Tests updated (dpmpp mapping in comfyui + from_diffusers;
unsupported examples switched to LMS/dpmpp_2m_sde). 43 integration tests pass.
Add use_karras_sigmas to SchedulerConfig (emitted only when True); from_diffusers
reads it; ComfyUI scheduler='karras' enables it; convert.py computes Karras
timesteps via diffusers and uses a float32 denoiser timestep (Karras timesteps
are fractional). Enables 'DPM++ 2M Karras' — the most popular SD sampler combo.
45 integration tests pass.
The ComfyUI translator reads KSampler.denoise; when <1.0 it computes
start_step = num_steps - round(num_steps*denoise) (matching diffusers
get_timesteps) and emits it in the pipeline metadata (build_diffusion_pipeline_metadata
gains a start_step arg). ComfyUIWorkflow carries denoise/start_step; convert
threads it through. 47 integration tests pass (denoise->start_step mapping,
denoise=1 omits it).
ComfyUI sampler_name 'euler_ancestral' and diffusers EulerAncestralDiscreteScheduler
now map to onnx-genai's new stochastic euler_ancestral kind; convert.py computes
its timesteps (float32, fractional) via diffusers. Other ancestral/SDE samplers
still rejected with a clear error. 49 integration tests pass.
Detect SDXL checkpoints (pipe.text_encoder_2) via DiffusionPipeline auto-loading
and export the SDXL shape: a combined dual text encoder (input_ids + input_ids_2 ->
concatenated penultimate hidden states 'encoder_hidden_states' + pooled
'text_embeds') and the SDXL UNet (sample/timestep/encoder_hidden_states/text_embeds/
time_ids -> noise_pred). build_diffusion_pipeline_metadata gains text_encoder_edges
to route both conditioning outputs; convert.py emits them for SDXL. time_ids is an
external denoiser input the driver supplies; multi-input CFG (onnx-genai) guides
both encoder_hidden_states and text_embeds.

Verified: mobius convert-comfyui on tiny SDXL produces the dual-edge pipeline +
5-input denoiser + dual-io text encoder. 51 integration tests pass (SDXL dual
conditioning-edge routing in metadata + convert).
So the run_comfyui.py driver can branch on SDXL (dual tokenizers + time_ids +
dual-conditioning uncond).
ComfyUI LoraLoader nodes are handled by fusing the LoRA into the base model
before ONNX export (no runtime LoRA support needed). The translator collects
stacked LoraLoaders along the KSampler.model chain (base-first order, with
strength_model); checkpoint_export gains a loras=[(path,strength)] arg that runs
diffusers load_lora_weights + fuse_lora before export; convert_comfyui_workflow
resolves ComfyUI LoRA filenames via a lora_paths map (or absolute paths); the
mobius convert-comfyui CLI gains --lora NAME=PATH (repeatable).

Validated (scripts/lora_e2e.py, onnx-genai): a fused export matches diffusers
load+fuse to 7.9e-6 and differs from the base by 0.63 (LoRA applied). 53
integration tests pass (LoRA collection: stacked order, strengths, none).
Like SDXL, ControlNet fits the declarative pipeline with NO runtime change: the
denoiser is a fused ControlNet+UNet taking an extra constant controlnet_cond image
input (the ControlNet produces down/mid residuals injected into the UNet).
checkpoint_export gains controlnet=<dir|file|__from_unet__>; the translator
collects ControlNetApply(control_net_name, strength); convert_comfyui_workflow
resolves it via controlnet_paths; mobius convert-comfyui --controlnet NAME=PATH.
controlnet_cond is an external denoiser input (like SDXL time_ids) so multi-input
CFG shares it across cond/uncond.

Validated (scripts/controlnet_e2e.py, onnx-genai): a fused ControlNet+UNet export
matches diffusers (unet+controlnet residuals) to 5.8e-6 and differs from the base
UNet by 0.45 (ControlNet has effect). 55 integration tests pass.
Compose the SDXL dual-encoder and ControlNet residual paths (removing the earlier
'not supported' guard): _export_sdxl_controlnet fuses ControlNet + SDXL UNet into a
denoiser taking sample/timestep/encoder_hidden_states/text_embeds/time_ids/
controlnet_cond (the ControlNet also consumes SDXL's added_cond_kwargs). No new
model needed. Validated (scripts/sdxl_controlnet_e2e.py): fused export matches
diffusers to 1.1e-6 and differs from the no-controlnet SDXL UNet by 0.16. 55
integration tests pass.
An inpainting UNet takes 9 input channels (latent[4]+mask[1]+masked_latent[4]) and
predicts the 4-ch noise. Like SDXL/ControlNet it fits the pipeline with NO runtime
change: export_checkpoint auto-detects it (in_channels == out_channels + 5) and
_export_sd_inpaint emits a denoiser taking the 4-ch loop-carried sample plus two
constant inputs mask/masked_latent, concatenated for the UNet. The latent dim is
the UNet out_channels (4), not the 9 input channels.

Validated (scripts/inpaint_e2e.py, onnx-genai): a constructed 9-ch UNet (conv_in
4->9) exports and matches the torch UNet to 1.2e-5 while mask/masked_latent change
the output by 0.84. 55 integration tests pass.
Export vae_encoder.onnx (image->latent) for inpainting models so a driver can
compute the masked-image latent; add exported.inpaint to convert run.json so the
renderer branches on it.
…xponential)

SchedulerConfig.use_exponential_sigmas (emitted when True); from_diffusers reads
it; ComfyUI scheduler='exponential' enables it; convert computes exponential
timesteps (fractional -> float32 timestep). 56 integration tests pass.
EmptyLatentImage.batch_size is now recovered into ComfyUIWorkflow.batch_size.
The onnx-genai runtime already handles batch>1 (loop-carried state / timestep
injection / schedulers are all shape-generic — verified by
scripts/batch_check.py). Driver batching (batched prompts/seeds) is a follow-up.
57 integration tests pass.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bd351e74-2569-444d-a7da-6456f3b5df82
…ckage

export_checkpoint now writes a fast-format tokenizer.json (and tokenizer_2.json
for SDXL) into the package root, converting the diffusers (possibly slow) CLIP
tokenizer to fast. This makes the exported ONNX package self-contained so the
native run_comfyui runner can tokenize prompts without the HF cache (which only
ships vocab.json + merges.txt). Add checkpoint_export_test.py.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The onnx-genai integration now dispatches through write_onnx_genai_config
(LLM decoder metadata + diffusion pipeline) rather than the older
write_inference_metadata entry point.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bd351e74-2569-444d-a7da-6456f3b5df82
Add build_language_diffusion_pipeline_metadata for masked (discrete)
language-diffusion models (LLaDA/Dream): emits an iterative pipeline with a
masked_diffusion scheduler (mask_token_id + optional block_length/temperature),
a loop-carried logits->input_ids self-edge, and optional unsupervised CFG
(guidance_scale). Exported from the package; 5 new tests including schema
validation against onnx-genai's committed inference_metadata schema.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bd351e74-2569-444d-a7da-6456f3b5df82
… convert

Mobius builds models from scratch (onnx_ir/components), never via torch.onnx.
Delete the diffusers->torch.onnx.export shortcut (checkpoint_export.py + test)
and make the ComfyUI convert path torch-free: it now only translates the
workflow into inference_metadata.yaml + run.json, reconciling the ComfyUI
sampler with the checkpoint's diffusers scheduler config (betas). The ONNX
component graphs (denoiser/VAE/text encoder) are produced by Mobius's
from-scratch diffusers builder (build_diffusers_pipeline) + write_onnx_genai_config.

- convert.py: drop export_checkpoint/ExportedCheckpoint; take a SchedulerConfig.
- __main__ convert-comfyui: drop --lora/--controlnet/--opset (export-only), add --sdxl.
- convert_test: use SchedulerConfig. 62 onnx_genai tests pass.

LoRA/ControlNet/inpaint become from-scratch builder capabilities (follow the
mobius adding-a-new-model / diffusion-models skills), not export-time fusion.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bd351e74-2569-444d-a7da-6456f3b5df82
Add an optional linear_class factory to _BasicAttention / _CrossAttentionBlock
in the from-scratch diffusion UNet, defaulting to plain Linear (backward-
compatible). Passing a LoRALinear factory builds the low-rank adapter branch
(base + x@A^T@B^T*scale) with an optional shared gate_holder for per-adapter
runtime scalar gating (on/off/blend), mirroring the Phi4MM multi-adapter
pattern. This is the foundation for runtime LoRA in the diffusion pipeline:
adapters baked as lora_A/lora_B params, switched at run time via a gate input
(no re-export).

New models/unet_lora_test.py (3 graph-build tests, no weights): plain vs LoRA
branch, adapter param naming, and runtime gate wiring. Diffusers-builder tests
unaffected (21 pass).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bd351e74-2569-444d-a7da-6456f3b5df82
…inputs

Thread the LoRALinear factory from UNet2DConditionModel through the down/mid/up
blocks (all cross-attention projections), driven by a new UNet2DConfig
lora_adapters field ((name, rank, scale) tuples; empty = plain, backward-
compatible). The UNet forward populates a shared _lora_gates mapping, and the
DenoisingTask declares one scalar lora_gate.{name} input per adapter (only for
modules that declare adapters, so DiT is unaffected). At run time a loaded model
switches/blends LoRAs by feeding the gate scalars (1.0/0.0/strength) — no
rebuild; onnx-genai feeds them as denoiser.lora_gate.{name} per-request inputs.

Tests: unet_lora_test.py grows to 5 (full-UNet end-to-end gate-input + adapter-
param assertions, plus no-LoRA has no gate inputs). 141 builder/tasks/unet tests
pass; DiT denoising-task tests green.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bd351e74-2569-444d-a7da-6456f3b5df82
Add remap_diffusers_unet_lora + load_unet_lora_safetensors: map a diffusers/
PEFT UNet LoRA state dict (lora.down/up or lora_A/B, under transformer_blocks.N)
onto the from-scratch UNet's baked LoRALinear params (lora_A.{name}.weight /
lora_B.{name}.weight, flattened attn1/attn2), verified against the real built
UNet's param names. Exported from mobius.models.

Tests grow to 8: key remap (both source spellings), cross-check that remapped
keys land on real baked params, and safetensors load w/ shape check. 139
builder/tasks/unet tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bd351e74-2569-444d-a7da-6456f3b5df82
…me LoRA

Wire the LoRA weight-loading into the from-scratch build: unet_loras maps
adapter_name -> lora.safetensors; each is loaded, remapped onto the baked
LoRALinear params, its rank inferred from the file, and injected as
UNet2DConfig.lora_adapters before building the UNet + merged alongside the base
weights. Baked scale = 1.0 (the runtime lora_gate.{name} input supplies the
effective strength). Adds _prepare_unet_loras (unit-tested: rank inference +
merge). 22 builder tests pass.

Runtime LoRA is now code-complete: gated graph (unet.py) + weight load/remap +
build integration; onnx-genai feeds denoiser.lora_gate.{name} per request.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bd351e74-2569-444d-a7da-6456f3b5df82
justinchuby and others added 6 commits July 19, 2026 13:03
Add an end-to-end test that builds a tiny random-weight LLaDA package via
MaskedDiffusionTask and asserts the exported ONNX I/O signature matches the
onnx-genai denoiser contract: single input input_ids [B,S] int64, single
output logits [B,S,V] f32, and no past/present KV ports. It then verifies
build_language_diffusion_pipeline_metadata emits the matching pipeline
(denoiser model, logits -> input_ids self-edge, iterative strategy,
masked_diffusion scheduler_config with mask_token_id 126336).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bd351e74-2569-444d-a7da-6456f3b5df82
…n names, max_length guard

Three fixes found while building a real Stable-Diffusion package
(OFA-Sys/small-stable-diffusion-v0) from scratch for onnx-genai:

- _diffusers_builder: load component weights from PyTorch .bin (index or
  single-file) in addition to safetensors — many real diffusers repos ship
  only .bin. Loader dispatches on extension (safetensors.torch vs torch.load).
- models/vae: preprocess_weights now maps legacy diffusers VAE mid-block
  attention names (query/key/value/proj_attn) to the current
  to_q/to_k/to_v/to_out.0, squeezing legacy 1x1-conv projections to Linear.
  Older SD VAE checkpoints (incl. small-stable-diffusion-v0) use the old names.
- __main__: guard args.max_length with getattr (the arg is not defined on the
  build parser; the validation dereferenced a missing attribute and crashed
  every build --runtime onnx-genai invocation).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bd351e74-2569-444d-a7da-6456f3b5df82
Register the classic SD components so `build_diffusers_pipeline` builds a
full text->image package (text_encoder + unet + vae) instead of only the VAE:

- Register `UNet2DConditionModel` (denoising) and `CLIPTextModel`
  (feature-extraction) in `_DIFFUSERS_CLASS_MAP`.
- Add `CLIPTextConfig.from_diffusers`, mapping a transformers CLIP text-encoder
  config onto `ArchitectureConfig` (layer_norm_eps -> rms_norm_eps, derived
  head_dim, quick_gelu).
- Teach the from-scratch UNet to honor `down_block_types`/`up_block_types` so
  cross-attention lands only where diffusers places it (SD 1.x uses a plain
  last DownBlock2D and first UpBlock2D). Add the fields to `UNet2DConfig`.
- Derive diffusion-pipeline metadata component filenames from the built package
  keys in `write_onnx_genai_config` (denoiser=unet, text_encoder prompt_only,
  vae_decoder final_only, latent port `latent_sample`), defaulting CFG to 7.5
  when a text encoder is present.

Parity vs diffusers fp32 on OFA-Sys/small-stable-diffusion-v0 (built fp16):
unet max|Δ|=1.0e-4 (rel 2e-5), text_encoder rel=1.2e-3, vae_decoder 4e-5.
Adds an SD 1.x mixed-block-type UNet parity regression test.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bd351e74-2569-444d-a7da-6456f3b5df82
…registration

The classic Stable Diffusion integration registered UNet2DConditionModel and
CLIPTextModel in _DIFFUSERS_CLASS_MAP but left four _diffusers_builder tests
asserting the old (DiT-only) roster, so the suite was red:

- test_populates_expected_classes: add UNet2DConditionModel + CLIPTextModel.
- test_task_names_are_valid: allow 'feature-extraction' (a real TASK_REGISTRY
  task, used by the CLIP text encoder).
- test_skips_unregistered_class_names: use T5EncoderModel (genuinely
  unregistered) instead of CLIPTextModel (now registered) to keep exercising
  the skip/raise path.
- test_multiple_components_built: text_encoder (CLIPTextModel) is now a
  supported component and builds; assert it IS in the result.

All 22 _diffusers_builder tests pass; broader diffusion suite green; ruff clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bd351e74-2569-444d-a7da-6456f3b5df82
The CLIPTextConfig and UNet2DConfig from_diffusers adapters added for the
from-scratch classic Stable Diffusion build had no dedicated test. Adds unit
coverage for the non-trivial CLIP mappings (transformers layer_norm_eps ->
rms_norm_eps, head_dim = hidden_size/num_attention_heads, num_key_value_heads
== num_attention_heads, quick_gelu default) across SD1.5/SD2.x shapes and
defaults, plus core UNet field mapping.

4 tests pass; ruff clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bd351e74-2569-444d-a7da-6456f3b5df82
Populate Gemma3 GGUF sliding attention config and RoPE scaling metadata, map Gemma3-specific GGUF norm tensors, and keep Gemma3 projections quantized when building with --keep-quantized.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@justinchuby
justinchuby requested review from a team and a lite review from Copilot August 6, 2026 06:38
Comment thread src/mobius/__main__.py

def _cmd_convert_comfyui(args: argparse.Namespace) -> None:
"""Execute the 'convert-comfyui' subcommand."""
import json
_SAMPLER_NODES = ("KSampler", "KSamplerAdvanced")
_VAE_DECODE_NODES = ("VAEDecode", "VAEDecodeTiled")
_TEXT_ENCODE_NODES = ("CLIPTextEncode",)
_CHECKPOINT_NODES = ("CheckpointLoaderSimple", "CheckpointLoader", "UNETLoader")

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 expands Mobius’ model/task/runtime integration surface to better support GGUF-imported Gemma3 (scaled/local RoPE, hybrid attention schedules, quantized builds), Stable Diffusion–style diffusers pipelines (including runtime-gated UNet LoRA), and onnx-genai inference metadata generation/conversion utilities.

Changes:

  • Fix and extend GGUF → ArchitectureConfig / tensor-name mapping for Gemma3 (sliding window, local RoPE base, rope_scaling defaults, norm/QK-norm tensors, quantized component wiring).
  • Add diffusion-oriented task/model capabilities: runtime-gated LoRA inputs for denoisers, diffusers pipeline build enhancements, and from-scratch UNet/VAE legacy weight remaps + parity tests.
  • Introduce onnx-genai inference_metadata.yaml generation for diffusion pipelines, ComfyUI workflow conversion, and add a new MLX EP capability profile.

Reviewed changes

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

Show a summary per file
File Description
src/mobius/tasks/_masked_diffusion.py New task for masked-diffusion (bidirectional, no-KV) language models.
src/mobius/tasks/_denoising.py Adds optional runtime LoRA gate inputs for denoising tasks.
src/mobius/tasks/init.py Registers the new masked-diffusion task.
src/mobius/models/vae.py Adds legacy diffusers VAE attention weight-name remapping + 1x1-conv squeeze.
src/mobius/models/unet.py Adds runtime LoRA plumbing, block-type awareness, and projection-mode handling for UNet.
src/mobius/models/unet_parity_test.py New numerical parity tests vs diffusers for UNet and attention blocks.
src/mobius/models/unet_lora_test.py New graph-build tests for LoRA branches + runtime gate wiring + key remapping helpers.
src/mobius/models/llada.py New bidirectional masked-diffusion language model implementation (LLaDA).
src/mobius/models/llada_test.py New parity + bidirectionality + masked-diffusion metadata contract tests.
src/mobius/models/gemma3_text.py Quantized component selection + embedding scaling + LM head re-tie fixes.
src/mobius/models/gemma3_text_test.py New tests for quantized component usage + retied LM head.
src/mobius/models/init.py Exports LLaDA + UNet LoRA helper functions.
src/mobius/integrations/ort_genai/genai_config_test.py Adds MLX EP test coverage for shared KV behavior.
src/mobius/integrations/onnx_genai/inference_metadata.py Major refactor: pipeline-style metadata for diffusion + scheduler config loading/parsing.
src/mobius/integrations/onnx_genai/inference_metadata_test.py Rewrites tests around new diffusion + language-diffusion metadata emitters.
src/mobius/integrations/onnx_genai/decoder_metadata.py New module for decoder-only LLM inference metadata generation/writing.
src/mobius/integrations/onnx_genai/decoder_metadata_test.py New tests for decoder metadata and schema validation.
src/mobius/integrations/onnx_genai/convert.py New ComfyUI → onnx-genai conversion orchestration + timestep best-effort.
src/mobius/integrations/onnx_genai/convert_test.py New tests for conversion reconciliation behavior.
src/mobius/integrations/onnx_genai/comfyui.py New ComfyUI workflow parser/translator producing pipeline metadata + run params.
src/mobius/integrations/onnx_genai/comfyui_test.py New tests for ComfyUI parsing/translation and schema validation.
src/mobius/integrations/onnx_genai/auto_export.py New dispatcher to write onnx-genai metadata based on package type.
src/mobius/integrations/onnx_genai/auto_export_test.py Tests for the auto-export dispatcher.
src/mobius/integrations/onnx_genai/init.py Public API surface update for new onnx-genai helpers.
src/mobius/integrations/gguf/_tensor_mapping.py Adds Gemma3-specific tensor-name mapping extras (norms, q/k norms).
src/mobius/integrations/gguf/_tensor_mapping_test.py Adds Gemma3 mapping regression tests.
src/mobius/integrations/gguf/_config_mapping.py Extends GGUF config mapping for sliding window, local RoPE base, rope_scaling defaults, Gemma3 activation/QK norm.
src/mobius/integrations/gguf/_config_mapping_test.py New synthetic GGUF metadata tests for rope_scaling/local RoPE/layer schedule.
src/mobius/_registry.py Registers model_type="llada" with the masked-diffusion task.
src/mobius/_model_package.py Updates save() doc reference to new onnx-genai export entrypoint.
src/mobius/_exporter_test.py Adds MLX EP capability test coverage.
src/mobius/_execution_providers.py Adds MLX EP capability registration (GQA dtypes, shared KV, no QKV packing).
src/mobius/_diffusers_configs.py Adds CLIP text config adapter + expands UNet config (block types, projection mode, LoRA adapters).
src/mobius/_diffusers_configs_test.py New tests for CLIPTextConfig and UNet2DConfig adapters.
src/mobius/_diffusers_builder.py Adds classic SD component support, .bin weight loading fallback, and UNet LoRA baking/merge.
src/mobius/_diffusers_builder_test.py Updates coverage for new SD components and LoRA preparation helper.
src/mobius/_configs/_base.py Adds more transformer-config field aliases (activation_type, d_model, n_heads, etc.).
src/mobius/main.py Updates onnx-genai export path + adds convert-comfyui CLI subcommand.
docs/execution_providers.md Documents the new MLX execution provider.
Suppressed comments (2)

src/mobius/models/unet.py:391

  • Same naming ambiguity as the down block: attention_head_dim is used as num_heads. A brief comment would make the intended semantics explicit.
    src/mobius/models/unet.py:480
  • attention_head_dim is being treated as the number of heads in this UNet mid block. Consider clarifying with an inline comment to prevent confusion with dim_head terminology used elsewhere (e.g. Transformer2DModel).

Comment on lines +112 to +119
cfg = config if config is not None else getattr(pkg, "config", None)
if cfg is None:
raise ValueError(
"onnx-genai decoder metadata requires a model config (pass config=... "
"or a package carrying `.config`)"
)
path = write_decoder_metadata(output_dir, config=cfg, kv_native_dtype=kv_native_dtype)
return {"inference_metadata": path}
Comment on lines +77 to +81
Ancestral samplers (which inject fresh noise every step) have no
deterministic onnx-genai equivalent and are rejected, as are scheduler
classes onnx-genai does not implement, so a Mobius-built package never
silently runs the wrong denoise dynamics.
"""
Comment on lines +99 to +102
raise ValueError(
f"unsupported diffusers scheduler {raw_name!r} for onnx-genai; "
f"supported kinds: ddim (DDIMScheduler), euler (EulerDiscreteScheduler)"
)
Comment thread src/mobius/models/unet.py
)
if cross_attention_dim:
num_heads = max(1, out_channels // attention_head_dim)
num_heads = attention_head_dim
Repack independent GGUF tensors with a bounded worker pool, reduce Q4 nibble-reorder temporaries, and show packed/written byte progress during long conversions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@justinchuby

Copy link
Copy Markdown
Member Author

Performance update added in commit 8d79344 (same PR, because the perf defect blocks validating the same GGUF conversion path fixed by the config-mapping changes).

What changed:

  • _load_quantized_state_dict now repacks independent quantized tensors through a bounded ThreadPoolExecutor (default min(16, os.cpu_count()), override with MOBIUS_GGUF_REPACK_WORKERS). Non-repacked/dequantized tensors stay serial to avoid expanding multiple multi-GB embeddings at once.
  • Q4_0/Q4_1 nibble reorder now writes directly into the output array instead of allocating low/high nibble arrays plus a concatenate.
  • Progress now reports repacked tensor count, in-flight work, packed GiB during repack, and external-data written GiB during ONNX save.

Profiling/measurement before optimizing:

  • Synthetic Q4_0 raw repack profile (8192x8192, 37.7 MB raw, 5 iterations): 0.614s; cProfile showed _reorder_nibbles_gguf_to_ort consumed 0.552s of 0.608s cumulative time (~91%).
  • Thread scaling check over independent synthetic Q4_0 tensors: 1 worker 0.812s, 16 workers 0.166s, confirming NumPy releases enough GIL here for threads to help.

After changes:

  • Same direct Q4_0 repack microbenchmark: 0.551s (small vectorization win; _reorder_nibbles_gguf_to_ort still dominates per tensor).
  • Synthetic _load_quantized_state_dict benchmark (112 tensors, 0.510 GiB raw):
    • 1 worker: 1.747s (0.313 raw GB/s)
    • 2 workers: 0.976s
    • 4 workers: 0.563s
    • 8 workers: 0.445s
    • 16 workers: 0.369s (1.485 raw GB/s)

Gemma-3 27B rebuild after optimization:

  • Command completed successfully in 81.122s wall-clock.
  • Output: C:\Users\justinchu\dev\models\gemma-3-27b-onnx\model.onnx
  • Total size: 16.079 GiB
  • onnx.checker.check_model(...): OK
  • MatMulNBits: 434
  • GatherBlockQuantized: 0 (token_embd.weight is Q6_K in this GGUF, so it remains f16 rather than using the graph's Q4_0 quantization config)

Validation after perf changes:

  • python -m pytest src\mobius\integrations\gguf\ src\mobius\_configs\ src\mobius\models\gemma3_text_test.py src\mobius\_model_package_test.py -> 211 passed
  • Ruff check/format-check on touched files -> passed.

Wire --max-shard-size through build-gguf and save sharded safetensors external data with bounded parallel shard writers while preserving ONNX external-data loadability.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@justinchuby

Copy link
Copy Markdown
Member Author

Follow-up on sharding/write-side question, added in commit 389bb5d.

Findings from reading the code:

  • ONNX external-data path (ir.save(..., external_data="model.onnx.data")) does not support sharding today; it writes a single raw model.onnx.data file. So the current --max-shard-size restriction to safetensors is an API limitation/incidental implementation constraint, not an ONNX-format impossibility.
  • Existing safetensors sharding in onnx_ir.save_safetensors split tensors into shards but wrote shards sequentially. It also failed in this environment with safetensors==0.8.0 because the low-level serialize_file API now expects TensorSpec objects.
  • Safetensors external data is standard ONNX external data in the sense that the ONNX initializers point at byte offsets inside the .safetensors files. I verified a sharded build-gguf --external-data safetensors --max-shard-size 10KB output with:
    • onnx.checker.check_model(...): OK
    • onnx.load(..., load_external_data=True): OK
    • onnxruntime.InferenceSession(..., providers=["CPUExecutionProvider"]): OK
      This is the closest practical proxy for onnx-genai loadability because onnx-genai ultimately loads the ONNX through ORT.

What changed:

  • Wired --max-shard-size through mobius build-gguf; it was documented in the request but was not actually accepted by that subcommand.
  • Replaced the safetensors save path in ModelPackage.save with a compatibility saver using the public safetensors.numpy.save_file API.
  • Sharded safetensors files are written with bounded parallel shard writers. Default is up to 4 writer threads; override with MOBIUS_SAFETENSORS_WRITE_WORKERS.
  • Added a regression test that saves sharded safetensors, checks the shard/index files, and loads the model back through ONNX external-data loading.

Important route decision for the 27B conversion:

  • I am not switching the final Gemma3 27B artifact to safetensors by default because mobius already warns that safetensors offsets are not guaranteed 256-byte aligned and can cause CUDA CUBLAS misaligned-address failures. This model is specifically for CUDA/on-demand/streaming experiments, so the safer final artifact remains ONNX raw external data.
  • The optimized ONNX external-data build is already practical: the 27B conversion completed in 81.122s, output 16.079 GiB, MatMulNBits=434, onnx.checker OK.

Updated validation:

  • python -m pytest src\mobius\integrations\gguf\ src\mobius\_configs\ src\mobius\models\gemma3_text_test.py src\mobius\_model_package_test.py -> 212 passed
  • Ruff check and format-check on touched files -> passed.

@justinchuby

Copy link
Copy Markdown
Member Author

Update on ONNX external-data sharding and the safetensors detour:

I verified the installed onnx_ir 0.2.1 source before building on sharding. The released ONNX external-data path does not expose a shard-size API:

  • onnx_ir.save(model, path, format=None, external_data=None, size_threshold_bytes=256, callback=None) accepts one external_data path and no shard-size parameter.
  • onnx_ir.external_data.convert_tensors_to_external(tensors, base_dir, relative_path, callback=None) also takes a single relative_path.
  • onnx_ir.external_data.unload_from_model(...) documents and implements conversion to "a single data file".
  • Searching the installed package for shard finds only the safetensors implementation under onnx_ir._safetensors, not the ONNX external-data implementation.

So the honest current status is: ONNX external-data sharding may exist in unreleased onnx-ir work, but it is not present in the released 0.2.1 API available to this mobius environment. Per the updated direction, I did not pursue safetensors as the solution and pushed 6e51b2d, which reverts the safetensors sharding commit.

What remains in this PR:

  • GGUF config/tensor/Gemma3 quantized export fixes.
  • Parallel/vectorized quantized GGUF repacking.
  • Progress reporting for both repack and ir.save writes using the onnx-ir callback.

Validation after the revert:

  • python -m pytest src\mobius\integrations\gguf\ src\mobius\_configs\ src\mobius\models\gemma3_text_test.py src\mobius\_model_package_test.py → 211 passed.
  • python -m ruff check ... and python -m ruff format --check ... on touched files → passed.
  • python -m lintrunner ... could not run in this environment because lintrunner is not installed.

Wire max_shard_size_bytes through the ONNX external-data save path and expose --max-shard-size for GGUF builds. Add a runtime error for onnx-ir releases that predate onnx/ir-py PR #427, plus regression coverage for passing the option and writing sharded ONNX data.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@justinchuby

Copy link
Copy Markdown
Member Author

Update after installing onnx-ir from git main:

Validation:

  • Targeted tests: python -m pytest src\mobius\integrations\gguf\ src\mobius\_configs\ src\mobius\models\gemma3_text_test.py src\mobius\_model_package_test.py -> 214 passed.
  • Ruff check/format-check on touched files -> passed.
  • Rebuilt Gemma3 27B after cleaning the output dir first:
    python -m mobius build-gguf C:\Users\justinchu\dev\models-gguf\gemma-3-27b-it-Q4_0.gguf --output C:\Users\justinchu\dev\models\gemma-3-27b-onnx --keep-quantized --dtype f16 --ep cuda --max-shard-size 5GB
    Wall-clock: 324.35s in this run.
  • Final sharded ONNX output:
    • model.onnx
    • model.onnx-00001-of-00004.data = 4.640 GiB
    • model.onnx-00002-of-00004.data = 4.607 GiB
    • model.onnx-00003-of-00004.data = 4.607 GiB
    • model.onnx-00004-of-00004.data = 2.225 GiB
    • Total = 16.079 GiB
    • MatMulNBits=434
    • onnx.checker.check_model(...): OK
  • Verified onnx-genai can load the sharded model by adding a temporary local test in the sibling repo that calls onnx_genai_ort::Session::new(..., C:\Users\justinchu\dev\models\gemma-3-27b-onnx\model.onnx, SessionOptions::default()); cargo test -p onnx-genai-ort --test sharded_external_data_tmp passed. The temporary test file was removed after the check.

Operational note: onnx-ir's sharded writer raises FileExistsError on shard-name collisions with files the in-memory model does not own, so repeated large conversions should clean the output directory first.

@justinchuby

Copy link
Copy Markdown
Member Author

Final artifact/loadability update:

After the latest correction, I stopped treating unreleased ONNX external-data sharding as a prerequisite for the artifact. The model now left on disk is plain ONNX external data with a single model.onnx.data file, built without --max-shard-size:

C:\Users\justinchu\dev\models\gemma-3-27b-onnx\model.onnx

Verification:

  • Files now present:
    • model.onnx = 1,350,474 bytes
    • model.onnx.data = 17,263,722,496 bytes
    • recursive total = 17,265,072,970 bytes = 16.079352 GiB
  • MatMulNBits=434
  • GatherBlockQuantized=0 because this GGUF's embedding is Q6_K and remains f16
  • external data location in the ONNX proto: model.onnx.data
  • onnx.checker.check_model(...): OK
  • onnx-genai graph load: passed via a temporary local test in the sibling repo calling onnx_genai_ort::Session::new(..., C:\Users\justinchu\dev\models\gemma-3-27b-onnx\model.onnx, SessionOptions::default()); the temporary test file was removed afterward.

Full onnx-genai CLI/model-directory loading is not complete yet because the output directory does not contain tokenizer.json (and no genai_config.json/metadata bundle was emitted by build-gguf). cargo run -q -p onnx-genai-cli -- show C:\Users\justinchu\dev\models\gemma-3-27b-onnx fails with tokenizer.json not found. So the ONNX graph itself is loadable by onnx-genai's ORT session layer, but the directory is not yet a complete generation-ready onnx-genai package.

Sharding note for reviewers: onnx/ir-py PR #427 is merged but not in PyPI's onnx-ir 0.2.1. It adds ir.save(..., max_shard_size_bytes=...) for plain ONNX external data; no index file is needed because the .onnx proto stores location/offset/length. The PR motivation is important here: "Safetensors sharding is incompatible with ORT CUDA kernels due to uncontrolled tensor alignment." Until that API is released and mobius can set a normal lower bound, sharding should be treated as requiring onnx-ir newer than 0.2.1 rather than assumed.

Treat GGUF Q4_0 and Q8_0 as requiring explicit zero-point initializers so MatMulNBits and GatherBlockQuantized do not rely on execution-provider-specific defaults. Add a GatherBlockQuantized dequantization regression that checks the zero-point value, not only node wiring.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@justinchuby

Copy link
Copy Markdown
Member Author

Added the zero-point fix to this PR in commit 592db0a rather than opening a second PR.

What changed:

  • GGUF Q4_0 and Q8_0 are now treated as requiring explicit zero-point initializers even though they are symmetric formats.
    • Q4_0 emits packed zp=8 (0x88 for pairs of 4-bit blocks; 0x08 when only one block is present).
    • Q8_0 emits zp=128.
  • Because QuantizedLinear and QuantizedEmbedding key zero-point parameters off not qc.sym, this makes both MatMulNBits and GatherBlockQuantized receive explicit zero_points for GGUF Q4_0/Q8_0 instead of relying on provider defaults.
  • The code comment names the CUDA corruption failure: GatherBlockQuantized CPU/CUDA defaults diverge when zero_points is omitted.

Regression coverage:

  • Added a single-node GatherBlockQuantized runtime test with qweight nibble value 10 and explicit zp=8. It asserts the dequantized output equals hand-computed (q - 8) * scale, then reruns with zp=0 and asserts that output no longer matches. This checks the value, not merely that the input is wired.
  • Existing synthetic GGUF builder tests now assert Q4_0 MatMulNBits nodes have four inputs and Q4_0 quantized embeddings emit model.embed_tokens.zero_points == 0x88 with GatherBlockQuantized four-input wiring.

Quantization types checked:

  • Direct GGUF repackers already produce zero-points for all supported direct formats: Q4_0 (8), Q4_1 (per-block affine), Q4_K (requantized per-block affine), Q8_0 (128), Q1_0 (1; Tencent Q1_0 uses its custom zp path).
  • Q5_0/Q5_1/Q5_K/Q6_K are not direct MatMulNBits repack targets in this path; when they appear in mixed presets they are dequantized and requantized to the common target, which produces explicit zero-points when the target is asymmetric.

Validation:

  • python -m pytest src\mobius\integrations\gguf\ src\mobius\_configs\ src\mobius\models\gemma3_text_test.py src\mobius\_model_package_test.py -> 216 passed.
  • python -m ruff check src\mobius\integrations\gguf\_builder.py src\mobius\integrations\gguf\_builder_test.py -> passed.
  • python -m ruff format --check src\mobius\integrations\gguf\_builder.py src\mobius\integrations\gguf\_builder_test.py -> passed.
  • Freshly converted C:\Users\justinchu\dev\models-gguf\qwen2.5-0.5b-instruct-q4_0.gguf with --keep-quantized --dtype f16 --ep cuda; the resulting graph has GatherBlockQuantized with 4 inputs, all MatMulNBits nodes with 4 inputs, and 170 zero-point initializers.
  • Ran greedy generation through ONNX Runtime CUDA after adding the installed NVIDIA CUDA/cuDNN package DLL dirs to the process DLL search path. Output: The capital of France is Paris. It is the largest city in, confirming the freshly converted 0.5B Q4_0 produces correct CUDA output without post-hoc patching.

Upstream ORT issue:

@justinchuby

Copy link
Copy Markdown
Member Author

Closing this PR rather than merging/rebasing it: it was accidentally based on another in-flight feature branch and includes unrelated commits. I rebuilt the urgent zero-point correctness fix from current origin/main in #459. The remaining Gemma3 config and repack performance work should be reapplied semantically as separate clean-main PRs.

@justinchuby justinchuby closed this Aug 6, 2026
justinchuby added a commit that referenced this pull request Aug 6, 2026
## Summary

This is the clean-main rebuild of the urgent correctness fix from #458.
It contains only the GGUF zero-point fix, based on current
`origin/main`.

GGUF Q4_0 and Q8_0 are symmetric formats, but their dequantization
formulas still require non-zero zero points:

- Q4_0: `(q - 8) * scale`
- Q8_0: `(q - 128) * scale`

Mobius previously marked those GGUF imports as symmetric, so
`QuantizedLinear` and `QuantizedEmbedding` omitted the optional
`zero_points` input for `MatMulNBits` and `GatherBlockQuantized`. That
is not portable: `GatherBlockQuantized` defaults diverge between ORT CPU
and ORT CUDA when `zero_points` is omitted, corrupting CUDA embeddings
before the first decoder layer runs.

This PR makes GGUF Q4_0/Q8_0 emit explicit zero-point initializers, so
both `GatherBlockQuantized` and `MatMulNBits` receive the intended
values instead of relying on EP defaults.

## Regression test

Added a single-node `GatherBlockQuantized` runtime regression:

- qweight nibbles are all `10`
- scales are known constants
- explicit zero point is `8`
- output is asserted against hand-computed `(q - 8) * scale`
- rerunning with zero point `0` is asserted not to match

I also temporarily flipped the test's zero point to `0`; it failed with
a 100% output mismatch, so the test checks the value and not just the
presence of the input.

Synthetic GGUF tests now also assert:

- Q4_0 `MatMulNBits` nodes have the explicit fourth input
- Q4_0 `GatherBlockQuantized` has the explicit fourth input
- Q4_0 embedding zero points are packed as `0x88`

## Quantization types checked

Direct GGUF repackers already produce explicit zero-points for supported
direct formats:

- Q4_0: zp=8
- Q4_1: per-block affine zp
- Q4_K: requantized per-block affine zp
- Q8_0: zp=128
- Q1_0: zp=1; Tencent Q1_0 uses its custom zp path

Q5_0/Q5_1/Q5_K/Q6_K are not direct `MatMulNBits` repack targets here;
when they appear in mixed presets they go through
dequantize+requantize/native-block fallback paths, which produce
explicit zero-points where needed.

## Validation

- `python -m pytest src\mobius\integrations\gguf\_builder_test.py -q` ->
`33 passed`
- `python -m pytest src\mobius\integrations\gguf\ src\mobius\_configs\
src\mobius\_model_package_test.py` -> `304 passed`
- `python -m ruff check src\mobius\integrations\gguf\_builder.py
src\mobius\integrations\gguf\_builder_test.py` -> passed
- `python -m ruff format --check
src\mobius\integrations\gguf\_builder.py
src\mobius\integrations\gguf\_builder_test.py` -> passed

Fresh conversion validated with
`C:\Users\justinchu\dev\models-gguf\qwen2.5-0.5b-instruct-q4_0.gguf`:

```powershell
python -m mobius build-gguf C:\Users\justinchu\dev\models-gguf\qwen2.5-0.5b-instruct-q4_0.gguf --output C:\Users\justinchu\dev\models\qwen2.5-0.5b-q4_0-mobius --keep-quantized --dtype f16 --ep cuda
```

Converted graph verification:

- `GatherBlockQuantized` input count: 4
- all `MatMulNBits` input counts: 4
- zero-point initializers: 170

Generation checks on the freshly converted model, no post-hoc graph
patching:

- ORT CPU via onnx-genai CLI: `The capital of France is Paris. It is the
largest city in`
- ORT CUDA via onnx-genai CLI (`ONNX_GENAI_ORT_LIB_DIR` pointed at the
installed onnxruntime-gpu package, CUDA/cuDNN DLL dirs on PATH): `The
capital of France is Paris. It is the largest city in`
- native CUDA via onnx-genai CLI (`--features native-cuda`, CUDA/cuDNN
DLL dirs on PATH): `The capital of France is Paris. It is the largest
city in`

## Upstream ORT issue

Reported the provider default divergence here:
microsoft/onnxruntime#31692

## Relationship to #458

#458 was accidentally based on another in-flight feature branch and
includes unrelated commits. This PR is the clean-main replacement for
the urgent zero-point correctness fix only.

---------

Signed-off-by: Justin Chu <justinchuby@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: justinchuby <11205048+justinchuby@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