Repository: huggingface/transformers
Pull request: fix: add identity reverse_op to dequantize ops for save_pretrained

## What does this PR do?

Fixes `save_pretrained()` for models loaded with `dequantize=True`.

`save_pretrained` calls `reverse_op` on all weight conversion operations from loading. Dequantize ops (`Mxfp4Dequantize`, `Fp8Dequantize`, `MetalDequantize`) don't have `reverse_op` implemented, so it raises `NotImplementedError`.

Since dequantized weights are already in their target dtype, the reverse op should just pass them through. Added `_IdentityOp` in `core_model_loading.py` and set it as `reverse_op` on all three dequantize operations.

### Tested with

- **MXFP4**: `openai/gpt-oss-20b` + `Mxfp4Config(dequantize=True)`
- **FP8**: `Qwen/Qwen3-0.6B-FP8` + `FineGrainedFP8Config(dequantize=True)`
- **Metal**: `medmekk/Llama-3.2-1B-Instruct-metal` + `MetalConfig(dequantize=True)`

All three: save ✓, no `quantization_config` in saved config.json ✓, reload ✓ (0 meta params)

Changed files:
filename: src/transformers/core_model_loading.py
patch: @@ -101,6 +101,17 @@ def reverse_op(self) -> ConversionOps:
         raise NotImplementedError
 
 
+class _IdentityOp(ConversionOps):
+    """Pass-through reverse op for dequantize operations.
+
+    Dequantized weights are already in their target dtype and should be
+    saved as-is without any conversion.
+    """
+
+    def convert(self, input_dict: dict[str, Any], **kwargs) -> dict[str, Any]:
+        return input_dict
+
+
 class Chunk(ConversionOps):
     """Split a tensor along ``dim`` into equally sized chunks."""
 

filename: src/transformers/integrations/finegrained_fp8.py
patch: @@ -17,7 +17,7 @@
 from torch.nn import functional as F
 
 from ..activations import ACT2FN
-from ..core_model_loading import ConversionOps
+from ..core_model_loading import ConversionOps, _IdentityOp
 from ..quantizers.quantizers_utils import should_convert_module
 from ..utils import is_kernels_available, is_torch_available, logging
 from .hub_kernels import get_kernel
@@ -752,3 +752,7 @@ def convert(
         return {
             full_layer_name: dequantized.reshape(quantized.shape),
         }
+
+    @property
+    def reverse_op(self) -> "ConversionOps":
+        return _IdentityOp()

filename: src/transformers/integrations/metal_quantization.py
patch: @@ -33,7 +33,7 @@
 which computes ``y = x @ dequant(weight).T``, identical to ``nn.Linear``.
 """
 
-from ..core_model_loading import ConversionOps
+from ..core_model_loading import ConversionOps, _IdentityOp
 from ..quantizers.quantizers_utils import should_convert_module
 from ..utils import is_torch_available, logging
 
@@ -294,3 +294,7 @@ def convert(self, input_dict: dict, full_layer_name: str | None = None, **kwargs
 
         w_deq = _affine_dequantize_tensor(quantized, scales, qbiases, group_size, bits)
         return {full_layer_name: w_deq.to(scales.dtype)}
+
+    @property
+    def reverse_op(self) -> "ConversionOps":
+        return _IdentityOp()

filename: src/transformers/integrations/mxfp4.py
patch: @@ -20,7 +20,7 @@
     from torch import nn
 from contextlib import contextmanager
 
-from ..core_model_loading import ConversionOps
+from ..core_model_loading import ConversionOps, _IdentityOp
 from ..quantizers.quantizers_utils import get_module_from_name, should_convert_module
 
 
@@ -145,6 +145,10 @@ def convert(
         dequantized = dequantize_convertops(param_data[f"{proj}_blocks"], param_data[f"{proj}_scales"])
         return {full_layer_name: dequantized}
 
+    @property
+    def reverse_op(self) -> "ConversionOps":
+        return _IdentityOp()
+
 
 class Mxfp4Deserialize(ConversionOps):
     def __init__(self, hf_quantizer):