Skip to content

Restore the MXFP4 dequantize transpose on the transformers 5.x load path - #1114

Merged
danielhanchen merged 5 commits into
mainfrom
fix-mxfp4-load-path-transpose-5x
Aug 27, 2026
Merged

danielhanchen merged 5 commits into
mainfrom
fix-mxfp4-load-path-transpose-5x

Conversation

@danielhanchen

@danielhanchen danielhanchen commented Aug 27, 2026 •

Copy link
Copy Markdown
Member

GPT-OSS MXFP4 has been loading with dims 1 and 2 swapped on every transformers 5.x

The red CI is the alarm, not the fire. unsloth_zoo/temporary_patches/mxfp4.py replaces convert_moe_packed_tensors with a variant that deliberately returns the un-transposed [E, D, G*B*2] layout, and leaves it to whichever loader hook is live to restore GPT-OSS's [E, G*B*2, D]. Which hook that is depends on the transformers version:

  • 4.x: module level mxfp4.dequantize, called from quantizer_mxfp4. Zoo patches it, and it applies the transpose. Correct.
  • 5.0 and newer: the live path is Mxfp4Dequantize (a ConversionOps) into dequantize_convertops. Nothing calls dequantize any more.

Zoo only ever patched dequantize. So from transformers 5.0.0 onward the compensating transpose was silently dropped and the model loaded with dims 1 and 2 swapped. dequantize still existed on 5.0 through 5.15, so the patch appeared to apply and nothing complained.

Measured with the real patch_convert_moe_packed_tensors():

transformers loader output, before this PR stock transformers gives
5.16.1 (2, 4, 96) (2, 96, 4)
5.4.0 (2, 4, 96) (2, 96, 4)
4.57.6 correct (legacy hook live and compensating) matches

What actually changed upstream

Both CI failures come from a single upstream PR, huggingface/transformers#47579 (the DTensor tensor parallel rewrite), first released in v5.16.0:

tag mxfp4.dequantize shard_and_distribute_module
v5.15.1 present full 8 argument signature
v5.16.0 deleted (*args, **kwargs) tombstone that raises RuntimeError

dequantize was not renamed or moved, it was deleted as dead code. The tlatest5-trl1latest drift lane installs unpinned transformers>=5,<6, so it picked 5.16.0 up the day it shipped, with no zoo commit to blame.

Worth stating for the next person who reads the drift output: shard_and_distribute_module reporting (*args, **kwargs) is not a decorator wrapper hiding a real signature. It has no __wrapped__, __closure__ is None, co_argcount is 0, and co_filename points at tensor_parallel.py:54, the tombstone's own code object. inspect.signature(..., follow_wrapped=True) returns the identical thing. The detector was reading the truth; the source was wrong.

Attribution: no zoo commit is to blame

Worth stating outright so nobody has to re-derive it. The regression was introduced by an upstream release, not by any commit in this repo. The tlatest5-trl1latest drift lane installs unpinned transformers>=5,<6, so it adopts each new transformers the moment it is published, with no zoo change involved.

The run history dates it to the second:

event time (UTC)
Core drift job on 183a1ee49, green started 12:28:11, completed 12:32:08
transformers 5.16.0 published to PyPI 12:32:10
transformers 5.16.1 published to PyPI 14:48:55
Core drift job on af37769fb, red started 15:55:49, completed 15:59:52

The last green drift job finished two seconds before the breaking release existed on PyPI. The next one started three and a half hours later, after the queue, and installed 5.16.1.

af37769fb (#1108) is simply the commit that happened to be at main when the next run started. It touches compiler.py, empty_model.py, peft_utils.py, saving_utils.py, tokenizer_utils.py, vllm_utils.py, lint config and security tests, and no mxfp4, tensor_parallel or gpt_oss code at all. Blaming it would be wrong. The 5.4 hour queue delay between run creation and job start is what made an unpinned lane flip mid-day.

Was upstream right to do this?

Taking the other reading seriously rather than assuming the newer change is correct:

On dequantize, upstream is right and the removal is clean. It was genuinely dead code from 5.0.0 onward: get_weight_conversions() has returned Mxfp4Dequantize since v5.0.0, and nothing has called the module level dequantize since. Upstream also removed the matching re-export from transformers/integrations/__init__.py, so there is no dangling public name left behind. from transformers.integrations import dequantize worked on 5.4.0 and now raises ImportError cleanly rather than resolving to something broken.

One inconsistency worth noting, not worth an issue. dequantize was publicly re-exported, and it was hard deleted in a minor release with no deprecation cycle, while its sibling in the very same PR, shard_and_distribute_module, got a proper FutureWarning tombstone. Two public symbols removed by one PR, two different removal policies. The tombstone is the better treatment and is why that half of the breakage produced a legible error instead of an AttributeError. This does not change the fix on our side and I am not filing anything upstream, but if someone else hits the dequantize removal cold, that asymmetry is the reason it is harder to diagnose than it should be.

The fix

Patch dequantize_convertops on 5.x. It is a clean choke point: Mxfp4Dequantize.convert is its only caller, verified on both 5.4.0 and 5.16.1. Critically this leaves zoo's un-transposed convention intact, so saving_utils._mxfp4_base_returns_transposed and the export convention tests need no changes at all.

Rejected alternatives:

  • Making zoo's convert_moe_packed_tensors self-transpose. Double-transposes on stock 4.55.x, and forces coherent edits to saving_utils, the CPU export branch, and both convention tests.
  • Skipping the convert_moe_packed_tensors patch on 5.16.0+. Only fixes 5.16+, leaves 5.0 through 5.15 corrupt, and desyncs the _cpu-presence signal saving_utils keys off.

The legacy dequantize patch is gated to < 5.0.0 and both shard_and_distribute_module call sites to < 5.16.0.

Two more defects CI never reported

  1. temporary_patches/gpt_oss.py:476 also calls shard_and_distribute_module with 8 positionals. Its patch target load_and_swizzle_mxfp4 was dropped in the same 5.16.0 release, so the patch is inert there, but the import bailed the whole patch_gpt_oss early and took replace_with_mxfp4_linear down with it. Now resolved leniently.
  2. Zoo passed set_param=False, which only exists on 5.x. Now that the branch carrying it is 4.x only, that kwarg would hit a 4.57.6 signature with no **kwargs to absorb it and TypeError on every 4.x tensor parallel load. Dropped, matching upstream 4.57.6's own 8 positional call.

Test evidence

tx 4.57.6 tx 5.16.1
-k mxfp4, before 8 passed, 1 skipped 2 failed, 7 passed
-k mxfp4, after 8 passed, 1 skipped 7 passed, 2 skipped

Remaining failures on both versions are a pre-existing local bitsandbytes artifact (libbitsandbytes_cuda128.so missing in my workspace), identical on pristine main and unrelated. CI installs a working bitsandbytes.

The drift tests do not catch the corruption. Reverting only the source fix from a byte snapshot, with the test changes left in place:

layout proof 5.16.1: (2, 4, 96)   FAIL - loader gets corrupted layout
drift tests  5.16.1: 7 passed, 2 skipped   <- STILL GREEN

That is the single most important result here. Version gating the two red tests would have turned CI green over a broken loader.

New regression test

tests/test_mxfp4_load_path_layout.py asserts the loaded layout against what pristine transformers produces for the same input. This gap is why the bug survived since 5.0.0: test_mxfp4_transpose_convention and test_mxfp4_convert_no_double_transpose both exercise the save path, which stayed self-consistent throughout.

Subprocess isolated because applying the patch mutates transformers.integrations.mxfp4 process-wide, CPU only, UNSLOTH_ALLOW_CPU=1 since conftest's GPU-free harness does not reach a subprocess. It captures its golden value before patching rather than hardcoding a shape, so it needs no version gating of its own.

Mutation tested in both directions. With the source reverted it fails on 5.16.1 and on 5.4.0, catching the pre-5.16 corruption that no existing test saw:

FAIL transformers 5.4.0: loader got (2, 4, 96), stock transformers gives (2, 96, 4).

It skips cleanly on 4.57.6, which has no dequantize_convertops.

Deliberate scope boundary

Tensor parallel MXFP4 loading on transformers 5.16.0+ now raises a clear RuntimeError pointing at from_pretrained(..., tp_plan=...) rather than silently corrupting weights. That matches what upstream itself now does. Genuinely restoring it means porting to apply_tensor_parallelism, which is a feature port and does not belong in a CI fix. Happy to be overruled if you would rather that land here.

Also fixed the drift message wording: a (*args, **kwargs) signature now reports that it accepts any call and forwards nothing, rather than "accepts only 0 positionals", which sent the reader looking for a signature change that never happened.

Robustifying the detector, not just the loader

The detector was right here and still let a silent corruption run from 5.0.0 to 5.16.0, because it only ever asked "does this symbol exist with this signature" and never "is the symbol we patch the one actually on the live path". mxfp4.dequantize existed with an unchanged signature the entire time. Every pin was green. The patch reached nothing.

This PR adds _referenced_global_names and _assert_on_live_path, which statically walk a caller's code objects and fail when the symbol zoo patches has no live caller. Nested code is included via co_consts, so a call made inside a comprehension, a nested def or a lambda is still seen, which a bare co_names check would miss. It is wired into the ConversionOps detector, and a 4.x equivalent asserts quantizer_mxfp4 still calls the legacy hook.

Verified non-vacuous rather than assumed:

dequantize_convertops referenced by Mxfp4Dequantize.convert : True
legacy 'dequantize'   referenced by Mxfp4Dequantize.convert : False   <- the bug condition
bogus name detected (must be False)                         : False
positive control (symbol with no caller)                    : correctly FAILS
nested comprehension name seen (must be True)               : True

The second line is the point: this mechanism can see the exact condition that made the old patch a no-op.

Known limitation, stated rather than implied. This is a static reference check, so it catches a symbol losing all its callers, which is the failure mode that actually bit us. It will not catch a caller that is present but unreachable at runtime (guarded by a config branch that is never taken, say), and it covers the mxfp4 detectors here rather than the whole suite. The 4.x check also skips rather than fails when transformers.quantizers cannot import, since it pulls optional heavy backends like torchao and a broken one of those says nothing about drift.

Unrelated

#1112 fixes the other main red (the DEVICE_TYPE collection error in the security lane). It is independent of this one.

Zoo's convert_moe_packed_tensors replacement returns the un-transposed
[E, D, G*B*2] layout on purpose and leaves transpose(1, 2) to whichever
loader hook is live. That hook is mxfp4.dequantize only on 4.x. From
transformers 5.0 the live path is Mxfp4Dequantize (a ConversionOps) into
dequantize_convertops, so the transpose was never applied and GPT-OSS
loaded with dims 1 and 2 swapped. 5.16.0 then deleted dequantize and
reduced shard_and_distribute_module to a raising tombstone, which is what
turned the drift detector red.

Patch dequantize_convertops on 5.x, gate the legacy dequantize patch to
< 5.0.0, and gate both shard_and_distribute_module call sites to < 5.16.0.

Also drop set_param=False: it only exists on 5.x, and the branch that
passed it is now 4.x only, where the signature has no **kwargs to absorb
it and every tensor parallel load would TypeError.

Add tests/test_mxfp4_load_path_layout.py, which asserts the loaded layout
against pristine transformers. The existing mxfp4 tests only cover the
save path, which stayed self-consistent and missed this entirely.
@danielhanchen
danielhanchen requested a review from Datta0 as a code owner August 27, 2026 05:17
chatgpt-codex-connector[bot]

This comment was marked as resolved.

Existence and signature pinning alone is what let this bug run from
transformers 5.0.0 to 5.16.0. Zoo patched mxfp4.dequantize while every
5.x release loaded through Mxfp4Dequantize into dequantize_convertops.
The symbol existed with an unchanged signature the whole time, so every
detector stayed green while the patch reached nothing.

Add _referenced_global_names and _assert_on_live_path, which walk a
caller's code objects (nested code included, so a call inside a
comprehension or nested def is still seen) and fail when the symbol zoo
patches has no live caller. Wire it into the ConversionOps detector, and
add the 4.x equivalent asserting quantizer_mxfp4 still calls the legacy
hook. The 4.x check skips rather than fails when transformers.quantizers
cannot import, since a broken optional backend says nothing about drift.
…usly

Two gates that did not gate.

The layout regression test was in no workflow. Zoo CI runs explicitly named
files, so it was only ever collected by `pytest tests/ --collect-only`, which
proves it imports and nothing more. It now runs in the Core drift job, whose
three lanes resolve to 4.57.6, 5.5.0 and 5.16.x: two affected versions gate it
and one exercises the skip. That job sets UNSLOTH_IS_PRESENT in env, which the
probe subprocess needs and conftest cannot reach, and it has no
continue-on-error and propagates its lane status.

The reachability check read co_names, which mixes attribute operands in with
globals, so every self.quantization_config.dequantize config read counted as a
call site. On transformers 5.16.1, where upstream genuinely removed the call, it
still reported three callers against zero real ones, so it would have stayed
green through the entire window it exists to prevent. Filtering on the opcode
narrows 4.x detection to create_quantized_param alone.
Compress the comments and docstrings added by this PR, and fix three
statements about upstream transformers:

- set_param was removed in 4.57.0 and is absent from 5.x, not a 5.x only kwarg
- shard_and_distribute_module was not deleted in 5.16.0; it still imports and is
  a tombstone that raises only when called, so the gates are defensive
- the dropped transpose affects 5.1.0 and newer, since 5.0.0 dequantize_convertops
  is still 3-arg
chatgpt-codex-connector[bot]

This comment was marked as resolved.

The core-upstream lane installs unsloth with `|| true`, so the checkout is
allowed to fail. The probe subprocess then imports unsloth_zoo, which raises at
__init__.py:250 when find_spec("unsloth") is None, and the failure names
unsloth rather than the layout this test exists to police.

Neither variable already in play covers it. UNSLOTH_ALLOW_CPU only bypasses
accelerator detection, and UNSLOTH_IS_PRESENT guards a separate later check
that the import never reaches, so the comment above the invocation named the
wrong mechanism as well. The probe now sets UNSLOTH_ZOO_DISABLE_GPU_INIT.

Verified with transformers 5.16.1 and unsloth genuinely absent: before, the
test fails with the unsloth ImportError; after, it passes. Reverting the source
still fails with the layout diagnostic, so the gate is intact.
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 27, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 27, 2026
danielhanchen added a commit to shimmyshimmer/unsloth-zoo-staging-2 that referenced this pull request Aug 27, 2026
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: 058b711fc8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

danielhanchen added a commit to shimmyshimmer/unsloth-zoo-staging-2 that referenced this pull request Aug 27, 2026
@danielhanchen
danielhanchen merged commit 1e2594b into main Aug 27, 2026
10 checks passed
@danielhanchen
danielhanchen deleted the fix-mxfp4-load-path-transpose-5x branch August 27, 2026 07:17
danielhanchen added a commit to shimmyshimmer/unsloth-zoo-staging-2 that referenced this pull request Aug 27, 2026
danielhanchen added a commit that referenced this pull request Aug 27, 2026
The guard asserted the lane invokes pytest on exactly 20 files. #1114 added
tests/test_mxfp4_load_path_layout.py to that lane, making it 21, so the guard
went red on a legitimate addition and blocked the fix for a real bug.

The risk it exists for is a continuation that silently drops files. A required
set catches that and names which file went missing, where a count only says
that one did. Additions no longer fail; a removal means dropping the name here
in the same commit, which is the deliberate act it should be.

Also reject a file named twice, which would parse as two entries and mask a
drop from the set check.

Co-authored-by: danielhanchen <unslothai@gmail.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