diff --git a/docs/w4a4_hessian.md b/docs/w4a4_hessian.md new file mode 100644 index 000000000..692e30562 --- /dev/null +++ b/docs/w4a4_hessian.md @@ -0,0 +1,286 @@ +# w4a4: accumulating the GPTQ Hessian on quantized activations + +## The problem + +GPTQ minimizes the layer output error. For weight-only quantization inference +computes `Ŵ·X`, so the objective and its Hessian are: + +``` +min_Ŵ ‖ W·X − Ŵ·X ‖² = min_Ŵ ‖ (W − Ŵ)·X ‖² -> H = 2·X·Xᵀ +``` + +Under w4a4 inference computes `Ŵ·Q(X)`. If calibration accumulates `H` on `X` +while the kernel feeds `Q(X)`, the solved weights are optimal for inputs the +model never sees. At 4-bit activations that gap is large — unlike w4a16, where +`Q(X) = X` and the question does not arise. + +In GPTQModel, activations enter in exactly one place: `GPTQ.process_batch` +(`gptqmodel/quantization/gptq.py`) reshapes the layer input and hands it to +`compute_hessian_xtx`; `add_batch` accumulates into `self.H`. After that `X` is +never referenced again. `self.H` feeds dead-column detection, act-order +permutation, act-group-aware grouping, and the damped Cholesky inverse — so +substituting at accumulation time propagates to all of them. + +## Two tiers + +| tier | objective | statistics | reference implementation | +|---|---|---|---| +| **A** | `min ‖(W−Ŵ)·Q(X)‖²` | `H` only | Model-Optimizer `GPTQHelper` | +| **B** | `min ‖W·X − Ŵ·Q(X)‖²` | `H` + `∆(XXᵀ)` | Quark GPTAQ / Qronos | + +Tier A substitutes the input and treats activation-quantization error as an +irreducible constant. It is an approximation: the true residual `W·X − Ŵ·Q(X)` +is **not** of the form `(W − Ŵ)·M` for any `M`, because the bias `W·(X − Q(X))` +cannot be cancelled by any choice of `Ŵ`. It is nonetheless strictly better than +the status quo. + +Tier B is the exact objective and absorbs that bias, at the cost of a second +accumulator and changes to the `fasterquant` solve. + +Tier A is a prerequisite for B. Recommendation: land A, measure against a w4a4 +perplexity baseline, then decide on B. + +--- + +## What the reference implementations do + +### Model-Optimizer — does exactly tier A + +`modelopt/torch/quantization/utils/calib_utils.py`, `GPTQHelper.setup()`: + +```python +def hessian_forward(self, input, *args, **kwargs): + inp = input.to_local() if hasattr(input, "to_local") else input + if self.input_quantizer is not None and self.input_quantizer.is_enabled: + hessian_input = self.input_quantizer(inp) # Q(X) + else: + hessian_input = inp # X + gptq_helper.hessian, gptq_helper.n_samples = update_hessian( + hessian_input, gptq_helper.hessian, gptq_helper.n_samples + ) + return self._forward_no_gptq_hessian(input, *args, **kwargs) + +bind_forward_method(self.module, hessian_forward, self.CACHE_NAME) +``` + +Three things to take from this: + +1. **One branch, no separate w4a4 path.** The Hessian goes on `Q(X)` whenever the + input quantizer is enabled. +2. **It reuses the same `input_quantizer` object the inference path uses** + (`conversion.py` does `input = self.input_quantizer(input)`), so the + calibration scheme cannot drift from the serving scheme. +3. **It patches `forward`, it does not use a hook.** This is essential. A + `forward_pre_hook` fires before the forward body and therefore sees the input + *before* the internal `input_quantizer` call. **The quantized activation + cannot be captured with a hook — the quantizer must be applied explicitly.** + A port that uses hooks will silently accumulate clean activations. + +`update_hessian` itself is standard (`H += (2/n)·XXᵀ` with incremental +rescaling), including a zero-token guard for MoE experts that receive no tokens. + +#### How the static activation scale is resolved + +NVFP4 activations use a **static per-tensor global scale** plus **dynamic +per-16-block E4M3 scales**. The global scale is a property of the whole +calibration set, which appears to create a chicken-and-egg with the calibration +pass itself. + +Model-Optimizer resolves it by **ordering, not by a two-pass GPTQ**: the input +quantizer's amax is calibrated in an earlier, separate stage (`max_calibrate`), +so by the time the GPTQ stage runs the quantizer is already calibrated and +enabled. GPTQ stays single-pass. This works because the scale belongs to the +quantizer, not to GPTQ. + +#### Paths in Model-Optimizer that deliberately do *not* do this + +- `local_hessian_calibrate` (`model_calib.py`) feeds **clean** activations. Its + hooks are `register_forward_pre_hook`s capturing `args[0]` — pre-quantizer, per + the point above. Correct there, because it is a different object: a per-cin-block + Hessian used to weight a *weight*-error metric + (`local_hessian_error(x, xq)` computes `dw = x − xq` over `cout`). +- **AWQ-lite / AWQ-clip** *do* use quantized activations, gated on + `is_input_quantized = module.input_quantizer.is_enabled`, then searching + against `self.input_quantizer(input)`. Note AWQ-clip calls `max_calibrate` on + the current batch and uses the result immediately — a running max. Acceptable + for a scale search; not advisable under a matrix that gets Cholesky-inverted. + +### Quark — a different axis, plus the tier-B machinery + +Quark's GPTQ (`quark/torch/algorithm/gptq/gptq.py`) exposes +`add_batch_quantized` / `add_batch_nonquantized`, which resembles this +distinction but is not it. In Quark, "quantized" means **the input produced by +running the block with already-quantized preceding weights** — sequential +weight-error propagation. Evidence: + +- The hook is a `register_forward_hook` capturing `input[0]`, i.e. pre-quantizer. +- The two passes differ by `layer_inputs` vs `orig_layer_inputs`, and the clean + pass runs under `RestoreOriginalWeights(layer)`. Restoring *weights* is what + produces the clean input, so the axis is weights, not activations. +- Plain GPTQ's `add_batch_nonquantized` raises + `ValueError("We don't need it for GPTQ")`. + +So Quark is not a precedent for activation-quantized Hessians. It does, however, +implement the asymmetric objective needed for tier B, in +`quark/torch/algorithm/gptaq/gptaq.py` (GPTAQ, arXiv 2504.02692) and +`qronos/qronos.py`: + +```python +# add_batch_quantized: H = X̃ X̃ᵀ +self.H += inp.matmul(inp.t()) + +# add_batch_nonquantized: ∆(XXᵀ) = (X − X̃) X̃ᵀ +delta_input = original_input - self.q_input +self.delta_X_Xt += delta_input.matmul(self.q_input.t()) +``` + +That is `min_Ŵ ‖W·X − Ŵ·X̃‖²`, structurally identical to the w4a4 objective with +`X̃ = Q(X)`; `∆(XXᵀ)` is the cross term tier A drops. GPTAQ also runs two +`block_forward`s per batch, so a second pass is accepted practice. + +--- + +## Proposed change to GPTQModel (tier A) + +### Config + +`grep` for `act_bits|activation_bits|act_quant|activation_scheme` in +`gptqmodel/quantization/config.py` returns nothing — GPTQModel has no notion of +activation quantization today. This adds a capability, not a flag. Add to +`GPTQConfig`: + +```python +act_format: Optional[FORMAT] = None # None = weight-only (today's behavior) +act_group_size: int = 16 # NVFP4 block +``` + +Validation: `act_format=nvfp4` should require `format=nvfp4`, and should reject +`mock_quantization` for the same reason the weight path does. + +### `gptq.py` + +Insert in `process_batch`, after `reshaped_inp = reshaped_inp.contiguous()` and +**before** the `_tp_pad_cols` zero-padding: + +```python +reshaped_inp = reshaped_inp.contiguous() + +# w4a4: the kernel sees Q(X), so the Hessian must describe Q(X), not X. +# Quantize before TP zero-padding so pad columns do not enter block statistics. +if self._act_quantizer is not None: + reshaped_inp = self._act_quantizer(reshaped_inp) + +if self._tp_pad_cols: + ... +``` + +Ordering matters twice: + +- **Before TP padding** — `_tp_pad_cols` appends zero columns; quantizing after + would let those zeros enter a 16-wide block's amax and shift its scale. +- **After any input-scale / smoothing transform** — `Q` must see the + post-transform activation, because that is what the kernel quantizes. + +### The activation quantizer + +`NVFP4Quantizer` (`gptqmodel/quantization/quantizer.py`) is **not** reusable: +`find_params` raises `NotImplementedError` for `weight=False`, and it derives one +scale per row of a group-sliced weight. Activations need per-token × per-16-block +along the last dim of a `(tokens, cin)` tensor. + +Use torchao's `nvfp4_quantize(x, block_size=16)` — already imported in +`gptqmodel/quantization/dtype.py` and used by the weight path — then dequantize +back to the staging dtype so `compute_hessian_xtx` is untouched. + +Following Model-Optimizer, the quantizer should be an object shared with (or +provably identical to) whatever the serving kernel applies, rather than a +reimplementation kept in sync by hand. + +## Consequences to watch + +- **Dead columns.** `dead = diag(H) == 0` currently sets `H[dead, dead] = 1`, + silently skipping correction for that column. Small-magnitude channels can + flush to zero under FP4, creating dead columns that did not exist in the clean + Hessian. Log a count when activation quantization is enabled. +- **act-order / act-group-aware.** Both sort on `diag(H)`. With `Q(X)` the + ordering is by quantized-activation importance — arguably more correct for + w4a4, but group assignments will differ from a weight-only run, so results are + not comparable across the flag. +- **Cost.** One quantize+dequantize per batch, negligible against the `XᵀX` GEMM, + but it adds a full-size fp32 temporary — relevant given the existing OOM + fallback in `process_batch`. +- **MoE.** Each expert sees only its routed tokens. With few tokens per expert a + dynamic scale is noisier; the static global scale mitigates this, which is + another argument for matching the serving scheme exactly. + +## The static global scale: how `tore-quant` does it + +`tore-quant` runs the production NVFP4 pipeline and answers the scale questions +concretely. + +**Formula** (`src/tore_quant/convert_mxfp4_to_nvfp4.py`): + +```python +def amax_to_scale(amax: torch.Tensor) -> torch.Tensor: + """NVFP4 second-level scale formula.""" + return amax.float() / 6.0 / 448.0 +``` + +i.e. `global_scale = amax / (F4_E2M1_MAX × F8_E4M3_MAX)`, serialized per module +as a fp32 scalar `.input_scale`. + +**The amax is a plain max, not clipped.** It comes from modelopt max-calibration +buffers, distilled by `save_amax_sidecar` (`src/tore_quant/ptq.py:525`). + +**Two phases, not one pass.** Phase 1 (`ptq.py --emit-amax-sidecar`) writes +`amax_per_layer.json`; Phase 2 (`convert_mxfp4_to_nvfp4`) consumes it. Same shape +as Model-Optimizer: the global scale is frozen by an earlier stage, so the +quantization step never has to solve the chicken-and-egg. + +**The scale is shared by input site, not per module** — the part that matters +most for GPTQModel: + +``` +layer{L}.moe_input <- max input amax over routed-expert w1/w3 +layer{L}.w2_input <- max input amax over routed-expert w2 +``` + +`w1` and `w3` consume the same MoE input, so they share one scale; `w2` sees the +post-SwiGLU activation and gets its own. A peer-max sync +(`fixup_moe_expert_amax`) then makes the value identical across every expert in a +layer and across ranks, so a per-layer scalar loses nothing. + +By the same logic `q/k/v` would share their attention input. + +**MoE calibration caveat, from `--calib-all-experts` (default False):** forcing +every token through every expert "distorts per-expert input amax". Native top-k +routing plus the peer-max sync is the supported path. + +### Consequence for this implementation + +Tier A currently passes `per_tensor_scale=None`, so it uses block scales only. +Closing that gap needs more than a number: `GPTQ` is per-module and has no way to +express "these modules share an activation scale". A faithful implementation +needs the scale keyed by **input site** and supplied from a sidecar, either + +- loaded from an `amax_per_layer.json`-style file (matches the existing + pipeline, no new calibration stage), or +- collected by a GPTQModel-side amax pass with sibling-sharing logic. + +The first is much cheaper and reuses a file the pipeline already produces. + +## Open questions + +1. Whether the input-scale (`inscale1`) transform applies to activations at + inference. If so `Q` sits after it, and any amax must be measured + post-transform. +2. Whether `X̃` should compose both error sources — upstream weight quantization + *and* activation quantization, i.e. `X̃ = Q(X_weightpath)`. That is what the + deployed model sees, and neither reference implementation does both at once. + +## Verification plan + +- A layer where `H_quant` and `H_clean` differ measurably (otherwise the change + is a no-op and something is misconfigured — e.g. quantizing via a hook). +- End-to-end: quantizing with `H_quant` should beat `H_clean` on w4a4 perplexity. + Without this the change is unfalsifiable. diff --git a/gptqmodel/looper/gptq_processor.py b/gptqmodel/looper/gptq_processor.py index a1dc0e7a4..2c08ce1e1 100644 --- a/gptqmodel/looper/gptq_processor.py +++ b/gptqmodel/looper/gptq_processor.py @@ -57,6 +57,12 @@ def clone_gptq_config_for_module( qcfg_clone = copy.deepcopy(qcfg) + # The caller-supplied fallback (base.py passes quantize_config.fallback into + # loop()) is the BASELINE, applied before dynamic so per-module `fallback` + # overrides win. Applying it after (the previous order) silently clobbered + # every dynamic fallback override in the standard quantize() path. + qcfg_clone.fallback = normalize_fallback(fallback, qcfg_clone.fallback) + # dynamic overrides if qcfg.dynamic is not None: qcfg_clone.bits = qcfg.dynamic_get(module_full_name, "bits", qcfg_clone.bits) @@ -102,7 +108,6 @@ def clone_gptq_config_for_module( qcfg_clone._resolve_activation_ordering(desc_act_override, act_group_aware_override) - qcfg_clone.fallback = normalize_fallback(fallback, qcfg_clone.fallback) return qcfg_clone class GPTQProcessor(LoopProcessor): diff --git a/gptqmodel/looper/weight_only_looper.py b/gptqmodel/looper/weight_only_looper.py index 87b9938ac..77e698e2e 100644 --- a/gptqmodel/looper/weight_only_looper.py +++ b/gptqmodel/looper/weight_only_looper.py @@ -30,7 +30,7 @@ from ..models import BaseQModel from ..models._const import CPU, SUPPORTS_MODULE_TYPES from ..nn_modules.converter import MODULE_CONVERTER_MAP -from ..quantization.config import BitsAndBytesConfig, FP8Config, GGUFConfig, RTNConfig, VramStrategy +from ..quantization.config import BitsAndBytesConfig, FP8Config, GGUFConfig, NVFP4Config, RTNConfig, VramStrategy from ..utils import has_gil_disabled from ..utils.device import get_device from ..utils.device_telemetry import emit_device_telemetry @@ -414,7 +414,7 @@ def _quantize_named_module( self, named: NamedModule, target_device: torch.device, - ) -> Tuple[NamedModule, Optional[RTNConfig | GGUFConfig | FP8Config | BitsAndBytesConfig]]: + ) -> Tuple[NamedModule, Optional[RTNConfig | GGUFConfig | FP8Config | NVFP4Config | BitsAndBytesConfig]]: """Run one module's weight-only quantization on its assigned device.""" self._prepare_named_module_for_quantization(named, target_device) @@ -427,7 +427,7 @@ def _quantize_named_module( def _finalize_quantized_module( self, named: NamedModule, - active_qcfg: RTNConfig | GGUFConfig | FP8Config | BitsAndBytesConfig, + active_qcfg: RTNConfig | GGUFConfig | FP8Config | NVFP4Config | BitsAndBytesConfig, ) -> str: """Move a quantized module back to CPU, pack it, and optionally offload it.""" @@ -477,7 +477,7 @@ def _finalize_quantized_module( def _finalize_target_device( self, - active_qcfg: RTNConfig | GGUFConfig | FP8Config | BitsAndBytesConfig, + active_qcfg: RTNConfig | GGUFConfig | FP8Config | NVFP4Config | BitsAndBytesConfig, ) -> torch.device: """Resolve the worker device for one finalize task.""" @@ -488,7 +488,7 @@ def _finalize_target_device( def _finalize_subset_modules( self, - quantized_modules: List[Tuple[NamedModule, RTNConfig | GGUFConfig | FP8Config | BitsAndBytesConfig]], + quantized_modules: List[Tuple[NamedModule, RTNConfig | GGUFConfig | FP8Config | NVFP4Config | BitsAndBytesConfig]], ) -> None: """Finalize one subset, using the device pool when multiple finalize targets exist.""" @@ -576,10 +576,10 @@ def _advance_finalize_progress(named: NamedModule, module_label: str) -> None: def _quantize_subset_modules( self, named_modules: List[NamedModule], - ) -> List[Tuple[NamedModule, RTNConfig | GGUFConfig | FP8Config | BitsAndBytesConfig]]: + ) -> List[Tuple[NamedModule, RTNConfig | GGUFConfig | FP8Config | NVFP4Config | BitsAndBytesConfig]]: """Quantize one subset, using multiple devices when available.""" - results_by_name: Dict[str, Tuple[NamedModule, RTNConfig | GGUFConfig | FP8Config | BitsAndBytesConfig]] = {} + results_by_name: Dict[str, Tuple[NamedModule, RTNConfig | GGUFConfig | FP8Config | NVFP4Config | BitsAndBytesConfig]] = {} task_specs = [ (named, self._assign_quant_device_for_module(named, CPU)) for named in named_modules @@ -669,10 +669,10 @@ def _offload_quantized_module(self, module: NamedModule) -> None: def loop(self, **kwargs): """Quantize layers directly from weights without calibration forwards.""" quant_config = self.gptq_model.quantize_config - if not isinstance(quant_config, (RTNConfig, GGUFConfig, FP8Config, BitsAndBytesConfig)): + if not isinstance(quant_config, (RTNConfig, GGUFConfig, FP8Config, NVFP4Config, BitsAndBytesConfig)): raise NotImplementedError( "Weight-only looper only supports `RTNConfig`, `GGUFConfig`, " - "`FP8Config`, and `BitsAndBytesConfig` today." + "`FP8Config`, `NVFP4Config`, and `BitsAndBytesConfig` today." ) if quant_config.lm_head: diff --git a/gptqmodel/looper/weight_only_processor.py b/gptqmodel/looper/weight_only_processor.py index 31b08abe7..1e4615aef 100644 --- a/gptqmodel/looper/weight_only_processor.py +++ b/gptqmodel/looper/weight_only_processor.py @@ -29,6 +29,7 @@ FP8Config, GGUFConfig, METHOD, + NVFP4Config, RTNConfig, clone_weight_only_config_for_module, resolve_quant_format, @@ -48,9 +49,9 @@ class WeightOnlyProcessor(LoopProcessor): def __init__( self, tokenizer, - qcfg: RTNConfig | GGUFConfig | FP8Config | BitsAndBytesConfig, + qcfg: RTNConfig | GGUFConfig | FP8Config | NVFP4Config | BitsAndBytesConfig, ): - """Initializes a weight-only processor for RTN, GGUF, FP8, or BitsAndBytes.""" + """Initializes a weight-only processor for RTN, GGUF, FP8, NVFP4, or BitsAndBytes.""" super().__init__( tokenizer=tokenizer, @@ -74,10 +75,10 @@ def is_skipped(self, module: NamedModule) -> bool: return self.qcfg.dynamic_get(layer_name=module.full_name) is False @staticmethod - def _uses_direct_pack(qcfg: RTNConfig | GGUFConfig | FP8Config | BitsAndBytesConfig) -> bool: + def _uses_direct_pack(qcfg: RTNConfig | GGUFConfig | FP8Config | NVFP4Config | BitsAndBytesConfig) -> bool: """Returns whether the method packs directly from the original dense weights.""" - return qcfg.method in {METHOD.GGUF, METHOD.FP8, METHOD.BITSANDBYTES} + return qcfg.method in {METHOD.GGUF, METHOD.FP8, METHOD.NVFP4, METHOD.BITSANDBYTES} def _update_logged_loss(self, module: NamedModule, avg_loss: str) -> None: """Backfills the logged loss field after late dequant-error measurement.""" @@ -93,7 +94,7 @@ def quantize_module( module: NamedModule, *, device: Optional[torch.device] = None, - ) -> Optional[RTNConfig | GGUFConfig | FP8Config | BitsAndBytesConfig]: + ) -> Optional[RTNConfig | GGUFConfig | FP8Config | NVFP4Config | BitsAndBytesConfig]: """Clones per-module config, quantizes weights, and logs the result.""" qcfg_clone = clone_weight_only_config_for_module(self.qcfg, module.full_name) @@ -149,7 +150,7 @@ def submodule_finalize( module: NamedModule, model: BaseQModel, *, - qcfg: Optional[RTNConfig | GGUFConfig | FP8Config | BitsAndBytesConfig] = None, + qcfg: Optional[RTNConfig | GGUFConfig | FP8Config | NVFP4Config | BitsAndBytesConfig] = None, **kwargs, ): """Creates and packs the final quantized module into the model graph.""" @@ -268,6 +269,8 @@ def name(self) -> str: return "weight_only_gguf" if self.qcfg.method == METHOD.FP8: return "weight_only_fp8" + if self.qcfg.method == METHOD.NVFP4: + return "weight_only_nvfp4" if self.qcfg.method == METHOD.BITSANDBYTES: return "weight_only_bitsandbytes" return "weight_only_rtn" diff --git a/gptqmodel/models/auto.py b/gptqmodel/models/auto.py index 478afc2f9..ed2699749 100644 --- a/gptqmodel/models/auto.py +++ b/gptqmodel/models/auto.py @@ -730,6 +730,8 @@ def export(model_id_or_path: str, target_path: str, format: str, trust_remote_co backend = BACKEND.PAROQUANT_CUDA elif normalized_method == METHOD.FP8.value: backend = BACKEND.FP8_TORCH + elif normalized_method == METHOD.NVFP4.value: + backend = BACKEND.NVFP4_TORCH elif normalized_method == METHOD.EXL3.value: backend = BACKEND.EXL3_TORCH else: diff --git a/gptqmodel/models/base.py b/gptqmodel/models/base.py index e045547a3..aab73a22e 100644 --- a/gptqmodel/models/base.py +++ b/gptqmodel/models/base.py @@ -896,6 +896,9 @@ def quantize( preferred_backend = BACKEND.AUTO elif self.quantize_config.method == METHOD.FP8: preferred_backend = BACKEND.FP8_TORCH + elif export_quant_method == METHOD.NVFP4 or format_code == FORMAT.NVFP4: + # Covers both weight-only NVFP4 and GPTQ-on-NVFP4-grid exports. + preferred_backend = BACKEND.NVFP4_TORCH elif self.quantize_config.method == METHOD.BITSANDBYTES: preferred_backend = BACKEND.BITSANDBYTES else: diff --git a/gptqmodel/nn_modules/qlinear/fp4.py b/gptqmodel/nn_modules/qlinear/fp4.py index 0eb6a0df8..1a3ab5756 100644 --- a/gptqmodel/nn_modules/qlinear/fp4.py +++ b/gptqmodel/nn_modules/qlinear/fp4.py @@ -9,11 +9,25 @@ import torch.nn as nn import torch.nn.functional as F +from ...adapter.adapter import Adapter, Lora +from ...models._const import DEVICE, PLATFORM +from ...quantization import FORMAT, METHOD +from ...quantization.config import NVFP4_WEIGHT_BLOCK_SIZE +from ...quantization.dtype import dequantize_f4_e2m1, device_supports_native_fp4 +from ...utils.backend import BACKEND +from . import WeightOnlyQuantLinear +from .fp8 import _weight_to_matrix +from .gguf import _apply_optional_smoother + try: - from torchao.prototype.mx_formats.nvfp4_tensor import NVFP4Tensor + from torchao.prototype.mx_formats.kernels import f32_to_f4_unpacked, pack_uint4 + from torchao.prototype.mx_formats.nvfp4_tensor import NVFP4Tensor, nvfp4_quantize except Exception: NVFP4Tensor = None + nvfp4_quantize = None + f32_to_f4_unpacked = None + pack_uint4 = None class TorchFP4Linear(nn.Module): @@ -29,6 +43,7 @@ def __init__( weight_block_size: int, orig_dtype: torch.dtype, bias: Optional[torch.Tensor] = None, + weight_scale_2: Optional[torch.Tensor] = None, ) -> None: super().__init__() self.in_features = in_features @@ -37,6 +52,12 @@ def __init__( self.orig_dtype = orig_dtype self.register_buffer("weight", weight) self.register_buffer("weight_scale", weight_scale) + # ModelOpt/Quark NVFP4 checkpoints carry a second-level per-tensor scale. + # Ignoring it would dequantize every block by a constant factor off. + if isinstance(weight_scale_2, torch.Tensor): + self.register_buffer("weight_scale_2", weight_scale_2.to(torch.float32).reshape(())) + else: + self.weight_scale_2 = None if isinstance(bias, torch.Tensor): self.register_buffer("bias", bias) else: @@ -52,6 +73,7 @@ def _native_weight(self) -> "NVFP4Tensor": self.weight_scale, block_size=self.weight_block_size, orig_dtype=self.orig_dtype, + per_tensor_scale=self.weight_scale_2, ) def forward(self, x: torch.Tensor) -> torch.Tensor: @@ -61,3 +83,408 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: if isinstance(bias, torch.Tensor) and bias.dtype != x.dtype: bias = bias.to(dtype=x.dtype) return F.linear(x, self._native_weight(), bias) + + +# NVFP4's second-level (per-tensor) scale. ModelOpt, Quark and tore-quant all +# derive it the same way -- amax / (E2M1_MAX * F8E4M3_MAX) -- and serialize it as +# `weight_scale_2`. Without it the E4M3 block scales carry absolute magnitudes: +# for typical weights `block_amax/6` lands below E4M3's smallest normal (2^-6), +# so `clamp(min=E4M3_EPS)` floors them and the block quantizes on a compressed +# grid. With it, block scales become 448*block_amax/global_amax and use E4M3's +# full range. +_F4_E2M1_MAX = 6.0 +_F8_E4M3_MAX = 448.0 + + +def compute_nvfp4_global_scale(weight: torch.Tensor) -> torch.Tensor: + """NVFP4 `weight_scale_2` for one tensor: amax / (6 * 448).""" + + amax = weight.detach().abs().max().to(torch.float32) + scale = amax / (_F4_E2M1_MAX * _F8_E4M3_MAX) + # An all-zero tensor would give 0 and make every block scale non-finite. + return torch.where(scale > 0, scale, torch.ones_like(scale)).reshape(()) + + +def quantize_nvfp4_weight( + weight: torch.Tensor, + *, + weight_block_size: int = NVFP4_WEIGHT_BLOCK_SIZE, +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """Quantize one dense 2D weight to NVFP4: packed bytes, E4M3 block scales, global scale.""" + + if nvfp4_quantize is None: + raise RuntimeError("NVFP4 quantization requires torchao with NVFP4 support.") + if weight.ndim != 2: + raise ValueError(f"NVFP4 quantization expects a 2D weight matrix, got shape {tuple(weight.shape)}.") + if int(weight_block_size) != NVFP4_WEIGHT_BLOCK_SIZE: + raise ValueError(f"NVFP4 quantization requires `weight_block_size={NVFP4_WEIGHT_BLOCK_SIZE}`.") + if weight.shape[1] % NVFP4_WEIGHT_BLOCK_SIZE != 0: + raise ValueError( + f"NVFP4 quantization expects in_features divisible by {NVFP4_WEIGHT_BLOCK_SIZE}, " + f"got shape {tuple(weight.shape)}." + ) + + weight = weight.to(device="cpu", dtype=torch.float32).contiguous() + global_scale = compute_nvfp4_global_scale(weight) + scales, packed = nvfp4_quantize( + weight, block_size=NVFP4_WEIGHT_BLOCK_SIZE, per_tensor_scale=global_scale + ) + # Store packed nibbles as uint8 so safetensors serialization matches the + # ModelOpt/torchao NVFP4 checkpoint layout regardless of torch fp4 dtype support. + if packed.dtype is not torch.uint8: + packed = packed.view(torch.uint8) + return packed.contiguous(), scales.contiguous(), global_scale.contiguous() + + +class TorchNVFP4Linear(WeightOnlyQuantLinear): + SUPPORTS_BACKENDS = [BACKEND.NVFP4_TORCH] + # METHOD.GPTQ is supported with FORMAT.NVFP4: the GPTQ loop quantizes on the + # NVFP4 grid and this kernel stores/executes the packed result. + SUPPORTS_METHODS = [METHOD.NVFP4, METHOD.GPTQ] + SUPPORTS_FORMATS = {FORMAT.NVFP4: 15} + SUPPORTS_BITS = [4] + SUPPORTS_GROUP_SIZE = [-1, NVFP4_WEIGHT_BLOCK_SIZE] + SUPPORTS_SYM = [True] + SUPPORTS_DESC_ACT = [False] + SUPPORTS_SHARDS = True + SUPPORTS_TRAINING = False + SUPPORTS_AUTO_PADDING = False + SUPPORTS_IN_FEATURES_DIVISIBLE_BY = [NVFP4_WEIGHT_BLOCK_SIZE] + SUPPORTS_OUT_FEATURES_DIVISIBLE_BY = [1] + SUPPORTS_DEVICES = [DEVICE.CPU, DEVICE.CUDA, DEVICE.ROCM, DEVICE.XPU, DEVICE.MPS] + SUPPORTS_PLATFORM = [PLATFORM.ALL] + SUPPORTS_PACK_DTYPES = [torch.int8, torch.int16, torch.int32, torch.int64] + SUPPORTS_ADAPTERS = [Lora] + SUPPORTS_DTYPES = [torch.float16, torch.bfloat16, torch.float32] + + QUANT_TYPE = "nvfp4" + + def __init__( + self, + bits: int, + group_size: int, + sym: bool, + desc_act: bool, + in_features: int, + out_features: int, + bias: bool = False, + pack_dtype: torch.dtype = torch.int32, + adapter: Adapter = None, + register_buffers: bool = True, + weight_block_size: int = NVFP4_WEIGHT_BLOCK_SIZE, + **kwargs, + ): + if int(weight_block_size) != NVFP4_WEIGHT_BLOCK_SIZE: + raise ValueError( + f"TorchNVFP4Linear requires `weight_block_size={NVFP4_WEIGHT_BLOCK_SIZE}`." + ) + self.weight_block_size = NVFP4_WEIGHT_BLOCK_SIZE + self._native_linear_hard_disabled = False + + super().__init__( + bits=bits, + in_features=in_features, + out_features=out_features, + bias=bias, + backend=kwargs.pop("backend", BACKEND.NVFP4_TORCH), + adapter=adapter, + register_buffers=False, + pack_dtype=pack_dtype, + **kwargs, + ) + + if register_buffers: + self._allocate_buffers(bias=bias) + + @classmethod + def validate_once(cls): + if NVFP4Tensor is None or nvfp4_quantize is None: + return False, RuntimeError("TorchNVFP4Linear requires torchao with NVFP4 support.") + return True, None + + def smooth_block_size(self) -> int: + return self.weight_block_size + + def _allocate_buffers(self, *, bias: bool) -> None: + weight = torch.zeros((self.out_features, self.in_features // 2), dtype=torch.uint8) + scale = torch.zeros( + (self.out_features, self.in_features // self.weight_block_size), + dtype=torch.float8_e4m3fn, + ) + + if "weight" in self._buffers: + self.weight = weight + else: + self.register_buffer("weight", weight) + + if "weight_scale" in self._buffers: + self.weight_scale = scale + else: + self.register_buffer("weight_scale", scale) + + # NVFP4's second-level per-tensor scale. 1.0 is the identity, so an + # un-packed module still dequantizes correctly. + scale_2 = torch.ones((), dtype=torch.float32) + if "weight_scale_2" in self._buffers: + self.weight_scale_2 = scale_2 + else: + self.register_buffer("weight_scale_2", scale_2) + + if bias: + bias_tensor = torch.zeros(self.out_features, dtype=torch.float16) + if "bias" in self._buffers: + self.bias = bias_tensor + else: + self.register_buffer("bias", bias_tensor) + else: + self.bias = None + + def list_buffers(self): + buffers = [] + if hasattr(self, "weight") and self.weight is not None: + buffers.append(self.weight) + if hasattr(self, "weight_scale") and self.weight_scale is not None: + buffers.append(self.weight_scale) + if hasattr(self, "weight_scale_2") and self.weight_scale_2 is not None: + buffers.append(self.weight_scale_2) + if hasattr(self, "bias") and self.bias is not None: + buffers.append(self.bias) + return buffers + + def extra_repr(self) -> str: + return ( + f"in_features={self.in_features}, out_features={self.out_features}, " + f"bias={self.bias is not None}, weight_block_size={self.weight_block_size}" + ) + + def _weight_to_matrix(self, linear: nn.Module) -> torch.Tensor: + return _weight_to_matrix(linear) + + def pack(self, linear: nn.Module, scales: torch.Tensor, zeros: torch.Tensor, g_idx: torch.Tensor = None): + self.pack_original(linear=linear, scales=scales, zeros=zeros, g_idx=g_idx) + + def pack_block( + self, + linear: nn.Module, + scales: torch.Tensor, + zeros: torch.Tensor, + g_idx: torch.Tensor = None, + block_in: int = 8192, + workers: int = 1, + ): + del block_in, workers + self.pack_original(linear=linear, scales=scales, zeros=zeros, g_idx=g_idx) + + def pack_gpu( + self, + linear: nn.Module, + scales: torch.Tensor, + zeros: torch.Tensor, + g_idx: torch.Tensor = None, + *, + block_in: int = 8192, + device: torch.device | None = None, + ): + del block_in, device + self.pack_original(linear=linear, scales=scales, zeros=zeros, g_idx=g_idx) + + def _pack_with_gptq_scales( + self, weight: torch.Tensor, scales: torch.Tensor + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """Pack a weight already on the NVFP4 grid using the loop-produced group scales. + + GPTQ error feedback mutates columns after each group scale is fixed, so the + final weight must be encoded against those exact scales — re-deriving scales + from the weight (RTN-style) would land some blocks on a different grid. + + That constraint also dictates how `weight_scale_2` is chosen here. The loop + already fixed each block scale `s_i`, so the two-level form must satisfy + `block_e4m3_i * ws2 == s_i` exactly, or the packed codes stop matching their + scales. A **power-of-two** `ws2` makes that exact: dividing by it only shifts + the exponent, leaving the E4M3 mantissa untouched. A greedy `amax/(6*448)` + would not, so it is used only on the RTN path where scales are free. + """ + + if f32_to_f4_unpacked is None or pack_uint4 is None: + raise RuntimeError("NVFP4 packing requires torchao with NVFP4 support.") + + scale = scales.to(device="cpu", dtype=torch.float32) + expected_groups = self.in_features // self.weight_block_size + if scale.shape != (self.out_features, expected_groups): + raise ValueError( + f"NVFP4 pack expects scales of shape {(self.out_features, expected_groups)}, " + f"got {tuple(scale.shape)}." + ) + + expanded = scale.repeat_interleave(self.weight_block_size, dim=1) + # Reciprocal-multiply, not divide: matches torchao's nvfp4_quantize numerics + # (see NVFP4Quantizer.quantize). A 1-ULP difference can flip an E2M1 code. + scaled = torch.clamp(weight * expanded.reciprocal(), -6.0, 6.0) + codes = f32_to_f4_unpacked(scaled.contiguous()) + packed = pack_uint4(codes) + if packed.dtype is not torch.uint8: + packed = packed.view(torch.uint8) + + # ws2 = 2^ceil(log2(max(s) / 448)): the smallest power of two that brings + # every block scale within E4M3's range. + smax = scale.max() + if smax > 0: + exponent = torch.ceil(torch.log2(smax / _F8_E4M3_MAX)) + global_scale = torch.exp2(exponent).to(torch.float32).reshape(()) + else: + global_scale = torch.ones((), dtype=torch.float32) + + block_scale = (scale * global_scale.reciprocal()).to(torch.float8_e4m3fn) + # The exactness the docstring depends on is cheap to verify, and a silent + # mismatch here means every packed code is read against the wrong scale. + recovered = block_scale.to(torch.float32) * global_scale + if not torch.equal(recovered, scale): + bad = int((recovered != scale).sum()) + raise RuntimeError( + f"NVFP4 two-level split changed {bad} of {scale.numel()} block scales; " + "packed codes would no longer match their scales." + ) + + return packed.contiguous(), block_scale.contiguous(), global_scale.contiguous() + + @torch.inference_mode() + def pack_original( + self, + linear: nn.Module, + scales: torch.Tensor, + zeros: torch.Tensor, + g_idx: torch.Tensor = None, + *, + smooth=None, + ): + del zeros, g_idx + + weight = self._weight_to_matrix(linear).to(device="cpu", dtype=torch.float32) + if isinstance(scales, torch.Tensor) and scales.numel() > 0: + qweight, weight_scale, weight_scale_2 = self._pack_with_gptq_scales(weight, scales) + else: + weight = _apply_optional_smoother( + weight, + smooth=smooth, + group_size=self.smooth_block_size(), + ) + qweight, weight_scale, weight_scale_2 = quantize_nvfp4_weight( + weight, + weight_block_size=self.weight_block_size, + ) + + if "weight" in self._buffers: + self.weight = qweight + else: + self.register_buffer("weight", qweight) + + if "weight_scale" in self._buffers: + self.weight_scale = weight_scale + else: + self.register_buffer("weight_scale", weight_scale) + + if "weight_scale_2" in self._buffers: + self.weight_scale_2 = weight_scale_2 + else: + self.register_buffer("weight_scale_2", weight_scale_2) + + if linear.bias is not None: + bias = linear.bias.detach().to(device="cpu", dtype=torch.float16) + if "bias" in self._buffers: + self.bias = bias + else: + self.register_buffer("bias", bias) + else: + self.bias = None + + self._native_linear_hard_disabled = False + + def dequantize_weight( + self, + *, + device: torch.device | str | None = None, + dtype: torch.dtype | None = None, + ) -> torch.Tensor: + target_device = self.weight.device if device is None else torch.device(device) + target_dtype = torch.float32 if dtype is None else dtype + + # dequantize_f4_e2m1 handles both the fused CPU path (bf16/fp16 targets) + # and the reference torchao path for other targets/devices. + dequant_dtype = target_dtype if target_dtype in (torch.bfloat16, torch.float16) else torch.bfloat16 + source = self.weight if self.weight.device == target_device else self.weight.to(device=target_device) + scale = ( + self.weight_scale + if self.weight_scale.device == target_device + else self.weight_scale.to(device=target_device) + ) + # Fold the per-tensor scale into the block scales rather than scaling the + # dequantized weight afterwards: one pass, and it keeps the fp32 product + # exact instead of rounding through the E4M3 block scale twice. + scale_2 = getattr(self, "weight_scale_2", None) + if isinstance(scale_2, torch.Tensor): + scale = scale.to(dtype=torch.float32) * scale_2.to(device=target_device, dtype=torch.float32) + weight = dequantize_f4_e2m1( + source, + scale=scale, + axis=None, + target_dtype=dequant_dtype, + ) + if weight.dtype != target_dtype: + weight = weight.to(dtype=target_dtype) + return weight.transpose(0, 1).contiguous() + + def _native_weight(self) -> "NVFP4Tensor": + if NVFP4Tensor is None: + raise RuntimeError("TorchNVFP4Linear requires torchao NVFP4Tensor support.") + return NVFP4Tensor( + self.weight, + self.weight_scale, + block_size=self.weight_block_size, + orig_dtype=torch.bfloat16, + per_tensor_scale=getattr(self, "weight_scale_2", None), + ) + + def _can_use_native_linear(self, x_flat: torch.Tensor) -> bool: + return ( + not self._native_linear_hard_disabled + and NVFP4Tensor is not None + and x_flat.device.type == "cuda" + and x_flat.dtype is torch.bfloat16 + and device_supports_native_fp4(x_flat.device) + ) + + def _forward_dequant_matmul(self, x_flat: torch.Tensor) -> torch.Tensor: + weight = self.dequantize_weight(device=x_flat.device, dtype=x_flat.dtype) + return torch.matmul(x_flat, weight) + + def forward(self, x: torch.Tensor): + original_shape = x.shape[:-1] + (self.out_features,) + x_flat = x.reshape(-1, x.shape[-1]) + + if self._can_use_native_linear(x_flat): + try: + output = F.linear(x_flat, self._native_weight(), None) + except Exception: + self._native_linear_hard_disabled = True + output = self._forward_dequant_matmul(x_flat) + else: + output = self._forward_dequant_matmul(x_flat) + + if self.bias is not None: + bias = self.bias + if bias.device != output.device or bias.dtype != output.dtype: + bias = bias.to(device=output.device, dtype=output.dtype) + output = output + bias + + if self.adapter: + output = self.adapter.apply(x=x_flat, out=output) + + return output.reshape(original_shape) + + +__all__ = [ + "TorchFP4Linear", + "TorchNVFP4Linear", + "quantize_nvfp4_weight", + "compute_nvfp4_global_scale", +] diff --git a/gptqmodel/quantization/__init__.py b/gptqmodel/quantization/__init__.py index c2261e779..d1fa8c5f6 100644 --- a/gptqmodel/quantization/__init__.py +++ b/gptqmodel/quantization/__init__.py @@ -27,6 +27,7 @@ GPTAQConfig, GPTQConfig, HessianConfig, + NVFP4Config, ParoConfig, PreProcessorCode, PreProcessorConfig, diff --git a/gptqmodel/quantization/config.py b/gptqmodel/quantization/config.py index 658c59126..bbba75566 100644 --- a/gptqmodel/quantization/config.py +++ b/gptqmodel/quantization/config.py @@ -114,6 +114,7 @@ class FORMAT(str, Enum): GPTQ_P = "gptq_p" GGUF = "gguf" FP8 = "fp8" + NVFP4 = "nvfp4" BITSANDBYTES = "bitsandbytes" MARLIN = "marlin" BITBLAS = "bitblas" @@ -134,6 +135,7 @@ class METHOD(str, Enum): GPTQ = "gptq" GGUF = "gguf" FP8 = "fp8" + NVFP4 = "nvfp4" BITSANDBYTES = "bitsandbytes" QQQ = "qqq" AWQ = "awq" @@ -652,6 +654,8 @@ def resolve_quant_format( return FORMAT.GGUF if method == METHOD.FP8: return FORMAT.FP8 + if method == METHOD.NVFP4: + return FORMAT.NVFP4 if method == METHOD.BITSANDBYTES: return FORMAT.BITSANDBYTES if method == METHOD.EXL3: @@ -1404,10 +1408,14 @@ def to_dict(self) -> Dict[str, Any]: FORMAT.GPTQ_P, FORMAT.MARLIN, FORMAT.BITBLAS, + FORMAT.NVFP4, }, METHOD.FP8: { FORMAT.FP8, }, + METHOD.NVFP4: { + FORMAT.NVFP4, + }, METHOD.BITSANDBYTES: { FORMAT.BITSANDBYTES, }, @@ -1439,6 +1447,7 @@ def to_dict(self) -> Dict[str, Any]: FORMAT.GPTQ_P, FORMAT.MARLIN, FORMAT.BITBLAS, + FORMAT.NVFP4, ) AWQ_EXPORT_FORMATS: Tuple[FORMAT, ...] = ( FORMAT.GEMM, @@ -1461,6 +1470,22 @@ def to_dict(self) -> Dict[str, Any]: FP8_EXPORT_FORMATS: Tuple[FORMAT, ...] = ( FORMAT.FP8, ) +NVFP4_EXPORT_FORMATS: Tuple[FORMAT, ...] = ( + FORMAT.NVFP4, +) +# NVFP4 fixes 16 FP4 values per FP8 E4M3 scale along the in-feature axis. +NVFP4_WEIGHT_BLOCK_SIZE = 16 + + +def _normalize_nvfp4_format(value: Optional[Union[str, FORMAT]]) -> str: + """Normalize accepted NVFP4 format aliases (`nvfp4`, `fp4`) to `nvfp4`.""" + + if value is None: + return FORMAT.NVFP4.value + normalized = str(getattr(value, "value", value)).strip().lower() + if normalized in {"nvfp4", "fp4"}: + return FORMAT.NVFP4.value + raise ValueError(f"NVFP4Config: unsupported `format`: `{value}`. Expected `nvfp4`.") BITSANDBYTES_EXPORT_FORMATS: Tuple[FORMAT, ...] = ( FORMAT.BITSANDBYTES, ) @@ -1485,6 +1510,7 @@ def to_dict(self) -> Dict[str, Any]: FORMAT.GPTQ_V2: METHOD.GPTQ, FORMAT.GPTQ_P: METHOD.GPTQ, FORMAT.FP8: METHOD.FP8, + FORMAT.NVFP4: METHOD.NVFP4, FORMAT.BITSANDBYTES: METHOD.BITSANDBYTES, FORMAT.EXL3: METHOD.EXL3, FORMAT.GGUF: METHOD.GGUF, @@ -2258,6 +2284,17 @@ def _normalize_fp8_kwargs(payload: Dict[str, Any]) -> Dict[str, Any]: return normalized +def _normalize_nvfp4_kwargs(payload: Dict[str, Any]) -> Dict[str, Any]: + normalized = dict(payload) + weight_only = normalized.pop("weight_only", None) + + if "smoother" not in normalized and "smooth" not in normalized: + normalized["smoother"] = _extract_weight_only_smooth(weight_only) + + normalized[FORMAT_FIELD_CODE] = _normalize_nvfp4_format(normalized.get(FORMAT_FIELD_CODE)) + return normalized + + def _normalize_bitsandbytes_kwargs(payload: Dict[str, Any]) -> Dict[str, Any]: normalized = dict(payload) weight_only = normalized.pop("weight_only", None) @@ -2303,6 +2340,8 @@ def _normalize_quantize_config_payload_for_target_cls(target_cls, payload: Dict[ expected_method = METHOD.AWQ elif target_cls is FP8Config: expected_method = METHOD.FP8 + elif target_cls is NVFP4Config: + expected_method = METHOD.NVFP4 elif target_cls is BitsAndBytesConfig: expected_method = METHOD.BITSANDBYTES elif target_cls is EXL3Config: @@ -2384,6 +2423,8 @@ def _prepare_target_quantize_config_kwargs(target_cls, payload: Dict[str, Any]) normalized = _normalize_gguf_kwargs(normalized) elif target_cls is FP8Config: normalized = _normalize_fp8_kwargs(normalized) + elif target_cls is NVFP4Config: + normalized = _normalize_nvfp4_kwargs(normalized) elif target_cls is BitsAndBytesConfig: normalized = _normalize_bitsandbytes_kwargs(normalized) return _filter_quantize_config_payload_for_target_cls(target_cls, normalized) @@ -2913,7 +2954,17 @@ def from_quant_config(cls, quantize_cfg, format: str = None): }: normalized[key] = val else: - normalized[key] = _normalize_format(val) + raw_method = quantize_cfg.get(METHOD_FIELD_CODE, quantize_cfg.get(QUANT_METHOD_FIELD)) + try: + method_hint = _normalize_quant_method(raw_method) if raw_method is not None else None + except ValueError: + method_hint = None + if method_hint == METHOD.NVFP4: + # NVFP4 payloads accept the `fp4` format alias, same as the + # constructor path (`_normalize_nvfp4_kwargs`). + normalized[key] = FORMAT(_normalize_nvfp4_format(val)) + else: + normalized[key] = _normalize_format(val) elif key in field_names: normalized[key] = val else: @@ -2997,6 +3048,8 @@ def from_quant_config(cls, quantize_cfg, format: str = None): normalized = _normalize_gguf_kwargs(normalized) elif target_cls is FP8Config: normalized = _normalize_fp8_kwargs(normalized) + elif target_cls is NVFP4Config: + normalized = _normalize_nvfp4_kwargs(normalized) elif target_cls is BitsAndBytesConfig: normalized = _normalize_bitsandbytes_kwargs(normalized) @@ -3212,6 +3265,36 @@ class GPTQConfig(PreProcessorConfig): metadata={"help": "Skip heavy computations for fast model loading validation"}, ) hessian: Optional[HessianConfig] = field(default_factory=HessianConfig) + act_format: Optional[FORMAT] = field( + default=None, + metadata={ + "help": "Activation format the deployed kernel will use (w4a4). When set, the " + "GPTQ Hessian is accumulated on Q(X) instead of X, so the solved weights " + "are optimal for the inputs the kernel actually sees. None = weight-only." + }, + ) + act_group_size: int = field( + default=NVFP4_WEIGHT_BLOCK_SIZE, + metadata={"help": "Activation quantization block size, along the last dim."}, + ) + act_amax_path: Optional[str] = field( + default=None, + metadata={ + "help": "Path to an activation amax sidecar (JSON) used to derive NVFP4's static " + "per-tensor global scale as amax/6/448. Accepts a flat {key: amax} mapping " + "or {'amax': {key: amax}}. Without it only block scales are applied, which " + "will not match a kernel that uses a static global activation scale." + }, + ) + act_amax_key_rules: Optional[List[Tuple[str, str]]] = field( + default=None, + metadata={ + "help": "Ordered (regex, replacement) pairs mapping a module name to its sidecar " + "key, first match wins; default is to look up the module name itself. " + "Modules sharing an input must map to the same key -- e.g. w1/w3 both to " + "`layer{N}.moe_input` -- because they share one activation scale." + }, + ) def allowed_quant_methods(self) -> Tuple[METHOD, ...]: return (METHOD.GPTQ,) @@ -3249,6 +3332,71 @@ def __post_init__(self): if self.act_group_aware and self.desc_act: raise ValueError("QuantizeConfig:: `act_group_aware` == `True` requires `desc_act` == `False`.") + if self.format == FORMAT.NVFP4: + # NVFP4 export stores contiguous 16-wide FP4 blocks with E4M3 scales, so the + # GPTQ loop must quantize on that exact grid without column reordering. + if quant_bits_width(self.bits) != 4: + raise ValueError("QuantizeConfig: `format=nvfp4` requires `bits=4`.") + if self.group_size != NVFP4_WEIGHT_BLOCK_SIZE: + raise ValueError( + f"QuantizeConfig: `format=nvfp4` requires `group_size={NVFP4_WEIGHT_BLOCK_SIZE}`." + ) + if not self.sym: + raise ValueError("QuantizeConfig: `format=nvfp4` requires `sym=True`.") + if self.desc_act: + raise ValueError("QuantizeConfig: `format=nvfp4` does not support `desc_act=True`.") + if self.act_group_aware: + if act_group_aware_user_value: + raise ValueError("QuantizeConfig: `format=nvfp4` does not support `act_group_aware=True`.") + self.act_group_aware = False + if float(self.mse or 0.0) > 0.0: + raise ValueError("QuantizeConfig: `format=nvfp4` does not support `mse` scale search.") + if self.fallback is not None: + # Only the RTN fallback routes through NVFP4Quantizer; every other + # strategy quantizes on an asymmetric integer grid whose zero-points + # the NVFP4 packer cannot represent. + if self.fallback.strategy != FallbackStrategy.RTN: + raise ValueError( + "QuantizeConfig: `format=nvfp4` requires `fallback.strategy=rtn`." + ) + # Smoothing multiplies the block scale by a per-block factor, so the + # stored scale is no longer E4M3-exact and packing stops being lossless. + if self.fallback.smooth is not None: + raise ValueError( + "QuantizeConfig: `format=nvfp4` does not support `fallback.smooth`." + ) + if self.mock_quantization: + # The mock loop rounds on an integer grid and applies one scale per + # 128-column block, neither of which lands on the NVFP4 grid. + raise ValueError( + "QuantizeConfig: `format=nvfp4` does not support `mock_quantization=True`." + ) + + if self.act_format is not None: + # w4a4: the Hessian is built from Q(X), so the activation grid has to be one + # we can actually reproduce here. + if self.act_format != FORMAT.NVFP4: + raise ValueError( + f"QuantizeConfig: `act_format={self.act_format}` is not supported; " + "only `act_format=nvfp4` is implemented." + ) + if self.format != FORMAT.NVFP4: + raise ValueError( + "QuantizeConfig: `act_format=nvfp4` requires `format=nvfp4`." + ) + if self.act_group_size <= 0: + raise ValueError("QuantizeConfig: `act_group_size` must be positive.") + if self.mock_quantization: + raise ValueError( + "QuantizeConfig: `act_format` does not support `mock_quantization=True`." + ) + elif self.act_amax_path is not None or self.act_amax_key_rules is not None: + # A sidecar with no activation quantization silently does nothing; that is + # far more likely a misconfiguration than an intent. + raise ValueError( + "QuantizeConfig: `act_amax_path`/`act_amax_key_rules` require `act_format` to be set." + ) + def _resolve_activation_ordering( self, desc_act_user_value: Optional[bool], @@ -3651,6 +3799,90 @@ def _update_output_payload(self, out: Dict[str, Any]) -> None: def uses_weight_only_lifecycle(self) -> bool: return True +@dataclass +class NVFP4Config(PreProcessorConfig): + """Weight-only NVFP4 (FP4 E2M1 + per-16-block FP8 E4M3 scales) export config.""" + + bits: int = field(default=4, metadata={"choices": [4]}) + method: METHOD = field(default=METHOD.NVFP4) + format: Optional[str] = field(default=FORMAT.NVFP4) + group_size: int = field(default=-1) + desc_act: Optional[bool] = field(default=False) + sym: bool = field(default=True) + # NVFP4 fixes the scaling block to 16 values along in-features. + weight_block_size: int = field(default=NVFP4_WEIGHT_BLOCK_SIZE) + + def _resolve_checkpoint_format(self) -> FORMAT: + self.format = FORMAT.NVFP4 + return FORMAT.NVFP4 + + def allowed_quant_methods(self) -> Tuple[METHOD, ...]: + return (METHOD.NVFP4,) + + def supported_export_formats(self) -> Tuple[FORMAT, ...]: + return NVFP4_EXPORT_FORMATS + + def default_desc_act(self) -> bool: + return False + + def __post_init__(self): + self._normalize_preprocessor_state() + super().__post_init__() + + if self.bits != 4: + raise ValueError("NVFP4Config: `bits` must be `4`.") + + if self.method != METHOD.NVFP4: + raise ValueError("NVFP4Config: `method` must be `nvfp4`.") + + self.group_size = -1 + self.desc_act = False + self.sym = True + self.format = _normalize_nvfp4_format(self.format) + + if int(self.weight_block_size) != NVFP4_WEIGHT_BLOCK_SIZE: + raise ValueError( + f"NVFP4Config: `weight_block_size` must be `{NVFP4_WEIGHT_BLOCK_SIZE}`." + ) + self.weight_block_size = NVFP4_WEIGHT_BLOCK_SIZE + + if self.dynamic is not None: + self.dynamic = { + **{k: v for k, v in self.dynamic.items() if k.startswith('-')}, + **{k: v for k, v in self.dynamic.items() if not k.startswith('-')}, + } + for layer, layer_dict in self.dynamic.items(): + self._normalize_dynamic_layer_config(layer, layer_dict) + + def _normalize_dynamic_layer_config( + self, + layer_name: str, + layer_dict: Dict[str, Any], + ) -> None: + if "bits" in layer_dict and int(layer_dict["bits"]) != 4: + raise ValueError(f"NVFP4Config: layer `{layer_name}` only supports 4-bit NVFP4 weights.") + if "group_size" in layer_dict and layer_dict["group_size"] not in (-1, None): + raise ValueError("NVFP4Config: `group_size` is not used; keep it at `-1`.") + if "weight_block_size" in layer_dict and int(layer_dict["weight_block_size"]) != NVFP4_WEIGHT_BLOCK_SIZE: + raise ValueError( + f"NVFP4Config: `weight_block_size` must be `{NVFP4_WEIGHT_BLOCK_SIZE}`." + ) + raw_format = layer_dict.get(FORMAT_FIELD_CODE) + if raw_format is not None: + layer_dict[FORMAT_FIELD_CODE] = _normalize_nvfp4_format(raw_format) + + def quant_linear_init_kwargs(self) -> Dict[str, Any]: + return { + "weight_block_size": self.weight_block_size, + } + + def _update_output_payload(self, out: Dict[str, Any]) -> None: + out[FORMAT_FIELD_CODE] = FORMAT.NVFP4.value + out["weight_block_size"] = self.weight_block_size + + def uses_weight_only_lifecycle(self) -> bool: + return True + @dataclass class BitsAndBytesConfig(PreProcessorConfig): bits: int = field(default=4, metadata={"choices": [4, 8]}) @@ -4078,9 +4310,9 @@ def uses_weight_only_lifecycle(self) -> bool: return True def clone_weight_only_config_for_module( - qcfg: Union[RTNConfig, GGUFConfig, FP8Config, BitsAndBytesConfig], + qcfg: Union[RTNConfig, GGUFConfig, FP8Config, NVFP4Config, BitsAndBytesConfig], module_full_name: str, -) -> Optional[Union[RTNConfig, GGUFConfig, FP8Config, BitsAndBytesConfig]]: +) -> Optional[Union[RTNConfig, GGUFConfig, FP8Config, NVFP4Config, BitsAndBytesConfig]]: if qcfg.dynamic_get(layer_name=module_full_name) is False: return None @@ -4225,11 +4457,12 @@ def _resolve_quantize_config_class(payload: Dict[str, Any]) -> type[BaseQuantize WeightOnlyMethod.RTN, WeightOnlyMethod.GGUF, WeightOnlyMethod.FP8, + WeightOnlyMethod.NVFP4, WeightOnlyMethod.BITSANDBYTES, }: raise ValueError( "QuantizeConfig: unsupported weight-only config. Weight-only export currently supports " - "`rtn`, `gguf`, `fp8`, and `bitsandbytes`." + "`rtn`, `gguf`, `fp8`, `nvfp4`, and `bitsandbytes`." ) if ( format_value == FORMAT.GGUF @@ -4240,6 +4473,8 @@ def _resolve_quantize_config_class(payload: Dict[str, Any]) -> type[BaseQuantize return GGUFConfig if weight_only_method == WeightOnlyMethod.FP8: return FP8Config + if weight_only_method == WeightOnlyMethod.NVFP4: + return NVFP4Config if weight_only_method == WeightOnlyMethod.BITSANDBYTES: return BitsAndBytesConfig if weight_only_method == WeightOnlyMethod.RTN: @@ -4248,6 +4483,12 @@ def _resolve_quantize_config_class(payload: Dict[str, Any]) -> type[BaseQuantize return RTNConfig if method == METHOD.FP8 or format_value == FORMAT.FP8 or _looks_like_fp8_fmt(fp8_storage_fmt): return FP8Config + if method == METHOD.NVFP4: + return NVFP4Config + # `format=nvfp4` with the default/explicit GPTQ method runs the GPTQ algorithm + # on the NVFP4 grid; only `method=nvfp4` selects the direct weight-only path. + if format_value == FORMAT.NVFP4 and method != METHOD.GPTQ: + return NVFP4Config if method == METHOD.BITSANDBYTES or format_value == FORMAT.BITSANDBYTES or _looks_like_bitsandbytes_format(raw_format_value): return BitsAndBytesConfig if method == METHOD.EXL3 or format_value == FORMAT.EXL3: @@ -4276,6 +4517,7 @@ def _known_quantize_config_field_names() -> set[str]: ParoConfig, QQQConfig, FP8Config, + NVFP4Config, BitsAndBytesConfig, EXL3Config, RTNConfig, diff --git a/gptqmodel/quantization/dtype.py b/gptqmodel/quantization/dtype.py index 15e34f7a7..6ce2fd641 100644 --- a/gptqmodel/quantization/dtype.py +++ b/gptqmodel/quantization/dtype.py @@ -42,6 +42,10 @@ "dequantize_fp8", "dequantize_f8_e4m3", "dequantize_f4_e2m1", + "dequantize_block_fp8", + "block_scale_multiplier", + "fake_quantize_nvfp4", + "fp8_storage_dtypes", "is_fp4_packed_dtype", ] @@ -612,6 +616,18 @@ def dequantize_f8_e4m3( if scale is not None and scale_inv is not None: raise ValueError("Provide either scale or scale_inv, not both") + + # MX checkpoints (e.g. mxfp8's UE8M0 `weight_scale_inv`) store block scales as + # uint8 exponent bytes: the multiplier is 2^(b - 127). A genuine float scale is + # never serialized as uint8, so route uint8 scales through the block dequant + # helper unconditionally. Feeding the raw bytes onward is wrong twice over: + # magnitudes ~110-130 exceed 1, so `_fast_scale_arg` flips scale_inv into + # divide mode (observed absmax 3.9 vs correct 0.28 on Minimax-M3-0602), and + # the fast CPU path does not understand 2D block grids in the first place. + mx_scale = scale if scale is not None else scale_inv + if mx_scale is not None and mx_scale.dtype == torch.uint8: + return dequantize_block_fp8(tensor, mx_scale, target_dtype=target_dtype) + if tensor.dtype in _FLOAT8_DTYPES and _can_use_fast_path( tensor, scale if scale is not None else scale_inv, @@ -681,6 +697,108 @@ def dequantize_fp8( ) +# E8M0 is a scale format, never a weight payload, so it is excluded here: a +# tensor stored as e8m0 is metadata and must not be dequantized as if it were +# quantized data. +_FP8_STORAGE_DTYPE_NAMES = ("float8_e4m3fn", "float8_e5m2", "float8_e4m3fnuz", "float8_e5m2fnuz") +_FP8_STORAGE_DTYPES = tuple( + getattr(torch, name) for name in _FP8_STORAGE_DTYPE_NAMES if hasattr(torch, name) +) + + +def fp8_storage_dtypes() -> tuple[torch.dtype, ...]: + """FP8 dtypes that can carry quantized weight payloads (excludes E8M0 scales).""" + + return _FP8_STORAGE_DTYPES + + +def fake_quantize_nvfp4( + x: torch.Tensor, + *, + block_size: int = 16, + per_tensor_scale: Optional[torch.Tensor] = None, +) -> torch.Tensor: + """Quantize to the NVFP4 grid and immediately dequantize, preserving shape/dtype. + + Used to make calibration statistics describe the activations a w4a4 kernel + actually consumes. Blocks run along the last dim, matching the weight path in + ``quantize_to_nvfp4``; ``per_tensor_scale`` is the optional static global + scale (omitted, as in the weight path, when the grid has block scales only). + """ + + if NVFP4Tensor is None or nvfp4_quantize is None: + raise RuntimeError("fake_quantize_nvfp4 requires torchao with NVFP4 support.") + + if x.shape[-1] % block_size != 0: + raise ValueError( + f"NVFP4 activation quantization needs the last dim ({x.shape[-1]}) " + f"to be a multiple of block_size ({block_size})." + ) + + orig_dtype = x.dtype + scales, packed = nvfp4_quantize( + x.to(torch.float32).contiguous(), + block_size=block_size, + per_tensor_scale=per_tensor_scale, + ) + tensor = NVFP4Tensor( + packed, + scales, + block_size, + orig_dtype, + per_tensor_scale=per_tensor_scale, + ) + return tensor.dequantize(orig_dtype) + + +def block_scale_multiplier(scale: torch.Tensor, target_shape: torch.Size) -> torch.Tensor: + """Expand a block scale so it multiplies elementwise against ``target_shape``. + + ``uint8`` scales are MX E8M0 exponents, whose multiplier is ``2**(e - 127)``. + Float scales are already multipliers (block-fp8 checkpoints store them that + way). Each dim is repeated by ``target_dim // scale_dim``, so ``[1, 32]`` + and ``[128, 128]`` block layouts are both handled without special-casing. + """ + + if scale.dtype == torch.uint8: + mult = torch.exp2(scale.to(torch.float32) - 127.0) + else: + mult = scale.to(torch.float32) + + while mult.ndim < len(target_shape): + mult = mult.unsqueeze(-1) + if mult.ndim != len(target_shape): + raise ValueError( + f"block scale rank {mult.ndim} exceeds weight rank {len(target_shape)}" + ) + + for dim, (want, have) in enumerate(zip(target_shape, mult.shape)): + if have == want: + continue + if have == 0 or want % have != 0: + raise ValueError( + f"block scale dim {dim} ({have}) does not evenly divide weight dim ({want})" + ) + mult = mult.repeat_interleave(want // have, dim=dim) + return mult + + +def dequantize_block_fp8( + tensor: torch.Tensor, + scale: torch.Tensor, + *, + target_dtype: torch.dtype = torch.bfloat16, +) -> torch.Tensor: + """Dequantize a block-scaled FP8 weight to ``target_dtype``. + + Unlike :func:`dequantize_fp8`, this applies a *block* scale (one entry per + fixed-size group of elements) and decodes E8M0 exponent bytes, which is what + mxfp8 checkpoints store alongside each weight. + """ + + return (tensor.to(torch.float32) * block_scale_multiplier(scale, tensor.shape)).to(target_dtype) + + def dequantize_f4_e2m1( tensor: torch.Tensor, *, diff --git a/gptqmodel/quantization/foem.py b/gptqmodel/quantization/foem.py index b17002068..7c070282b 100644 --- a/gptqmodel/quantization/foem.py +++ b/gptqmodel/quantization/foem.py @@ -20,6 +20,7 @@ from ..quantization import QuantizeConfig from ..utils.torch import TORCH_GTE_28, torch_compile, torch_sync from .gptq import GPTQ +from .quantizer import NVFP4Quantizer class FOEM(GPTQ): @@ -155,6 +156,8 @@ def quantize( W = self.module_copy self.module_copy = None + if isinstance(self.quantizer, NVFP4Quantizer): + self.quantizer.prime_global_scale(W) self.quantizer.find_params(W, weight=True) H = self.H diff --git a/gptqmodel/quantization/gptaq.py b/gptqmodel/quantization/gptaq.py index 79ef7f0dd..4472f7f65 100644 --- a/gptqmodel/quantization/gptaq.py +++ b/gptqmodel/quantization/gptaq.py @@ -20,6 +20,7 @@ from ..quantization import QuantizeConfig from ..utils.torch import TORCH_GTE_28, torch_compile, torch_sync from .gptq import GPTQ +from .quantizer import NVFP4Quantizer class GPTAQ(GPTQ): @@ -140,6 +141,8 @@ def quantize( W = self.module_copy self.module_copy = None + if isinstance(self.quantizer, NVFP4Quantizer): + self.quantizer.prime_global_scale(W) self.quantizer.find_params(W, weight=True) H = self.H diff --git a/gptqmodel/quantization/gptq.py b/gptqmodel/quantization/gptq.py index af04c8d49..d3cee8c85 100644 --- a/gptqmodel/quantization/gptq.py +++ b/gptqmodel/quantization/gptq.py @@ -6,12 +6,15 @@ # Based on original gptq algorithm and code from https://github.com/IST-DASLab/gptq import contextlib +import functools +import json import math import os +import re import sys import threading import time -from typing import Dict, Optional, Tuple +from typing import Callable, Dict, Optional, Tuple import numpy as np import torch @@ -21,10 +24,11 @@ from ..looper.named_module import NamedModule from ..quantization import QuantizeConfig -from ..quantization.config import FallbackStrategy, SmoothMSE +from ..quantization.config import FORMAT, FallbackStrategy, SmoothMSE from ..utils.device import get_device from ..utils.logger import setup_logger from ..utils.torch import torch_sync +from .dtype import fake_quantize_nvfp4 from .fallback_smooth import mse_optimal_quant, smooth_block from .gar import ( compose_final_perm, @@ -34,7 +38,7 @@ invert_perm, ) from .npu_linalg import npu_inverse_cholesky_factor -from .quantizer import HF_OPTIMUM, Quantizer +from .quantizer import HF_OPTIMUM, NVFP4Quantizer, Quantizer log = setup_logger() @@ -129,6 +133,28 @@ def _device_supports_bfloat16(device: torch.device) -> bool: return support +@functools.lru_cache(maxsize=8) +def _load_act_amax_table(path: str) -> Dict[str, float]: + """Load an activation amax sidecar, cached so thousands of modules read it once. + + Accepts a flat ``{key: amax}`` mapping or ``{"amax": {key: amax}}``, the shape + the NVFP4 conversion pipeline emits. + """ + + with open(path, "r") as handle: + payload = json.load(handle) + + if isinstance(payload, dict) and isinstance(payload.get("amax"), dict): + payload = payload["amax"] + + if not isinstance(payload, dict): + raise ValueError( + f"GPTQ: activation amax sidecar '{path}' must be a mapping, or contain an 'amax' mapping." + ) + + return {str(k): float(v) for k, v in payload.items()} + + def get_number_of_rows_and_cols(layer: nn.Module): # return layer.weight.shape[0], np.prod(layer.weight.shape[1:]) if isinstance(layer, NamedModule): @@ -215,6 +241,7 @@ def __init__(self, module: nn.Module, qcfg: Optional[QuantizeConfig] = None): self.nsamples = 0 self.quantizer = self.create_quantizer(name=self.name) + self._act_quantizer = self._create_act_quantizer() # fwd counter self.fwd_counter = 0 @@ -269,7 +296,90 @@ def validate_module(module): # def has_hessian_issues(self) -> bool: # return any([self.issue_zero_samples, self.issue_nan_hessian, self.issue_non_invertible]) + def _resolve_act_global_scale(self) -> Optional[torch.Tensor]: + """NVFP4's static per-tensor activation scale, from the amax sidecar. + + NVFP4 activations are scaled twice: a static per-tensor global scale frozen + at calibration, and dynamic per-16-block E4M3 scales. Only the block scales + can be derived here, so the global scale is read from a sidecar produced by + an earlier calibration pass (`amax / 6 / 448`, matching the serving + pipeline). Returns ``None`` when no sidecar is configured, leaving block + scales only. + + Keys are resolved through ``act_amax_key_rules`` so modules sharing an + input map to one entry — w1/w3 see the same MoE input and must therefore + share a scale, as must q/k/v. + """ + + path = getattr(self.qcfg, "act_amax_path", None) + if not path: + return None + + table = _load_act_amax_table(path) + + # `self.name` is layer-relative (`mlp.experts.1.gate_proj`) -- it carries no + # layer index for the key rules to capture. NamedModule.full_name is the + # full dotted path (`...layers.3.mlp.experts.1.gate_proj`), so resolve + # against that when available. + name = getattr(self._named_module, "full_name", None) or self.name + + key = name + for pattern, replacement in (getattr(self.qcfg, "act_amax_key_rules", None) or []): + if re.search(pattern, name): + key = re.sub(pattern, replacement, name) + break + + if key not in table: + # Failing loud beats silently falling back to block-only scales, which + # would quantize against a different grid than the kernel uses. + raise KeyError( + f"GPTQ: activation amax for module '{name}' (key '{key}') not found in " + f"'{path}'. Add the entry or extend `act_amax_key_rules`." + ) + + amax = float(table[key]) + if not math.isfinite(amax) or amax <= 0.0: + raise ValueError( + f"GPTQ: activation amax for '{key}' must be finite and positive, got {amax}." + ) + + # NVFP4 second-level scale: amax / F4_E2M1_MAX / F8_E4M3_MAX. + return torch.tensor(amax / 6.0 / 448.0, dtype=torch.float32) + + def _create_act_quantizer(self) -> Optional[Callable[[torch.Tensor], torch.Tensor]]: + """Build the activation fake-quantizer for w4a4, or ``None`` for weight-only. + + Under w4a4 the kernel consumes ``Q(X)``, so the Hessian must be built from + ``Q(X)`` rather than ``X`` — otherwise GPTQ solves for inputs that never + occur. Returning ``None`` preserves the weight-only behaviour exactly. + """ + + act_format = getattr(self.qcfg, "act_format", None) + if act_format is None: + return None + + if act_format != FORMAT.NVFP4: + raise ValueError(f"GPTQ: unsupported `act_format={act_format}`.") + + block_size = int(getattr(self.qcfg, "act_group_size", 16)) + # Resolved on first use, not here: `self.name` is not final at construction + # (the looper names modules afterwards), and a name-keyed sidecar lookup done + # too early silently reads the wrong entry. + resolved: Dict[str, Optional[torch.Tensor]] = {} + + def _quantize_act(x: torch.Tensor) -> torch.Tensor: + if "scale" not in resolved: + resolved["scale"] = self._resolve_act_global_scale() + scale = resolved["scale"] + if scale is not None: + scale = scale.to(x.device) + return fake_quantize_nvfp4(x, block_size=block_size, per_tensor_scale=scale) + + return _quantize_act + def create_quantizer(self, name: str) -> Quantizer: + if getattr(self.qcfg, "format", None) == FORMAT.NVFP4: + return NVFP4Quantizer(qcfg=self.qcfg, name=name) return Quantizer(qcfg=self.qcfg, name=name) def shape(self): @@ -528,6 +638,13 @@ def process_batch(self, inp: torch.Tensor) -> Tuple[int, Optional[torch.Tensor], # Delay dtype conversion until we materialize Hessian chunks to avoid unnecessary temporaries reshaped_inp = reshaped_inp.contiguous() + + # w4a4: the kernel consumes Q(X), so the Hessian must describe Q(X), not X. + # Applied before the TP zero-padding below so the pad columns never enter a + # block's amax, and after any input-scale transform baked into `inp`. + if self._act_quantizer is not None: + reshaped_inp = self._act_quantizer(reshaped_inp) + if self._tp_pad_cols: pad = reshaped_inp.new_zeros((reshaped_inp.shape[0], self._tp_pad_cols)) reshaped_inp = torch.cat((reshaped_inp, pad), dim=1) @@ -679,6 +796,11 @@ def _fallback_quantize(self, strategy: FallbackStrategy, blocksize: int): scale_chunks = [] zero_chunks = [] + if isinstance(self.quantizer, NVFP4Quantizer): + # Same two-level priming as the main path: the RTN fallback quantizes + # group by group and would otherwise floor-clamp every block scale. + self.quantizer.prime_global_scale(W) + for start in range(0, self.columns, effective_group_size): end = min(start + effective_group_size, self.columns) block = W[:, start:end] @@ -948,8 +1070,11 @@ def quantize( use_hessian = False threshold_text = str(getattr(self.fallback, "threshold", None)) threshold_info = f", threshold_raw={threshold_raw}" if threshold_raw is not None and is_percent else "" + # full_name where available: `self.name` is layer-relative and repeats + # across layers, which makes fallback logs ambiguous in multi-layer runs. + fallback_log_name = getattr(self._named_module, "full_name", None) or self.name log.warn( - f"Quantization: Module `{self.name}` -> " + f"Quantization: Module `{fallback_log_name}` -> " f"Using `{resolved_strategy.value}` fallback quantization (observed {self.nsamples} samples, threshold={threshold_text}{threshold_info}, max_total={self.expected_nsamples})." ) self.H = self.create_H(target_device=target_device) @@ -983,6 +1108,10 @@ def quantize( W = self.module_copy.to(device=self.H.device) del self.module_copy + if isinstance(self.quantizer, NVFP4Quantizer): + # Two-level NVFP4 needs the per-tensor global scale fixed from the FULL + # weight before any per-group find_params -- groups alone floor-clamp. + self.quantizer.prime_global_scale(W) self.quantizer.find_params(W, weight=True) # H = self.H.to(device=self.H.device) @@ -1244,6 +1373,12 @@ def quantize( if math.isnan(avg_loss): print("Losses sum item:", torch.sum(Losses).item()) if fallback_configured: + if getattr(self.qcfg, "format", None) == FORMAT.NVFP4: + # The mock retry quantizes on an integer grid and reuses one + # scale per block, both off the NVFP4 grid; the RTN fallback + # goes through NVFP4Quantizer and stays on it. + log.info(f"Quantization: Failed due to `NaN` loss for `{self.name}`, using `{resolved_strategy.value}` fallback quantization for `{self.name}`") + return self._fallback_quantize(resolved_strategy, blocksize) log.info(f"Quantization: Failed due to `NaN` loss for `{self.name}`, use mock quantization retry for `{self.name}`") self.qcfg.mock_quantization = True return self.quantize(blocksize=blocksize) diff --git a/gptqmodel/quantization/quantizer.py b/gptqmodel/quantization/quantizer.py index fa99c5542..bdfb340de 100644 --- a/gptqmodel/quantization/quantizer.py +++ b/gptqmodel/quantization/quantizer.py @@ -5,6 +5,8 @@ # adapted from @qwopqwop200 's [GPTQ-for-LLaMa](https://github.com/qwopqwop200/GPTQ-for-LLaMa/tree/cuda), which itself is based on [gptq](https://github.com/IST-DASLab/gptq) +from typing import Optional + import torch import torch.nn as nn @@ -173,4 +175,97 @@ class QQQQuantizer(Quantizer): def requires_groupwise_processing(self) -> bool: return self.qcfg.group_size == -1 and self.qcfg.sym -__all__ = ["Quantizer"] + +try: + from torchao.prototype.mx_formats.kernels import f4_unpacked_to_f32, f32_to_f4_unpacked +except Exception: + f4_unpacked_to_f32 = None + f32_to_f4_unpacked = None + +_F4_E2M1_MAX = 6.0 +_F8_E4M3_MAX = 448.0 + + +class NVFP4Quantizer(Quantizer): + """Quantizer whose grid is NVFP4: E2M1 values scaled per 16-wide block by E4M3 scales. + + Scale derivation and value rounding mirror torchao's ``nvfp4_quantize`` exactly so + the GPTQ-corrected weight is bit-identical to the packed NVFP4 checkpoint layout. + """ + + def configure(self, *args, **kwargs): + if f32_to_f4_unpacked is None or f4_unpacked_to_f32 is None: + raise RuntimeError("NVFP4Quantizer requires torchao with NVFP4 support.") + super().configure(*args, **kwargs) + self.global_scale: Optional[torch.Tensor] = None + + def prime_global_scale(self, weight: torch.Tensor) -> torch.Tensor: + """Fix the per-tensor second-level scale from the FULL weight, before the loop. + + NVFP4 is two-level: block scales are E4M3 *relative to* a per-tensor global + scale. Without one, ``find_params`` derives absolute block scales + ``amax/6`` -- for realistic weights those sit below E4M3's smallest normal + (2^-6) and get floor-clamped (measured 96.8% of blocks on Minimax-M3 + experts), compressing the grid and costing ~1.4x reconstruction error. + + The global scale is snapped to a power of two, ``2^ceil(log2(amax/(6*448)))`` + (tore-quant's lossless-conversion snap), for exactness downstream: the + largest block's E4M3 ratio lands in (224, 448] by construction, so + ``_pack_with_gptq_scales`` re-derives exactly this value from the effective + scales and the two-level split reproduces the loop's grid bit-for-bit. + + find_params only ever sees one 16-wide group at a time, so this must be + called once per module with the full weight; an unprimed find_params raises. + """ + + amax = weight.detach().abs().amax().to(torch.float32) + if amax > 0: + exponent = torch.ceil(torch.log2(amax / (_F4_E2M1_MAX * _F8_E4M3_MAX))) + self.global_scale = torch.exp2(exponent).reshape(()) + else: + self.global_scale = torch.ones((), dtype=torch.float32) + return self.global_scale + + def find_params(self, x, weight=False): + if not weight: + raise NotImplementedError("NVFP4Quantizer only supports weight quantization.") + + dev = x.device + x_flat = x.flatten(1).to(torch.float32) + + amax = x_flat.abs().amax(dim=1) + gs = getattr(self, "global_scale", None) + if gs is None: + # No single-level fallback: absolute amax/6 block scales sit below + # E4M3's 2^-6 normal floor on realistic weights (measured 96.8% of + # blocks clamped, 1.4x reconstruction error). An unprimed caller is a + # bug, not a mode. + raise RuntimeError( + "NVFP4Quantizer.find_params called without a primed global scale; " + "call prime_global_scale(full_weight) before per-group find_params." + ) + # Two-level (torchao nvfp4_quantize with per_tensor_scale): block ratio + # amax/(6*gs) uses E4M3's full range; effective scale = e4m3 * gs. + ratio = amax / (_F4_E2M1_MAX * gs.to(amax.device)) + ratio = torch.clamp(ratio, min=torch.finfo(torch.float8_e4m3fn).tiny, max=_F8_E4M3_MAX) + scale = ratio.to(torch.float8_e4m3fn).to(torch.float32) * gs.to(amax.device) + + shape = [-1] + [1] * (len(x.shape) - 1) + self.scale = scale.reshape(shape).to(device=dev) + self.zero = torch.zeros_like(self.scale) + + def quantize(self, x): + orig_dtype = x.dtype + # Multiply by the reciprocal rather than dividing, matching torchao's + # nvfp4_quantize (and the MSLK triton kernel it tracks). `x / s` and + # `x * (1/s)` differ by up to 1 ULP in fp32, and with only 16 E2M1 codes a + # value on a rounding boundary can land on a different code. + scaled = torch.clamp( + x.to(torch.float32) * self.scale.reciprocal(), -_F4_E2M1_MAX, _F4_E2M1_MAX + ) + codes = f32_to_f4_unpacked(scaled.contiguous()) + dequant = f4_unpacked_to_f32(codes) * self.scale + return dequant.to(orig_dtype) + + +__all__ = ["Quantizer", "QQQQuantizer", "NVFP4Quantizer"] diff --git a/gptqmodel/utils/backend.py b/gptqmodel/utils/backend.py index 9d4dde801..301f779e8 100644 --- a/gptqmodel/utils/backend.py +++ b/gptqmodel/utils/backend.py @@ -50,6 +50,9 @@ class BACKEND(str, Enum): # FP8 kernels FP8_TORCH = "fp8_torch" + # NVFP4 kernels + NVFP4_TORCH = "nvfp4_torch" + # GGUF kernels / engines GGUF_TORCH = "gguf_torch" GGUF_TRITON = "gguf_triton" @@ -130,6 +133,9 @@ class PROFILE(str, Enum): "fp8": { BACKEND.TORCH: BACKEND.FP8_TORCH, }, + "nvfp4": { + BACKEND.TORCH: BACKEND.NVFP4_TORCH, + }, "exl3": { BACKEND.EXLLAMA_V3: BACKEND.EXL3_EXLLAMA_V3, BACKEND.TORCH: BACKEND.EXL3_TORCH, diff --git a/gptqmodel/utils/model.py b/gptqmodel/utils/model.py index e58e04f14..88692d74e 100644 --- a/gptqmodel/utils/model.py +++ b/gptqmodel/utils/model.py @@ -40,6 +40,7 @@ DEVICE, EXPERT_INDEX_PLACEHOLDER, SUPPORTS_MODULE_TYPES, + normalize_device, ) from ..nn_modules.qlinear import BaseQuantLinear, GPTQQuantLinear from ..nn_modules.qlinear.exllamav2 import ExllamaV2Linear @@ -622,7 +623,7 @@ def create_quant_module( dtype=dtype, in_features=in_features, out_features=out_features, - device=DEVICE(device) if isinstance(device, str) else device, + device=normalize_device(device) if device is not None else None, adapter=adapter, # TODO FIX ME..need to pass Eora if loaded ) if err is not None: diff --git a/gptqmodel/utils/structure.py b/gptqmodel/utils/structure.py index 6edf2ef33..bd4d3077c 100644 --- a/gptqmodel/utils/structure.py +++ b/gptqmodel/utils/structure.py @@ -44,6 +44,7 @@ from safetensors import safe_open from torch import nn +from ..quantization.dtype import dequantize_block_fp8, fp8_storage_dtypes from ..utils.logger import setup_logger @@ -2294,6 +2295,58 @@ def _load_checkpoint_tensors_for_module_path( tensors[rel_name] = handler.get_tensor(full_name) return tensors + def _materialize_dtype(self) -> torch.dtype: + """Compute dtype for dequantized checkpoint tensors.""" + + for cfg in (getattr(self.config, "text_config", None), self.config): + dtype = getattr(cfg, "torch_dtype", None) or getattr(cfg, "dtype", None) + if isinstance(dtype, torch.dtype): + return dtype + if isinstance(dtype, str) and hasattr(torch, dtype): + resolved = getattr(torch, dtype) + if isinstance(resolved, torch.dtype): + return resolved + return torch.bfloat16 + + def _scale_tensor_name(self, full_name: str) -> Optional[str]: + """Checkpoint name of the block scale paired with ``full_name``, if any.""" + + candidate = f"{full_name}_scale_inv" + return candidate if candidate in self._weight_map else None + + def _read_checkpoint_tensor(self, handler, shard_path: str, full_name: str) -> torch.Tensor: + """Read a checkpoint tensor, applying block scales to FP8 payloads. + + An FP8 checkpoint stores only the mantissa payload; the magnitude lives + in a sibling ``*_scale_inv`` tensor. Copying the payload into a bf16 + parameter with a plain dtype cast silently drops that scale, so the + dequant has to happen here, before the tensor reaches the target param. + + Layers the exporter left unquantized (``ignored_layers``) carry no scale + tensor, so the lookup misses and they keep the plain-cast path. + """ + + tensor = handler.get_tensor(full_name) + if tensor.dtype not in fp8_storage_dtypes(): + return tensor + + scale_name = self._scale_tensor_name(full_name) + if scale_name is None: + return tensor + + scale_shard = self._weight_map.get(scale_name) + if scale_shard is None: + return tensor + + scale_path = os.path.join(self.model_local_path, scale_shard) + if scale_path == shard_path: + scale = handler.get_tensor(scale_name) + else: + with safe_open(scale_path, framework="pt", device="cpu") as scale_handler: + scale = scale_handler.get_tensor(scale_name) + + return dequantize_block_fp8(tensor, scale, target_dtype=self._materialize_dtype()) + def _copy_checkpoint_tensors_into_submodule( self, *, @@ -2414,7 +2467,9 @@ def _copy_checkpoint_tensors_into_submodule( ) shard_path = os.path.join(self.model_local_path, shard) with safe_open(shard_path, framework="pt", device="cpu") as handler: - parts.append(handler.get_tensor(full_name)) + parts.append( + self._read_checkpoint_tensor(handler, shard_path, full_name) + ) try: tensor = torch.cat(parts, dim=concat_dim).contiguous() @@ -2511,7 +2566,9 @@ def _copy_checkpoint_tensors_into_submodule( rel_name=rel_name, modules_by_name=modules_by_name, ) - checkpoint_tensor = handler.get_tensor(full_name) + checkpoint_tensor = self._read_checkpoint_tensor( + handler, shard_path, full_name + ) tensor = self._transform_checkpoint_tensor( checkpoint_tensor, expert_index=expert_index, @@ -2759,6 +2816,15 @@ def _materialize_direct_meta_tensors( ) -> int: synced = 0 + # Quantized linear tensors are produced by quantization and restored from the + # quant offload directory, never from the source checkpoint. Kernels whose + # buffer names shadow checkpoint tensors (e.g. NVFP4/FP8 `weight`) must not be + # re-aliased from the original dense weights here. + from ..nn_modules.qlinear import BaseQuantLinear + + if isinstance(shell_sub, BaseQuantLinear): + return synced + with torch.inference_mode(): for name, shell_param in dict(shell_sub.named_parameters(recurse=False)).items(): if not _is_meta_tensor(shell_param): @@ -2782,7 +2848,9 @@ def _materialize_direct_meta_tensors( )) source_path = os.path.join(self.model_local_path, shard) with safe_open(source_path, framework="pt", device="cpu") as handler: - parts.append(handler.get_tensor(full_name)) + parts.append( + self._read_checkpoint_tensor(handler, source_path, full_name) + ) try: source_param = torch.cat(parts, dim=concat_dim).contiguous() @@ -2844,7 +2912,9 @@ def _materialize_direct_meta_tensors( source_path = os.path.join(self.model_local_path, shard) with safe_open(source_path, framework="pt", device="cpu") as handler: - checkpoint_param = handler.get_tensor(full_name) + checkpoint_param = self._read_checkpoint_tensor( + handler, source_path, full_name + ) source_param = self._transform_checkpoint_tensor( checkpoint_param, expert_index=expert_index, @@ -2919,7 +2989,9 @@ def _materialize_direct_meta_tensors( )) source_path = os.path.join(self.model_local_path, shard) with safe_open(source_path, framework="pt", device="cpu") as handler: - parts.append(handler.get_tensor(full_name)) + parts.append( + self._read_checkpoint_tensor(handler, source_path, full_name) + ) try: source_buffer = torch.cat(parts, dim=concat_dim).contiguous() @@ -2979,7 +3051,9 @@ def _materialize_direct_meta_tensors( source_path = os.path.join(self.model_local_path, shard) with safe_open(source_path, framework="pt", device="cpu") as handler: - checkpoint_buffer = handler.get_tensor(full_name) + checkpoint_buffer = self._read_checkpoint_tensor( + handler, source_path, full_name + ) source_buffer = self._transform_checkpoint_tensor( checkpoint_buffer, expert_index=expert_index, diff --git a/scripts/minimax_m3_nvfp4/debug_expert_capture.py b/scripts/minimax_m3_nvfp4/debug_expert_capture.py new file mode 100644 index 000000000..097eec7f3 --- /dev/null +++ b/scripts/minimax_m3_nvfp4/debug_expert_capture.py @@ -0,0 +1,93 @@ +"""Diagnose why layer-3 expert capture saw ~0 tokens during the pilot. + +Loads the model the same way the pilot does, attaches plain forward hooks to all +layer-3 expert Linears, runs ONE 8k calibration sample through the model, and +reports per-expert token counts plus which experts implementation is active. +""" + +import json +import random + +import torch +import torch.nn as nn +from transformers import AutoTokenizer + +MODEL = "/data/sgambhira/models/Minimax-M3-0602" +SNAP = ( + "/data/sgambhira/hf-home/hub/datasets--togethercomputer--m3-quant-eval-runs/" + "snapshots/27f2a493e10922e878840d27924aabd0bbf5d745" +) + +from gptqmodel import BACKEND, GPTQModel, QuantizeConfig # noqa: E402 + +qcfg = QuantizeConfig( + bits=4, + group_size=16, + format="nvfp4", + offload_to_disk_path="/data/sgambhira/gptqmodel-offload/debug-capture", +) +model = GPTQModel.load(MODEL, quantize_config=qcfg, backend=BACKEND.TORCH) +print("DBG model loaded", flush=True) + +cfg = model.model.config +text_cfg = getattr(cfg, "text_config", cfg) +print("DBG top config _experts_implementation:", getattr(cfg, "_experts_implementation", ""), flush=True) +print("DBG text config _experts_implementation:", getattr(text_cfg, "_experts_implementation", ""), flush=True) + +# Materialize only what a 4-layer forward needs (weights are lazy/meta otherwise), +# using the same API the looper uses. +lm = model.model.model.language_model +device = torch.device("cpu") +for sub in [lm.embed_tokens, lm.rotary_emb, lm.norm, *lm.layers[:4]]: + model.shell_module_materialize(sub, device) +print("DBG materialized embed/rotary/norm/layers0-3", flush=True) +# truncate the stack so the forward stops after layer 3 +lm.layers = nn.ModuleList(list(lm.layers[:4])) + +layer3 = lm.layers[3] +experts_mod = layer3.mlp.experts +print("DBG experts module class:", type(experts_mod).__name__, flush=True) +print("DBG experts forward qualname:", type(experts_mod).forward.__qualname__, flush=True) +has_3d = any(p.ndim == 3 for p in experts_mod.parameters(recurse=False)) +print("DBG experts has 3D params:", has_3d, flush=True) + +counts = {} + +def make_hook(name): + def hook(mod, inp, out): + counts[name] = counts.get(name, 0) + inp[0].shape[0] + return hook + +hooks = [] +for e in range(128): + child = getattr(experts_mod, str(e), None) + if child is None: + continue + for proj in ("gate_proj", "up_proj", "down_proj"): + m = getattr(child, proj, None) + if isinstance(m, nn.Linear): + hooks.append(m.register_forward_hook(make_hook(f"{e}.{proj}"))) +print("DBG hooks attached:", len(hooks), flush=True) + +# one 8k calibration sample +tokenizer = AutoTokenizer.from_pretrained(MODEL) +rows = [json.loads(l) for l in open(f"{SNAP}/hle-mxfp8-0602-text2158/responses.jsonl")] +r = random.Random(42).sample(rows, 64)[0] +messages = [ + {"role": "user", "content": r["question"]}, + {"role": "assistant", "content": r["response"], "reasoning_content": r["reasoning"]}, +] +text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False) +ids = tokenizer(text, add_special_tokens=False, return_tensors="pt")["input_ids"][:, :8192] +print("DBG sample tokens:", ids.shape[1], flush=True) + +with torch.inference_mode(): + lm(input_ids=ids) + +gate_counts = {k: v for k, v in counts.items() if k.endswith("gate_proj")} +total = sum(gate_counts.values()) +nonzero = len(gate_counts) +print(f"DBG experts hit: {nonzero}/128 | total routed tokens (gate): {total:,} | expected ~{ids.shape[1]*4:,}", flush=True) +top = sorted(gate_counts.items(), key=lambda kv: -kv[1])[:8] +print("DBG top experts:", top, flush=True) +print("DBG DONE", flush=True) diff --git a/scripts/minimax_m3_nvfp4/emit_act_amax.py b/scripts/minimax_m3_nvfp4/emit_act_amax.py new file mode 100644 index 000000000..9e108bf07 --- /dev/null +++ b/scripts/minimax_m3_nvfp4/emit_act_amax.py @@ -0,0 +1,276 @@ +"""Step 0 for w4a4: emit the per-layer activation amax sidecar. + +NVFP4 activations carry a *static* per-tensor global scale (calibrated, frozen at +serving) plus dynamic per-16-block E4M3 scales. Only the block scales can be +derived while GPTQ accumulates its Hessian, so the global scale has to come from +an earlier pass -- this one. Consumed via `ACT_AMAX=` in run.sh, which turns it +into `global_scale = amax / 6 / 448` (see docs/w4a4_hessian.md). + +Scales are keyed by *input site*, not by module, because modules sharing an input +share a scale: + + layer{N}.moe_input -> input to routed-expert gate_proj/up_proj (w1/w3) + layer{N}.w2_input -> input to routed-expert down_proj (w2), post-SwiGLU + +A plain max is taken over abs, matching tore-quant's `save_amax_sidecar` +(no percentile or MSE clipping), and reduced across every expert of a layer -- +the peer-max sync `fixup_moe_expert_amax` performs. + +Streams the model window-by-window through one GPU exactly as +p1b_expert_coverage.py does (materialize -> forward all samples -> free), since +the checkpoint is far larger than any single device. +""" + +import json +import os +import random +import time + +import torch +import torch.nn as nn +from transformers import AutoTokenizer + +MODEL = os.environ.get("MODEL", "/data/huggingface/Minimax-M3-0602") +OUT_JSON = os.environ.get( + "OUT_JSON", "/data/sgambhira/GPTQModel/scripts/minimax_m3_nvfp4/amax_per_layer.json" +) +SNAP = os.environ.get( + "SNAP", + "/data/sgambhira/hf-home/hub/datasets--togethercomputer--m3-quant-eval-runs/" + "snapshots/27f2a493e10922e878840d27924aabd0bbf5d745", +) +OFFLOAD = os.environ.get("OFFLOAD", "/data/sgambhira/gptqmodel-offload/amax") +SEED = int(os.environ.get("SEED", "42")) +NUM_SAMPLES = int(os.environ.get("NUM_SAMPLES", "64")) +MAX_TOKENS = int(os.environ.get("MAX_TOKENS", "8192")) +WINDOW = int(os.environ.get("WINDOW", "5")) +DEVICE = torch.device(os.environ.get("DEVICE", "cuda:0")) +CKPT = os.environ.get("CKPT", "/data/sgambhira/gptqmodel-offload/amax_resume.pt") + +t0 = time.time() + + +def log(msg: str) -> None: + print(f"[amax +{time.time() - t0:7.1f}s] {msg}", flush=True) + + +from gptqmodel import BACKEND, GPTQModel, QuantizeConfig # noqa: E402 + +qcfg = QuantizeConfig(bits=4, group_size=16, format="nvfp4", offload_to_disk_path=OFFLOAD) +model = GPTQModel.load(MODEL, quantize_config=qcfg, backend=BACKEND.TORCH) +log("model loaded") + +lm = model.model.model.language_model +all_layers = list(lm.layers) +num_layers = len(all_layers) + +# --- calibration inputs (identical to the coverage / GPTQ calibration set) --- +tokenizer = AutoTokenizer.from_pretrained(MODEL) +rows = [json.loads(line) for line in open(f"{SNAP}/hle-mxfp8-0602-text2158/responses.jsonl")] +sample = random.Random(SEED).sample(rows, NUM_SAMPLES) +inputs = [] +for r in sample: + messages = [ + {"role": "user", "content": r["question"]}, + {"role": "assistant", "content": r["response"], "reasoning_content": r["reasoning"]}, + ] + text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False) + ids = tokenizer(text, add_special_tokens=False, return_tensors="pt")["input_ids"][:, :MAX_TOKENS] + inputs.append(ids) +total_tokens = sum(i.shape[1] for i in inputs) +log(f"calibration: {len(inputs)} samples, {total_tokens:,} tokens") + +# --- amax capture ----------------------------------------------------------- +amax: dict[str, float] = {} + + +def _update(key: str, tensor: torch.Tensor) -> None: + if not torch.is_tensor(tensor) or tensor.numel() == 0: + return + v = tensor.detach().abs().max().to(torch.float32).item() + if v > amax.get(key, 0.0): + amax[key] = v + + +def make_moe_input_hook(layer_idx: int): + # Pre-hook on the experts module: args[0] is the hidden state entering w1/w3. + def hook(module, args, kwargs): + if args and torch.is_tensor(args[0]): + _update(f"layer{layer_idx}.moe_input", args[0]) + return hook + + +def make_apply_gate_patch(layer_idx: int, original): + """Capture w2_input by wrapping the fused experts' `_apply_gate`. + + MiniMaxM3VLExperts stores experts as 3D nn.Parameters -- `gate_up_proj` and + `down_proj` -- and runs them with F.linear inside a Python loop, so there is + no down_proj submodule to hook. Its forward is: + + current = self._apply_gate(F.linear(hidden[idx], gate_up_proj[e])) + current = F.linear(current, down_proj[e]) * top_k_weights[...] + + so `_apply_gate`'s return value *is* the down_proj input -- the post-SwiGLU + activation we need. Wrapping it captures that exactly, with no + reimplementation of the SwiGLU to drift out of sync. It fires once per hit + expert, so the max is already reduced across the layer's experts (the + peer-max sync). + """ + + def patched(gate_up: torch.Tensor) -> torch.Tensor: + out = original(gate_up) + _update(f"layer{layer_idx}.w2_input", out) + return out + + return patched + + +def make_w2_input_hook(layer_idx: int): + # Pre-hook on a per-expert down_proj: its input IS the post-SwiGLU activation. + # Fires once per expert, so the max is already reduced across the layer. + def hook(module, args): + if args and torch.is_tensor(args[0]): + _update(f"layer{layer_idx}.w2_input", args[0]) + return hook + + +# The experts tree differs by loader, so support both rather than betting on one: +# * GPTQModel.load splits the checkpoint's fused 3D tensors into 128 per-expert +# nn.Linear modules (gate_proj / up_proj / down_proj) -- hook down_proj. +# * Stock transformers keeps MiniMaxM3VLExperts fused as 3D nn.Parameters with +# no submodules -- wrap `_apply_gate`, whose return value is the down_proj +# input. +hook_handles = [] +patched_experts = [] +moe_layers = [] +n_down = 0 +for li, layer in enumerate(all_layers): + experts = getattr(getattr(layer, "mlp", None), "experts", None) + if not isinstance(experts, nn.Module): + continue + moe_layers.append(li) + hook_handles.append( + experts.register_forward_pre_hook(make_moe_input_hook(li), with_kwargs=True) + ) + + downs = [m for name, m in experts.named_modules() + if name.endswith("down_proj") and isinstance(m, nn.Linear)] + if downs: + for m in downs: + hook_handles.append(m.register_forward_pre_hook(make_w2_input_hook(li))) + n_down += len(downs) + continue + + original_apply_gate = getattr(experts, "_apply_gate", None) + if original_apply_gate is None: + raise SystemExit( + f"layer {li}: {type(experts).__name__} exposes neither down_proj Linears nor " + "`_apply_gate`; w2_input cannot be captured. Adapt the capture to this " + "architecture rather than emitting a sidecar missing every down_proj scale." + ) + # Instance attribute shadows the bound method, so `self._apply_gate(x)` hits it. + experts._apply_gate = make_apply_gate_patch(li, original_apply_gate) + patched_experts.append(experts) + +log(f"hooked {len(moe_layers)} MoE layers ({moe_layers[:3]}...{moe_layers[-2:]}): " + f"moe_input via experts pre-hook; w2_input via {n_down} down_proj hooks " + f"+ {len(patched_experts)} _apply_gate wrappers") + +# --- windowed streaming forward (same structure as p1b_expert_coverage.py) --- +model.shell_module_materialize(lm.rotary_emb, DEVICE) +model.shell_module_materialize(lm.norm, DEVICE) + +start_layer = 0 +if os.path.exists(CKPT): + state = torch.load(CKPT, map_location="cpu", weights_only=False) + hidden = state["hidden"] + amax.update(state["amax"]) + start_layer = state["next_layer"] + log(f"resumed from checkpoint at layer {start_layer}") +else: + embed = model.shell_module_materialize(lm.embed_tokens, DEVICE) + hidden = [] + with torch.inference_mode(): + for ids in inputs: + hidden.append(embed(ids.to(DEVICE)).to("cpu")) + log("embedded all samples") + +orig_layers = lm.layers + +for w_start in range(start_layer, num_layers, WINDOW): + w_layers = all_layers[w_start : w_start + WINDOW] + lm.layers = orig_layers + for layer in w_layers: + model.shell_module_materialize(layer, DEVICE) + lm.layers = nn.ModuleList(w_layers) + + window_out = {} + + def tail_hook(module, args, output): + window_out["h"] = (output[0] if isinstance(output, tuple) else output).detach() + + tail = w_layers[-1].register_forward_hook(tail_hook) + + with torch.inference_mode(): + for si in range(len(hidden)): + h = hidden[si].to(DEVICE) + pos = torch.arange(h.shape[1], device=DEVICE).unsqueeze(0) + lm(inputs_embeds=h, position_ids=pos, use_cache=False) + hidden[si] = window_out["h"].to("cpu") + + tail.remove() + for layer in w_layers: + layer.to_empty(device="meta") + torch.cuda.empty_cache() + log(f"window done: layers {w_start}-{w_start + len(w_layers) - 1}") + + next_layer = w_start + len(w_layers) + if next_layer < num_layers: + tmp = CKPT + ".tmp" + torch.save({"hidden": hidden, "amax": amax, "next_layer": next_layer}, tmp) + os.replace(tmp, CKPT) + +lm.layers = orig_layers +for h in hook_handles: + h.remove() +for experts in patched_experts: + # Drop the instance attribute so the class's bound method is live again. + del experts._apply_gate + +# --- emit ------------------------------------------------------------------- +missing = [ + f"layer{li}.{site}" + for li in moe_layers + for site in ("moe_input", "w2_input") + if f"layer{li}.{site}" not in amax +] +if missing: + raise SystemExit(f"missing amax entries, refusing to write a partial sidecar: {missing[:8]}") + +payload = { + "amax": {k: amax[k] for k in sorted(amax, key=lambda s: (int(s.split(".")[0][5:]), s))}, + "meta": { + "model": MODEL, + "samples": NUM_SAMPLES, + "max_tokens": MAX_TOKENS, + "seed": SEED, + "total_tokens": total_tokens, + "method": "plain max over |x|, reduced across experts (peer-max)", + "sites": {"moe_input": "gate_proj/up_proj input", "w2_input": "down_proj input"}, + }, +} +os.makedirs(os.path.dirname(OUT_JSON) or ".", exist_ok=True) +with open(OUT_JSON, "w") as f: + json.dump(payload, f, indent=1) +log(f"wrote {OUT_JSON} ({len(payload['amax'])} entries)") + +if os.path.exists(CKPT): + os.remove(CKPT) + +vals = list(payload["amax"].values()) +log(f"amax range: min={min(vals):.4g} max={max(vals):.4g}") +log(f"implied global_scale range: {min(vals) / 6 / 448:.4g} .. {max(vals) / 6 / 448:.4g}") +for li in moe_layers[:3] + moe_layers[-2:]: + log(f" layer{li}: moe_input={amax[f'layer{li}.moe_input']:.4g} " + f"w2_input={amax[f'layer{li}.w2_input']:.4g}") +log("DONE") diff --git a/scripts/minimax_m3_nvfp4/p03_load_dryrun.py b/scripts/minimax_m3_nvfp4/p03_load_dryrun.py new file mode 100644 index 000000000..b30ba5e52 --- /dev/null +++ b/scripts/minimax_m3_nvfp4/p03_load_dryrun.py @@ -0,0 +1,116 @@ +"""Phase 0.3: GPTQModel.load dry run for Minimax-M3-0602 NVFP4 plan. + +Verifies: (a) load routes to the quantize path and MXFP8 decodes to bf16, +(b) runtime module naming (block_sparse_moe/w1 vs mlp/gate_proj), (c) the +experts-only dynamic rules select the expected 16,512 modules. + +Run: .venv/bin/python scripts/minimax_m3_nvfp4/p03_load_dryrun.py +See: docs/minimax_m3_nvfp4_quantization_plan.md +""" + +import json + +import torch +import torch.nn as nn + +MODEL = "/data/sgambhira/models/Minimax-M3-0602" +SKIP_LAYERS = "22|23|24|25|26|27|28|32|33|34|35|36|37|38" + +from gptqmodel import BACKEND, GPTQModel, QuantizeConfig # noqa: E402 + +qcfg = QuantizeConfig( + bits=4, + group_size=16, + format="nvfp4", + dynamic={ + r"-:.*self_attn\..*": {}, + r"-:.*shared_experts\..*": {}, + r"-:.*layers\.(0|1|2)\.mlp\..*": {}, + rf"-:.*layers\.({SKIP_LAYERS})\..*": {}, + }, + moe_vram_strategy="balanced", +) +print("P03 qcfg class:", type(qcfg).__name__, "format:", qcfg.format, "device:", qcfg.device, flush=True) + +# Non-quantized models always load CPU-side (lazy); GPUs are used per-layer +# during quantization. Do not pass device= when qcfg.device is set/auto. +model = GPTQModel.load(MODEL, quantize_config=qcfg, backend=BACKEND.TORCH) +print("P03 loaded model class:", type(model.model).__name__, flush=True) + +# (b) runtime module names for layer 3 +names_l3 = [ + (n, type(m).__name__) + for n, m in model.model.named_modules() + if ".layers.3." in n and isinstance(m, nn.Linear) +] +print("P03 layer-3 linear count:", len(names_l3), flush=True) +for n, t in names_l3[:12]: + print("P03 l3 module:", n, t, flush=True) +expert_markers = { + "mlp.experts": sum(1 for n, _ in names_l3 if "mlp.experts." in n), + "block_sparse_moe.experts": sum(1 for n, _ in names_l3 if "block_sparse_moe.experts." in n), +} +print("P03 expert naming counts (layer 3):", expert_markers, flush=True) + +# (a) dequant spot check on one expert projection +expert_name = next((n for n, _ in names_l3 if ".experts.0." in n), None) +print("P03 spot-check module:", expert_name, flush=True) +mod = dict(model.model.named_modules())[expert_name] +w = mod.weight +print("P03 module weight dtype/shape/device:", w.dtype, tuple(w.shape), w.device, flush=True) + +if w.device.type != "meta": + from safetensors import safe_open + + index = json.load(open(f"{MODEL}/model.safetensors.index.json"))["weight_map"] + cands = [ + k + for k in index + if ".layers.3." in k + and ".experts.0." in k + and k.endswith(".weight") + and "scale" not in k + and "shared" not in k + ] + print("P03 checkpoint candidates:", cands[:3], flush=True) + matched = False + for ck in cands: + with safe_open(f"{MODEL}/{index[ck]}", framework="pt", device="cpu") as f: + fp8 = f.get_tensor(ck) + scale_u8 = f.get_tensor(ck.replace(".weight", ".weight_scale_inv")) + if tuple(fp8.shape) != tuple(w.shape): + continue + scale = torch.pow(2.0, scale_u8.to(torch.float32) - 127.0) + manual = fp8.to(torch.float32) * scale.repeat_interleave(32, dim=1) + diff = (w.detach().to(torch.float32).cpu() - manual).abs().max().item() + print(f"P03 dequant max abs diff vs manual mxfp8 ({ck}):", diff, flush=True) + matched = True + break + if not matched: + print("P03 no shape-matching checkpoint candidate found", flush=True) + +# (c) dynamic selection counts over all language-model layers +included, excluded = [], [] +for n, m in model.model.named_modules(): + if not isinstance(m, nn.Linear) or ".layers." not in n: + continue + if "visual" in n or "vision" in n: + continue + if qcfg.dynamic_get(layer_name=n) is False: + excluded.append(n) + else: + included.append(n) +experts_included = [n for n in included if ".experts." in n and "shared" not in n] +non_expert_included = [n for n in included if n not in set(experts_included)] +print("P03 total linears:", len(included) + len(excluded), flush=True) +print("P03 included expert linears:", len(experts_included), "(expect 16512)", flush=True) +print("P03 included NON-expert linears:", len(non_expert_included), flush=True) +for n in non_expert_included[:10]: + print("P03 non-expert included (should be empty or router-only):", n, flush=True) +skip_hits = [ + n + for n in included + if any(f".layers.{x}." in n for x in "22 23 24 25 26 27 28 32 33 34 35 36 37 38".split()) +] +print("P03 included modules in skip layers (expect 0):", len(skip_hits), flush=True) +print("P03 DONE", flush=True) diff --git a/scripts/minimax_m3_nvfp4/p1_pilot_layer3.py b/scripts/minimax_m3_nvfp4/p1_pilot_layer3.py new file mode 100644 index 000000000..1087e9abf --- /dev/null +++ b/scripts/minimax_m3_nvfp4/p1_pilot_layer3.py @@ -0,0 +1,123 @@ +"""Phase 1: single-layer (layer 3) GPTQ->NVFP4 pilot for Minimax-M3-0602. + +Quantizes ONLY layer-3 routed experts (384 modules) with the full 64-trajectory +HLE calibration set. Gates checked here before the full run: + - per-expert calibration coverage / RTN-fallback rate under native routing + - loss sanity (also validates the MXFP8->bf16 decode numerically) + - saved tensor layout (uint8 packed weight + float8_e4m3fn weight_scale) + - what non-quantized modules are written as (plan open-decision #1) + +Run: .venv/bin/python scripts/minimax_m3_nvfp4/p1_pilot_layer3.py +See: docs/minimax_m3_nvfp4_quantization_plan.md +""" + +import json +import random + +import torch +from transformers import AutoTokenizer + +MODEL = "/data/sgambhira/models/Minimax-M3-0602" +OUT = "/data/sgambhira/models/Minimax-M3-0602-NVFP4-pilot-l3" +SNAP = ( + "/data/sgambhira/hf-home/hub/datasets--togethercomputer--m3-quant-eval-runs/" + "snapshots/27f2a493e10922e878840d27924aabd0bbf5d745" +) +SEED = 42 +NUM_SAMPLES = 64 +# 80GB H100 headroom (user-confirmed): keep the FIRST 8k tokens per sample. The sdpa +# sparse-attention path materializes dense per-head masks (64 x n^2 x 2B) plus a bool +# intermediate — ~9GB + ~4GB at 8k vs 32GB + 17GB at 16k, which OOMed. +MAX_TOKENS_PER_SAMPLE = 8192 + +from gptqmodel import BACKEND, GPTQModel, QuantizeConfig # noqa: E402 + +qcfg = QuantizeConfig( + bits=4, + group_size=16, + format="nvfp4", + dynamic={ + r"-:.*self_attn\..*": {}, + r"-:.*shared_experts\..*": {}, + r"-:.*layers\.(0|1|2)\.mlp\..*": {}, + # pilot: skip every layer except 3 + r"-:.*layers\.([4-9]|[1-5][0-9])\..*": {}, + }, + moe_vram_strategy="balanced", + # /tmp is on the ~full 124G root disk; module offload must go to /data (15T free). + offload_to_disk_path="/data/sgambhira/gptqmodel-offload/pilot-l3", +) +print("P1 qcfg:", type(qcfg).__name__, qcfg.format, flush=True) + +# --- calibration: 64 seed-42 HLE trajectories, pre-tokenized (matches P0.2) --- +tokenizer = AutoTokenizer.from_pretrained(MODEL) +rows = [json.loads(l) for l in open(f"{SNAP}/hle-mxfp8-0602-text2158/responses.jsonl")] +sample = random.Random(SEED).sample(rows, NUM_SAMPLES) + +calibration = [] +total_tokens = 0 +for r in sample: + messages = [ + {"role": "user", "content": r["question"]}, + {"role": "assistant", "content": r["response"], "reasoning_content": r["reasoning"]}, + ] + text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False) + enc = tokenizer(text, add_special_tokens=False, return_tensors="pt") + input_ids = enc["input_ids"][:, :MAX_TOKENS_PER_SAMPLE] + attention_mask = enc["attention_mask"][:, :MAX_TOKENS_PER_SAMPLE] + calibration.append({"input_ids": input_ids, "attention_mask": attention_mask}) + total_tokens += input_ids.shape[1] +truncated = sum(1 for c in calibration if c["input_ids"].shape[1] == MAX_TOKENS_PER_SAMPLE) +print( + f"P1 calibration: {len(calibration)} samples, {total_tokens:,} tokens " + f"({truncated} truncated to first {MAX_TOKENS_PER_SAMPLE})", + flush=True, +) + +model = GPTQModel.load(MODEL, quantize_config=qcfg, backend=BACKEND.TORCH) +print("P1 model loaded", flush=True) + +model.quantize(calibration, batch_size=1, backend=BACKEND.TORCH) +print("P1 quantize complete", flush=True) + +model.save(OUT) +print("P1 saved to", OUT, flush=True) + +# --- post-checks on the saved checkpoint --- +from safetensors import safe_open # noqa: E402 + +index = json.load(open(f"{OUT}/model.safetensors.index.json"))["weight_map"] + +def _tensor(name): + with safe_open(f"{OUT}/{index[name]}", framework="pt", device="cpu") as f: + return f.get_tensor(name) + +l3_keys = sorted(k for k in index if ".layers.3.mlp.experts.0." in k) +print("P1 saved layer-3 expert-0 tensors:", l3_keys, flush=True) +for k in l3_keys: + t = _tensor(k) + print(f"P1 saved {k}: {t.dtype} {tuple(t.shape)}", flush=True) + +# non-quantized passthrough: layer-4 expert + layer-3 attention +for probe in [ + next((k for k in index if ".layers.4.mlp.experts.0." in k and k.endswith(".weight")), None), + next((k for k in index if ".layers.3.self_attn.q_proj." in k and k.endswith(".weight")), None), +]: + if probe: + t = _tensor(probe) + print(f"P1 passthrough {probe}: {t.dtype} {tuple(t.shape)}", flush=True) + +# dequant round-trip vs torchao for one quantized expert +wk = next((k for k in index if ".layers.3.mlp.experts.0.down_proj.weight" == k.split("model.", 1)[-1] or k.endswith("layers.3.mlp.experts.0.down_proj.weight")), None) +sk = wk.replace(".weight", ".weight_scale") if wk else None +if wk and sk in index: + from torchao.prototype.mx_formats.nvfp4_tensor import NVFP4Tensor + + w, s = _tensor(wk), _tensor(sk) + nv = NVFP4Tensor(w, s, block_size=16, orig_dtype=torch.bfloat16) + deq = nv.dequantize(torch.bfloat16) if not hasattr(nv, "to_dtype") else nv.to_dtype(torch.bfloat16) + print(f"P1 nvfp4 round-trip ok: {wk} -> dequant {tuple(deq.shape)} finite={torch.isfinite(deq).all().item()}", flush=True) +else: + print("P1 WARNING: could not locate quantized down_proj weight/scale pair", flush=True) + +print("P1 DONE", flush=True) diff --git a/scripts/minimax_m3_nvfp4/p1b_expert_coverage.py b/scripts/minimax_m3_nvfp4/p1b_expert_coverage.py new file mode 100644 index 000000000..9c8a01c78 --- /dev/null +++ b/scripts/minimax_m3_nvfp4/p1b_expert_coverage.py @@ -0,0 +1,208 @@ +"""Phase 1b: per-expert routing coverage across all MoE layers of Minimax-M3-0602. + +Streams the model layer-window by layer-window through one GPU (materialize -> +forward all samples -> free), capturing each MoE layer's routed expert +assignments (top_k_index) at the experts-module boundary. Native top-4 routing, +64 seed-42 HLE samples truncated to their first 8k tokens — identical to the +Phase 1 pilot calibration. + +Output: JSON matrix [57 layers x 128 experts] of routed token counts plus a +per-layer concentration summary. +""" + +import json +import os +import random +import time + +import torch +import torch.nn as nn +from transformers import AutoTokenizer + +# /scratch/tonyzhang/models/Minimax-M3-0602 now returns EIO on config.json and +# every safetensors shard (btrfs read errors); this copy is intact. +MODEL = "/data/huggingface/Minimax-M3-0602" +SNAP = ( + "/data/sgambhira/hf-home/hub/datasets--togethercomputer--m3-quant-eval-runs/" + "snapshots/27f2a493e10922e878840d27924aabd0bbf5d745" +) +OUT_JSON = "/data/sgambhira/GPTQModel/scripts/minimax_m3_nvfp4/coverage_layer_expert.json" +SEED = 42 +NUM_SAMPLES = 64 +MAX_TOKENS = 8192 +# A recurring co-tenant pipeline used to claim GPUs 0-3 for ~150GB in bursts, +# which forced WINDOW=1 (~23GB peak). With the GPU idle, WINDOW=5 amortizes the +# per-window materialize/free cost over 5 layers instead of 1. +WINDOW = 5 +NUM_EXPERTS = 128 +DEVICE = torch.device("cuda:1") +# Co-tenant bursts OOM-killed two previous runs; checkpoint the CPU-side hidden +# states + counts so a restart resumes instead of redoing completed layers. +CKPT = "/data/sgambhira/gptqmodel-offload/coverage_resume.pt" +CKPT_EVERY = 1 # windows between checkpoint saves +OOM_RETRIES = 20 +OOM_WAIT_S = 90 + + +def _retry_oom(fn, what, max_tries=OOM_RETRIES, wait_s=OOM_WAIT_S): + for i in range(max_tries): + try: + return fn() + except torch.OutOfMemoryError: + torch.cuda.empty_cache() + print(f"COV OOM during {what} (attempt {i + 1}/{max_tries}); waiting {wait_s}s", flush=True) + time.sleep(wait_s) + raise RuntimeError(f"COV {what} failed after {max_tries} OOM retries") + +from gptqmodel import BACKEND, GPTQModel, QuantizeConfig # noqa: E402 + +qcfg = QuantizeConfig( + bits=4, + group_size=16, + format="nvfp4", + offload_to_disk_path="/data/sgambhira/gptqmodel-offload/coverage", +) +model = GPTQModel.load(MODEL, quantize_config=qcfg, backend=BACKEND.TORCH) +print("COV model loaded", flush=True) + +lm = model.model.model.language_model +num_layers = len(lm.layers) +all_layers = list(lm.layers) + +# --- calibration inputs (identical to pilot) --- +tokenizer = AutoTokenizer.from_pretrained(MODEL) +rows = [json.loads(l) for l in open(f"{SNAP}/hle-mxfp8-0602-text2158/responses.jsonl")] +sample = random.Random(SEED).sample(rows, NUM_SAMPLES) +inputs = [] +for r in sample: + messages = [ + {"role": "user", "content": r["question"]}, + {"role": "assistant", "content": r["response"], "reasoning_content": r["reasoning"]}, + ] + text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False) + ids = tokenizer(text, add_special_tokens=False, return_tensors="pt")["input_ids"][:, :MAX_TOKENS] + inputs.append(ids) +total_tokens = sum(i.shape[1] for i in inputs) +print(f"COV calibration: {len(inputs)} samples, {total_tokens:,} tokens", flush=True) + +# --- routed-assignment capture at the experts-module boundary --- +counts = {} # layer_idx -> LongTensor[NUM_EXPERTS] + +def make_experts_hook(layer_idx): + def pre_hook(module, args, kwargs): + idx = None + if len(args) >= 2 and torch.is_tensor(args[1]): + idx = args[1] + elif "top_k_index" in kwargs: + idx = kwargs["top_k_index"] + if idx is None: + return + c = torch.bincount(idx.reshape(-1).to("cpu", torch.int64), minlength=NUM_EXPERTS) + counts[layer_idx] = counts.get(layer_idx, torch.zeros(NUM_EXPERTS, dtype=torch.long)) + c + return pre_hook + +hook_handles = [] +moe_layers = [] +for li, layer in enumerate(all_layers): + experts = getattr(getattr(layer, "mlp", None), "experts", None) + if isinstance(experts, nn.Module): + moe_layers.append(li) + hook_handles.append(experts.register_forward_pre_hook(make_experts_hook(li), with_kwargs=True)) +print(f"COV hooked {len(moe_layers)} MoE layers: {moe_layers[:4]}...{moe_layers[-2:]}", flush=True) + +# --- windowed streaming forward --- +# hidden states enter each window via inputs_embeds; rotary/causal-mask are +# position-derived so per-window model forwards are exact. +model.shell_module_materialize(lm.rotary_emb, DEVICE) +model.shell_module_materialize(lm.norm, DEVICE) + +start_layer = 0 +if os.path.exists(CKPT): + state = torch.load(CKPT, map_location="cpu") + hidden = state["hidden"] + counts.update(state["counts"]) + start_layer = state["next_layer"] + print(f"COV resumed from checkpoint: continuing at layer {start_layer}", flush=True) +else: + embed = model.shell_module_materialize(lm.embed_tokens, DEVICE) + hidden = [] + with torch.inference_mode(): + for ids in inputs: + hidden.append(embed(ids.to(DEVICE)).to("cpu")) + print("COV embedded all samples", flush=True) + +orig_layers = lm.layers + +for w_start in range(start_layer, num_layers, WINDOW): + w_layers = all_layers[w_start : w_start + WINDOW] + # materialization resolves module paths against the model tree, so the full + # layer list must be attached while materializing; truncate only for forward. + lm.layers = orig_layers + for layer in w_layers: + model.shell_module_materialize(layer, DEVICE) + lm.layers = nn.ModuleList(w_layers) + + # capture the last window layer's output as the next window's input + window_out = {} + def tail_hook(module, args, output): + window_out["h"] = (output[0] if isinstance(output, tuple) else output).detach() + tail = w_layers[-1].register_forward_hook(tail_hook) + + with torch.inference_mode(): + for si in range(len(hidden)): + h = hidden[si].to(DEVICE) + pos = torch.arange(h.shape[1], device=DEVICE).unsqueeze(0) + lm(inputs_embeds=h, position_ids=pos, use_cache=False) + hidden[si] = window_out["h"].to("cpu") + + tail.remove() + for layer in w_layers: + layer.to_empty(device="meta") + torch.cuda.empty_cache() + print(f"COV window done: layers {w_start}-{w_start + len(w_layers) - 1}", flush=True) + + next_layer = w_start + len(w_layers) + if ((w_start - start_layer) // WINDOW + 1) % CKPT_EVERY == 0 and next_layer < num_layers: + tmp = CKPT + ".tmp" + torch.save({"hidden": hidden, "counts": counts, "next_layer": next_layer}, tmp) + os.replace(tmp, CKPT) + print(f"COV checkpoint saved through layer {next_layer - 1}", flush=True) + +lm.layers = orig_layers + +for h in hook_handles: + h.remove() + +# --- summarize --- +matrix = {str(li): counts.get(li, torch.zeros(NUM_EXPERTS, dtype=torch.long)).tolist() for li in moe_layers} +payload = { + "model": MODEL, + "samples": NUM_SAMPLES, + "max_tokens": MAX_TOKENS, + "seed": SEED, + "total_tokens": total_tokens, + "top_k": 4, + "counts": matrix, +} +with open(OUT_JSON, "w") as f: + json.dump(payload, f) +print(f"COV wrote {OUT_JSON}", flush=True) + +if os.path.exists(CKPT): + os.remove(CKPT) # a finished run must not seed the next fresh run + +threshold = 0.005 * total_tokens +print("COV layer | experts>0 | experts>=fallback_thr | top1_share | top8_share", flush=True) +for li in moe_layers: + c = torch.tensor(matrix[str(li)], dtype=torch.float64) + tot = c.sum().item() + if tot == 0: + print(f"COV {li:>3} | ZERO CAPTURE", flush=True) + continue + srt = c.sort(descending=True).values + print( + f"COV {li:>3} | {(c > 0).sum().item():>3} | {(c >= threshold).sum().item():>3} | " + f"{srt[0].item() / tot:5.1%} | {srt[:8].sum().item() / tot:5.1%}", + flush=True, + ) +print("COV DONE", flush=True) diff --git a/scripts/minimax_m3_nvfp4/quantize_w4a4.py b/scripts/minimax_m3_nvfp4/quantize_w4a4.py new file mode 100644 index 000000000..19a88a802 --- /dev/null +++ b/scripts/minimax_m3_nvfp4/quantize_w4a4.py @@ -0,0 +1,208 @@ +"""GPTQ -> NVFP4 quantization of Minimax-M3-0602, calibrated for w4a4. + +Quantizes the routed experts of all 57 MoE layers to NVFP4. Everything else -- +attention, index projections, shared experts, the 3 dense MLP layers, routers, +norms, embeddings, lm_head, vision tower -- is left untouched and passes through +in the source checkpoint's precision, matching the format map in +tore-quant/recipes/minimax-m3-nvfp4/README.md. + +When an activation amax sidecar is supplied, the GPTQ Hessian is accumulated on +Q(X) rather than X, so the weights are solved for the activations a w4a4 kernel +actually consumes (docs/w4a4_hessian.md). Without one it degrades to a +weight-only calibration and says so. + +Driven by run.sh; all knobs are environment variables. +""" + +import json +import os +import random +import sys +import time + +import torch +from transformers import AutoTokenizer + +MODEL = os.environ.get("MODEL", "/data/huggingface/Minimax-M3-0602") +OUT = os.environ.get("OUT", "/data/huggingface/Minimax-M3-0602-NVFP4-GPTQ-w4a4") +SNAP = os.environ.get( + "SNAP", + "/data/sgambhira/hf-home/hub/datasets--togethercomputer--m3-quant-eval-runs/" + "snapshots/27f2a493e10922e878840d27924aabd0bbf5d745", +) +OFFLOAD = os.environ.get("OFFLOAD", "/data/sgambhira/gptqmodel-offload/w4a4") +ACT_AMAX = os.environ.get("ACT_AMAX", "").strip() +SEED = int(os.environ.get("SEED", "42")) +NUM_SAMPLES = int(os.environ.get("NUM_SAMPLES", "64")) +MAX_TOKENS = int(os.environ.get("MAX_TOKENS", "8192")) +BATCH_SIZE = int(os.environ.get("BATCH_SIZE", "1")) +# Fallback threshold, in TOKENS seen by a module (not samples). +# +# The library default "0.5%" is a fixed quantile of the routing distribution and +# is scale-invariant -- an expert is RTN'd iff its routing share < pct/(100*top_k), +# with total tokens cancelling -- so more calibration data does not reduce it. +# Worse, at 2,355 tokens it sits below the input dim, so ~22% of the modules it +# lets through are GPTQ-solved on a singular Hessian held up only by damping. +# +# H = XᵀX is (columns x columns) and rank(XᵀX) = rank(X) <= min(n_tokens, columns), +# so a module needs at least `columns` (= in_features) tokens to be full rank. +# Default here is per-projection thresholds at exactly that bound. +FALLBACK_THRESHOLD = os.environ.get("FALLBACK_THRESHOLD", "").strip() +# Multiplier on in_features. 1.0 = bare full rank (well-defined but ill-conditioned +# at exactly n == columns); >1 buys conditioning margin at the cost of more RTN. +RANK_MARGIN = float(os.environ.get("RANK_MARGIN", "1.0")) +LAYERS = os.environ.get("LAYERS", "").strip() # e.g. "3" or "3-5"; empty = all + +from gptqmodel import BACKEND, GPTQModel, QuantizeConfig # noqa: E402 +from gptqmodel.quantization.config import FORMAT, Fallback, FallbackStrategy # noqa: E402 + +t0 = time.time() + + +def log(msg: str) -> None: + print(f"[w4a4 +{time.time() - t0:7.1f}s] {msg}", flush=True) + + +# --- what to quantize ------------------------------------------------------- +# Only routed experts. `-:` prefixes a skip rule. +dynamic = { + r"-:.*self_attn\..*": {}, + r"-:.*shared_experts\..*": {}, + r"-:.*\.layers\.(0|1|2)\.mlp\..*": {}, + r"-:.*mlp\.gate\..*": {}, # routers stay F32 + r"-:.*(embed_tokens|lm_head).*": {}, + r"-:.*(vision|visual|mm_projector|patch_merge).*": {}, +} +if LAYERS: + lo, _, hi = LAYERS.partition("-") + keep = set(range(int(lo), int(hi or lo) + 1)) + skip = [str(i) for i in range(60) if i not in keep] + dynamic[rf"-:.*\.layers\.({'|'.join(skip)})\..*"] = {} + log(f"LAYERS={LAYERS}: quantizing only {sorted(keep)}") + +# --- RTN fallback threshold, keyed to each projection's Hessian rank --------- +# All three projections of one expert see the same routed tokens, but they have +# different in_features, so a single global threshold either admits singular +# solves (too low) or RTNs down_proj modules that were perfectly well-posed +# (too high). `dynamic` supports a per-module `fallback` override, so key it to +# the actual dimension: gate/up_proj are (3072, 6144) and down_proj (6144, 3072). +IN_FEATURES = {"gate_proj": 6144, "up_proj": 6144, "down_proj": 3072} + +if FALLBACK_THRESHOLD: + default_fallback = Fallback(strategy=FallbackStrategy.RTN, threshold=FALLBACK_THRESHOLD) + log(f"fallback: global threshold {FALLBACK_THRESHOLD} (overriding rank-based default)") +else: + # Anything not matched below keeps the strictest bound. + default_fallback = Fallback(strategy=FallbackStrategy.RTN, + threshold=int(max(IN_FEATURES.values()) * RANK_MARGIN)) + for proj, cols in IN_FEATURES.items(): + thr = int(cols * RANK_MARGIN) + dynamic[rf".*mlp\.experts\.\d+\.{proj}$"] = { + "fallback": {"strategy": "rtn", "threshold": thr} + } + log("fallback: per-projection rank thresholds " + + ", ".join(f"{p}={int(c * RANK_MARGIN)}" for p, c in IN_FEATURES.items()) + + f" (RANK_MARGIN={RANK_MARGIN}) -- every GPTQ solve gets a full-rank Hessian") + +# --- activation quantization (w4a4) ---------------------------------------- +act_kwargs = {} +if ACT_AMAX: + if not os.path.isfile(ACT_AMAX): + sys.exit(f"ACT_AMAX sidecar not found: {ACT_AMAX}") + act_kwargs = dict( + act_format=FORMAT.NVFP4, + act_group_size=16, + act_amax_path=ACT_AMAX, + # w1/w3 (gate_proj/up_proj) share the MoE input, so they share one amax + # entry; w2 (down_proj) sees the post-SwiGLU activation and gets its own. + act_amax_key_rules=[ + (r".*\.layers\.(\d+)\.mlp\.experts\.\d+\.(?:gate_proj|up_proj)$", r"layer\1.moe_input"), + (r".*\.layers\.(\d+)\.mlp\.experts\.\d+\.down_proj$", r"layer\1.w2_input"), + ], + ) + log(f"w4a4: Hessian will be accumulated on Q(X), amax sidecar {ACT_AMAX}") +else: + log("NO ACT_AMAX: weight-only calibration (Hessian on clean X). NOT w4a4-optimal.") + +qcfg = QuantizeConfig( + bits=4, + group_size=16, + format="nvfp4", + sym=True, + desc_act=False, + dynamic=dynamic, + fallback=default_fallback, + moe_vram_strategy="balanced", + offload_to_disk_path=OFFLOAD, + **act_kwargs, +) +log(f"qcfg format={qcfg.format} bits={qcfg.bits} group_size={qcfg.group_size}") + +# --- calibration: 64 seed-42 HLE trajectories, first 8k tokens -------------- +tokenizer = AutoTokenizer.from_pretrained(MODEL) +rows = [json.loads(line) for line in open(f"{SNAP}/hle-mxfp8-0602-text2158/responses.jsonl")] +sample = random.Random(SEED).sample(rows, NUM_SAMPLES) + +calibration = [] +total_tokens = 0 +for r in sample: + messages = [ + {"role": "user", "content": r["question"]}, + {"role": "assistant", "content": r["response"], "reasoning_content": r["reasoning"]}, + ] + text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False) + enc = tokenizer(text, add_special_tokens=False, return_tensors="pt") + calibration.append({ + "input_ids": enc["input_ids"][:, :MAX_TOKENS], + "attention_mask": enc["attention_mask"][:, :MAX_TOKENS], + }) + total_tokens += calibration[-1]["input_ids"].shape[1] + +log(f"calibration: {len(calibration)} samples, {total_tokens:,} tokens") + +# --- run -------------------------------------------------------------------- +model = GPTQModel.load(MODEL, quantize_config=qcfg, backend=BACKEND.TORCH) +log("model loaded") + +model.quantize(calibration, batch_size=BATCH_SIZE, backend=BACKEND.TORCH) +log("quantize complete") + +model.save(OUT) +log(f"saved -> {OUT}") + +# --- post-check: the export must actually be NVFP4 -------------------------- +# A silently-passed-through expert is the failure mode that looks like success, +# so assert the layout rather than trusting the run log. +from safetensors import safe_open # noqa: E402 + +index = json.load(open(f"{OUT}/model.safetensors.index.json"))["weight_map"] +# Probe INSIDE the quantized layer set: with LAYERS set, most layers are +# passthrough and legitimately lack NVFP4 scales, so a layer-blind probe fails +# on a perfectly good checkpoint (it did: it hit passthrough layer 10 first). +probe_layer = LAYERS.partition("-")[0] if LAYERS else "3" +probe = next( + (k for k in index + if k.endswith(f".layers.{probe_layer}.mlp.experts.0.down_proj.weight")), + None, +) +if probe is None: + sys.exit(f"post-check FAILED: no routed-expert weight for layer {probe_layer} in the saved index") + +with safe_open(f"{OUT}/{index[probe]}", framework="pt", device="cpu") as f: + w = f.get_tensor(probe) +scale_key = probe.replace(".weight", ".weight_scale") +if scale_key not in index: + sys.exit(f"post-check FAILED: {probe} has no weight_scale -- not NVFP4") +scale2_key = probe.replace(".weight", ".weight_scale_2") +if scale2_key not in index: + sys.exit(f"post-check FAILED: {probe} has no weight_scale_2 -- one-level export") +with safe_open(f"{OUT}/{index[scale_key]}", framework="pt", device="cpu") as f: + s = f.get_tensor(scale_key) + +log(f"post-check {probe}: weight {w.dtype} {tuple(w.shape)} | scale {s.dtype} {tuple(s.shape)}") +assert w.dtype == torch.uint8, f"expected packed uint8 weight, got {w.dtype}" +assert s.dtype == torch.float8_e4m3fn, f"expected float8_e4m3fn scale, got {s.dtype}" + +n_scales = sum(1 for k in index if k.endswith(".weight_scale")) +log(f"post-check OK: {n_scales:,} NVFP4 weight_scale tensors in the checkpoint") +log("DONE") diff --git a/scripts/minimax_m3_nvfp4/run.sh b/scripts/minimax_m3_nvfp4/run.sh new file mode 100755 index 000000000..63c2d576d --- /dev/null +++ b/scripts/minimax_m3_nvfp4/run.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +set -euo pipefail + +# GPTQ -> NVFP4 quantization of Minimax-M3-0602, calibrated for w4a4. +# +# Quantizes the routed experts of all 57 MoE layers (57 x 128 x 3 = 21,888 +# modules). Attention, shared experts, dense MLP 0-2, routers, norms, embeddings +# and the vision tower pass through untouched -- the format map from +# tore-quant/recipes/minimax-m3-nvfp4. +# +# bash scripts/minimax_m3_nvfp4/run.sh # preflight + full run +# PREFLIGHT_ONLY=1 bash scripts/minimax_m3_nvfp4/run.sh # checks only +# LAYERS=3 bash scripts/minimax_m3_nvfp4/run.sh # single-layer smoke test +# ACT_AMAX=/path/amax_per_layer.json bash .../run.sh # true w4a4 calibration +# +# READ BEFORE RUNNING -- two things this does NOT do: +# +# 1. Without ACT_AMAX this is a *weight-only* calibration. The Hessian is built +# on clean X, so the weights are solved for activations a w4a4 kernel will +# not feed them. See docs/w4a4_hessian.md. Produce a sidecar with +# tore-quant `ptq.py --emit-amax-sidecar`. +# +# 2. The export writes `weight` (packed uint8) + `weight_scale` (float8_e4m3fn) +# + `weight_scale_2` (fp32 per-tensor). It does NOT write `input_scale`; +# a serving stack expecting the full ModelOpt layout needs that added as a +# post-step (derivable from the ACT_AMAX sidecar: amax/6/448 per input site). +# ACT_AMAX changes what the weights are optimized for, not the tensor set. + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="$(cd "${HERE}/../.." && pwd)" +PY="${PYTHON_BIN:-${REPO}/.venv/bin/python}" + +# /scratch/tonyzhang/models/Minimax-M3-0602 returns EIO on config.json and every +# safetensors shard (btrfs read errors). This copy is intact. +MODEL="${MODEL:-/data/huggingface/Minimax-M3-0602}" +OUT="${OUT:-/data/huggingface/Minimax-M3-0602-NVFP4-GPTQ-w4a4}" +SNAP="${SNAP:-/data/sgambhira/hf-home/hub/datasets--togethercomputer--m3-quant-eval-runs/snapshots/27f2a493e10922e878840d27924aabd0bbf5d745}" +OFFLOAD="${OFFLOAD:-/data/sgambhira/gptqmodel-offload/w4a4}" +ACT_AMAX="${ACT_AMAX:-}" +LAYERS="${LAYERS:-}" +NUM_SAMPLES="${NUM_SAMPLES:-64}" +MAX_TOKENS="${MAX_TOKENS:-8192}" +# Empty = per-projection rank thresholds (gate/up 6144, down 3072). Set to a +# number or "N%" to override globally. +FALLBACK_THRESHOLD="${FALLBACK_THRESHOLD:-}" +RANK_MARGIN="${RANK_MARGIN:-1.0}" +LOG="${LOG:-${HERE}/w4a4_run.log}" +PREFLIGHT_ONLY="${PREFLIGHT_ONLY:-0}" + +# Weights alone are ~211 GiB (57 x 3.80 GiB NVFP4 experts) plus ~16.5 GiB of +# passthrough; leave room for the offload dir on the same filesystem. +MIN_FREE_GIB="${MIN_FREE_GIB:-600}" + +say() { printf '\n\033[1m== %s\033[0m\n' "$*"; } +die() { printf '\033[31mFAIL: %s\033[0m\n' "$*" >&2; exit 1; } + +say "Preflight" + +[[ -x "${PY}" ]] || die "python not executable: ${PY} (set PYTHON_BIN)" + +# Read CONTENT, not just stat: /scratch's copy passes `ls` and fails on read. +[[ -d "${MODEL}" ]] || die "model dir missing: ${MODEL}" +head -c 1 "${MODEL}/config.json" >/dev/null 2>&1 || die "cannot READ ${MODEL}/config.json (I/O error?)" +shards=$(ls "${MODEL}"/*.safetensors 2>/dev/null | wc -l) +[[ "${shards}" -gt 0 ]] || die "no safetensors shards in ${MODEL}" +first_shard=$(ls "${MODEL}"/*.safetensors | head -1) +dd if="${first_shard}" of=/dev/null bs=1M count=1 status=none 2>/dev/null \ + || die "cannot READ ${first_shard} -- checkpoint is corrupt" +echo " model ${MODEL} (${shards} shards, readable)" + +[[ -f "${SNAP}/hle-mxfp8-0602-text2158/responses.jsonl" ]] \ + || die "calibration responses.jsonl missing under ${SNAP}" +echo " calib data ${SNAP}" + +if [[ -n "${ACT_AMAX}" ]]; then + [[ -f "${ACT_AMAX}" ]] || die "ACT_AMAX sidecar not found: ${ACT_AMAX}" + "${PY}" -c "import json,sys; d=json.load(open('${ACT_AMAX}')); d=d.get('amax',d); print(f' act amax ${ACT_AMAX} ({len(d)} entries)')" \ + || die "ACT_AMAX is not valid JSON" +else + printf '\033[33m act amax NOT SET -- weight-only calibration, not w4a4-optimal\033[0m\n' +fi + +mkdir -p "${OFFLOAD}" "$(dirname "${OUT}")" +free_gib=$(df -PBG "$(dirname "${OUT}")" | awk 'NR==2{gsub(/G/,"",$4); print $4}') +[[ "${free_gib}" -ge "${MIN_FREE_GIB}" ]] \ + || die "only ${free_gib} GiB free at $(dirname "${OUT}"); need >= ${MIN_FREE_GIB}" +echo " disk ${free_gib} GiB free" + +"${PY}" - <<'PYCHK' || die "no usable CUDA device" +import torch, sys +if not torch.cuda.is_available(): + sys.exit(1) +free, total = torch.cuda.mem_get_info(0) +print(f" gpu {torch.cuda.device_count()}x {torch.cuda.get_device_name(0)}, " + f"{free/2**30:.0f}/{total/2**30:.0f} GiB free on cuda:0") +PYCHK + +if [[ -e "${OUT}" ]]; then + printf '\033[33m WARNING %s already exists; model.save() will overwrite it\033[0m\n' "${OUT}" +fi + +[[ "${PREFLIGHT_ONLY}" == "1" ]] && { say "Preflight only -- stopping"; exit 0; } + +say "Expected RTN fallback rate" +# A module seeing fewer tokens than its own in_features cannot form a full-rank +# Hessian, so it is RTN-quantized rather than GPTQ-solved. Surface the rate up +# front rather than discovering it in the log. +COVERAGE="${HERE}/coverage_layer_expert.json" +if [[ -f "${COVERAGE}" ]]; then + "${PY}" - <5} tok -> RTN {r:,}/{tot:,} = {r/tot:.1%}") +print(" Thresholds are each projection's in_features: H = XtX is (cols x cols) and") +print(" rank(XtX) <= min(n_tokens, cols), so fewer tokens than cols gives a singular H.") +print(" NOTE: more calibration data does NOT lower this. A percentage threshold is") +print(" scale-invariant (RTN iff share < pct/(100*top_k)); only the threshold moves it.") +PYRTN +else + echo " (no coverage_layer_expert.json; run p1b_expert_coverage.py to predict)" +fi + +say "Quantizing" +echo " out ${OUT}" +echo " log ${LOG}" +[[ -n "${LAYERS}" ]] && echo " layers ${LAYERS} (subset)" + +MODEL="${MODEL}" OUT="${OUT}" SNAP="${SNAP}" OFFLOAD="${OFFLOAD}" \ +ACT_AMAX="${ACT_AMAX}" LAYERS="${LAYERS}" NUM_SAMPLES="${NUM_SAMPLES}" \ +MAX_TOKENS="${MAX_TOKENS}" FALLBACK_THRESHOLD="${FALLBACK_THRESHOLD}" \ +RANK_MARGIN="${RANK_MARGIN}" \ +PYTORCH_CUDA_ALLOC_CONF="${PYTORCH_CUDA_ALLOC_CONF:-expandable_segments:True}" \ +COLUMNS="${COLUMNS:-360}" \ + "${PY}" -u "${HERE}/quantize_w4a4.py" 2>&1 | tee "${LOG}" + +# Phase 3: splice quantized experts into the SOURCE checkpoint so passthrough +# modules keep their original serialization (MXFP8/BF16/F32, source naming) and +# experts gain input_scale from the amax sidecar -- the serving format map. +SPLICE="${SPLICE:-1}" +OUT_FINAL="${OUT_FINAL:-${OUT}-final}" +if [[ "${SPLICE}" == "1" ]]; then + say "Splicing source passthrough -> ${OUT_FINAL}" + SRC="${MODEL}" QNT="${OUT}" OUT="${OUT_FINAL}" ACT_AMAX="${ACT_AMAX}" "${PY}" -u "${HERE}/splice_source_passthrough.py" 2>&1 | tee -a "${LOG}" +fi + +say "Done" +# Roster: which modules were RTN'd (named in WARN lines) vs GPTQ-solved (the rest). +ROSTER="${OUT}/quant_roster.json" +"${PY}" - "${LOG}" "${ROSTER}" <<'PYROSTER' || true +import json, re, sys +log, out = sys.argv[1], sys.argv[2] +rtn = {} +for m in re.finditer(r"Module `([\w.]+)` -> Using `rtn` fallback quantization \(observed (\d+) samples, threshold=(\d+)", open(log, errors="replace").read()): + rtn[m.group(1)] = {"observed_tokens": int(m.group(2)), "threshold": int(m.group(3))} +json.dump({"rtn_modules": rtn, "note": "GPTQ-solved = quantized modules not listed here"}, open(out, "w"), indent=1) +print(f" roster {out} ({len(rtn)} RTN modules)") +PYROSTER +echo " checkpoint ${OUT}" +echo " next validate + add input_scale/weight_scale_2 if your serving" +echo " stack expects the ModelOpt NVFP4 layout (see header note 2)" diff --git a/scripts/minimax_m3_nvfp4/splice_source_passthrough.py b/scripts/minimax_m3_nvfp4/splice_source_passthrough.py new file mode 100644 index 000000000..10feb1604 --- /dev/null +++ b/scripts/minimax_m3_nvfp4/splice_source_passthrough.py @@ -0,0 +1,180 @@ +"""Splice GPTQ-NVFP4 experts into the source checkpoint (passthrough = source bytes). + +GPTQModel's save materializes every module, so passthrough tensors come out as +dequantized BF16 -- numerically right, but not the serving format map: attention, +index projections, shared experts, dense MLP 0-2 and non-quantized expert layers +must stay byte-identical MXFP8/BF16/F32 from the source release +(tore-quant/recipes/minimax-m3-nvfp4 format map). + +This builds the final checkpoint the way tore-quant's build_nvfp4_mxfp8_mix does: +walk the SOURCE tensor list, and only for routed experts of quantized layers swap +in the GPTQ output's NVFP4 tensors (renamed to source naming), plus the +`input_scale` the ModelOpt layout expects, derived from the activation amax +sidecar (amax / 6 / 448 per input site -- the same numbers the Hessian was +calibrated with). + + SRC=/data/huggingface/Minimax-M3-0602 \ + QNT=/data/huggingface/Minimax-M3-0602-NVFP4-w4a4-smoke-l3 \ + OUT=/data/huggingface/Minimax-M3-0602-NVFP4-w4a4-smoke-l3-final \ + ACT_AMAX=scripts/minimax_m3_nvfp4/amax_per_layer.json \ + python scripts/minimax_m3_nvfp4/splice_source_passthrough.py +""" + +import json +import os +import re +import shutil +import time + +import torch +from safetensors import safe_open +from safetensors.torch import save_file + +SRC = os.environ.get("SRC", "/data/huggingface/Minimax-M3-0602") +QNT = os.environ["QNT"] +OUT = os.environ["OUT"] +ACT_AMAX = os.environ.get("ACT_AMAX", "").strip() +SHARD_BYTES = int(os.environ.get("SHARD_BYTES", str(8 << 30))) + +t0 = time.time() + + +def log(msg: str) -> None: + print(f"[splice +{time.time() - t0:7.1f}s] {msg}", flush=True) + + +# --- naming: source <-> gptqmodel-save --------------------------------------- +# language_model.model.layers.N.block_sparse_moe.experts.E.{w1,w2,w3} +# <-> model.language_model.layers.N.mlp.experts.E.{gate_proj,down_proj,up_proj} +PROJ_TO_QNT = {"w1": "gate_proj", "w2": "down_proj", "w3": "up_proj"} +SRC_EXPERT_RE = re.compile( + r"^language_model\.model\.layers\.(\d+)\.block_sparse_moe\.experts\.(\d+)\.(w[123])\." +) + + +def qnt_base(layer: str, expert: str, proj: str) -> str: + return f"model.language_model.layers.{layer}.mlp.experts.{expert}.{PROJ_TO_QNT[proj]}" + + +src_index = json.load(open(f"{SRC}/model.safetensors.index.json"))["weight_map"] +qnt_index = json.load(open(f"{QNT}/model.safetensors.index.json"))["weight_map"] + +# Quantized modules are exactly those the GPTQ output gave a weight_scale_2. +quantized_layers = sorted({ + int(m.group(1)) + for k in qnt_index if k.endswith(".weight_scale_2") + for m in [re.search(r"\.layers\.(\d+)\.mlp\.experts\.", k)] if m +}) +log(f"quantized layers in {QNT}: {quantized_layers}") +if not quantized_layers: + raise SystemExit("no quantized expert layers found in QNT -- nothing to splice") + +amax = {} +if ACT_AMAX: + payload = json.load(open(ACT_AMAX)) + amax = payload.get("amax", payload) + log(f"input_scale source: {ACT_AMAX} ({len(amax)} entries)") +else: + log("WARNING: no ACT_AMAX -- input_scale tensors will NOT be written") + +# --- plan the output tensor list, in source order ----------------------------- +# Per quantized expert projection: drop source {weight, weight_scale_inv}; emit +# {weight, weight_scale, weight_scale_2[, input_scale]} from QNT under source names. +PROJ_SITE = {"w1": "moe_input", "w3": "moe_input", "w2": "w2_input"} + +plan = [] # (out_name, origin, origin_key) origin in {"src", "qnt", "amax"} +emitted = set() +for name in src_index: + m = SRC_EXPERT_RE.match(name) + if m and int(m.group(1)) in quantized_layers: + layer, expert, proj = m.group(1), m.group(2), m.group(3) + base_src = f"language_model.model.layers.{layer}.block_sparse_moe.experts.{expert}.{proj}" + if base_src in emitted: + continue # second source tensor (weight_scale_inv) of the same module + emitted.add(base_src) + base_q = qnt_base(layer, expert, proj) + for suffix in (".weight", ".weight_scale", ".weight_scale_2"): + if base_q + suffix not in qnt_index: + raise SystemExit(f"QNT missing {base_q}{suffix} -- incomplete quantized module") + plan.append((base_src + suffix, "qnt", base_q + suffix)) + if amax: + key = f"layer{layer}.{PROJ_SITE[proj]}" + if key not in amax: + raise SystemExit(f"amax sidecar missing {key}") + plan.append((base_src + ".input_scale", "amax", key)) + else: + plan.append((name, "src", name)) + +log(f"plan: {len(plan)} tensors ({sum(1 for _, o, _ in plan if o != 'src')} spliced, " + f"{sum(1 for _, o, _ in plan if o == 'src')} source passthrough)") + +# --- stream tensors into fresh shards ----------------------------------------- +os.makedirs(OUT, exist_ok=True) +handles = {} + + +def read_tensor(origin: str, key: str) -> torch.Tensor: + if origin == "amax": + return torch.tensor(float(amax[key]) / 6.0 / 448.0, dtype=torch.float32) + root, index = (SRC, src_index) if origin == "src" else (QNT, qnt_index) + shard = index[key] + cache_key = (root, shard) + if cache_key not in handles: + handles.clear() # one open shard per side at a time; source order keeps locality + handles[cache_key] = safe_open(os.path.join(root, shard), framework="pt") + return handles[cache_key].get_tensor(key) + + +weight_map = {} +buffer, buf_bytes, shard_id = {}, 0, 0 +total = 0 + + +def flush(): + global buffer, buf_bytes, shard_id + if not buffer: + return + shard_id += 1 + fname = f"model-{shard_id:05d}.safetensors" + save_file(buffer, os.path.join(OUT, fname)) + for k in buffer: + weight_map[k] = fname + buffer, buf_bytes = {}, 0 + + +for out_name, origin, key in plan: + t = read_tensor(origin, key) + buffer[out_name] = t + buf_bytes += t.numel() * t.element_size() + total += 1 + if buf_bytes >= SHARD_BYTES: + flush() + if shard_id % 8 == 0: + log(f" wrote shard {shard_id} ({total}/{len(plan)} tensors)") +flush() +log(f"wrote {shard_id} shards, {len(weight_map)} tensors") + +# rename shards to the canonical model-XXXXX-of-YYYYY pattern +final_names = {} +for i in range(1, shard_id + 1): + old = f"model-{i:05d}.safetensors" + new = f"model-{i:05d}-of-{shard_id:05d}.safetensors" + os.replace(os.path.join(OUT, old), os.path.join(OUT, new)) + final_names[old] = new +weight_map = {k: final_names[v] for k, v in weight_map.items()} + +with open(f"{OUT}/model.safetensors.index.json", "w") as f: + json.dump({"metadata": {"total_size": sum( + os.path.getsize(os.path.join(OUT, s)) for s in set(weight_map.values()) + )}, "weight_map": weight_map}, f, indent=1) + +# --- config + aux files: source's, with quantized experts removed from the +# mxfp8 ignore/exclude story. Serving-side quantization_config for the mixed +# NVFP4+MXFP8 layout follows the MiniMax-M3-NVFP4-mxfp8skip14 recipe and is left +# to the serving prep step; here we copy source aux files verbatim. +for fname in os.listdir(SRC): + if fname.endswith((".json", ".py", ".jinja", ".txt")) and fname != "model.safetensors.index.json": + shutil.copy2(os.path.join(SRC, fname), os.path.join(OUT, fname)) +log("copied source aux/config files verbatim (serving quantization_config is a separate prep step)") + +log(f"DONE -> {OUT}") diff --git a/tests/test_fp8_e8m0_dequant.py b/tests/test_fp8_e8m0_dequant.py new file mode 100644 index 000000000..0fc55ad76 --- /dev/null +++ b/tests/test_fp8_e8m0_dequant.py @@ -0,0 +1,89 @@ +# SPDX-FileCopyrightText: 2026 ModelCloud.ai +# SPDX-License-Identifier: Apache-2.0 + +"""uint8 scales entering dequantize_fp8 are UE8M0 exponent bytes, not magnitudes. + +MX checkpoints (mxfp8) serialize `weight_scale_inv` as uint8 E8M0: multiplier +2^(b - 127). Feeding the raw bytes into the float scale arithmetic is the bug +this pins: byte values ~110-130 exceed 1, so `_fast_scale_arg` flipped scale_inv +into divide mode and every dequantized weight came out wrong by a block-dependent +factor (observed absmax 3.9 instead of 0.28 on Minimax-M3-0602). This was the +weight source for GPTQ's quant_source path, so it would have silently poisoned +whole quantization runs. +""" + +import os + + +os.environ.setdefault("CUDA_VISIBLE_DEVICES", "") + +import pytest +import torch + +from gptqmodel.quantization.dtype import dequantize_block_fp8, dequantize_fp8 + + +BLOCK = 32 # mxfp8 weight_block_size [1, 32] + + +def _mx_tensor(rows=8, cols=128, seed=0): + """A synthetic mxfp8 pair: fp8 payload + uint8 E8M0 block scales.""" + + torch.manual_seed(seed) + payload = (torch.randn(rows, cols) * 100).to(torch.float8_e4m3fn) + # exponents spanning well below and above the bias, like real checkpoints + scale = torch.randint(110, 135, (rows, cols // BLOCK), dtype=torch.uint8) + return payload, scale + + +def test_uint8_scale_inv_decodes_as_e8m0(): + payload, scale = _mx_tensor() + out = dequantize_fp8(payload, scale_inv=scale, axis=None, target_dtype=torch.bfloat16) + ref = dequantize_block_fp8(payload, scale, target_dtype=torch.bfloat16) + assert torch.equal(out, ref), "uint8 scale_inv not decoded as 2^(b-127)" + + +def test_uint8_decode_is_multiplicative_not_divisive(): + """The historic failure mode: bytes > 1 flipped the path into divide mode.""" + + payload = torch.full((1, BLOCK), 4.0).to(torch.float8_e4m3fn) + scale = torch.tensor([[127 - 3]], dtype=torch.uint8) # 2^-3 = 0.125 + out = dequantize_fp8(payload, scale_inv=scale, axis=None, target_dtype=torch.float32) + assert torch.allclose(out, torch.full((1, BLOCK), 0.5)), ( + f"expected 4.0 * 2^-3 = 0.5, got {out[0, 0].item()} " + "(4.0 / 124 would indicate divide-by-raw-bytes has returned)" + ) + + +def test_float_scale_inv_behavior_unchanged(): + """Float scales must not take the uint8/E8M0 branch: their multiplier + semantics are pre-existing and pinned here with a full-shape scale, which is + unambiguous on every internal path.""" + + payload, _ = _mx_tensor(seed=1) + scale_f32 = torch.full(payload.shape, 0.125, dtype=torch.float32) + out = dequantize_fp8(payload, scale_inv=scale_f32, axis=None, target_dtype=torch.float32) + ref = payload.to(torch.float32) * 0.125 + assert torch.allclose(out, ref) + + +@pytest.mark.skipif( + not os.path.isdir("/data/huggingface/Minimax-M3-0602"), reason="M3 checkpoint not present" +) +def test_real_minimax_m3_tensor_decodes_to_sane_magnitude(): + """End-to-end pin against the real checkpoint that exposed the bug.""" + + import json + + from safetensors import safe_open + + d = "/data/huggingface/Minimax-M3-0602" + idx = json.load(open(f"{d}/model.safetensors.index.json"))["weight_map"] + name = "language_model.model.layers.3.self_attn.q_proj.weight" + with safe_open(os.path.join(d, idx[name]), framework="pt") as f: + w = f.get_tensor(name) + with safe_open(os.path.join(d, idx[name + "_scale_inv"]), framework="pt") as f: + s = f.get_tensor(name + "_scale_inv") + out = dequantize_fp8(w, scale_inv=s, axis=None, target_dtype=torch.bfloat16) + absmax = out.float().abs().max().item() + assert 0.1 < absmax < 1.0, f"absmax {absmax}: ~0.28 is correct, 3.9 is divide-mode, 448 is raw" diff --git a/tests/test_nvfp4_export.py b/tests/test_nvfp4_export.py new file mode 100644 index 000000000..599e82ed5 --- /dev/null +++ b/tests/test_nvfp4_export.py @@ -0,0 +1,442 @@ +# SPDX-FileCopyrightText: 2026 ModelCloud.ai +# SPDX-License-Identifier: Apache-2.0 + +"""NVFP4 export coverage: GPTQ-on-NVFP4-grid and weight-only NVFP4 packing. + +1. `NVFP4Quantizer` must be bit-identical to torchao's ``nvfp4_quantize`` grid. +2. `TorchNVFP4Linear` packing must be lossless against the scales produced by + the GPTQ loop (error feedback mutates columns after group scales are fixed, + so export must reuse the loop's scales, never re-derive them). +3. End-to-end: GPTQ quantize -> save -> reload -> forward on a tiny model, + with the checkpoint stored in the NVFP4 layout (packed uint8 weight + + float8_e4m3fn `weight_scale`). +""" + +import os +from pathlib import Path + + +# Keep this test on CPU so it works on CPU-only runners. +os.environ.setdefault("CUDA_VISIBLE_DEVICES", "") + +import pytest +import torch +import torch.nn as nn +from safetensors import safe_open +from tokenizers import Tokenizer +from tokenizers.models import WordLevel +from tokenizers.pre_tokenizers import Whitespace +from tokenizers.trainers import WordLevelTrainer +from transformers import PreTrainedTokenizerFast +from transformers.models.llama.modeling_llama import LlamaConfig, LlamaForCausalLM + +from gptqmodel import BACKEND, GPTQModel, QuantizeConfig +from gptqmodel.nn_modules.qlinear.fp4 import TorchNVFP4Linear, quantize_nvfp4_weight +from gptqmodel.quantization.config import FORMAT, METHOD, GPTQConfig, NVFP4Config +from gptqmodel.quantization.quantizer import NVFP4Quantizer + + +try: + from torchao.prototype.mx_formats.nvfp4_tensor import NVFP4Tensor, nvfp4_quantize +except Exception: + NVFP4Tensor = None + nvfp4_quantize = None + +pytestmark = [ + pytest.mark.cpu, + pytest.mark.skipif(NVFP4Tensor is None, reason="torchao NVFP4 support required"), +] + +BLOCK = 16 + + +def _nvfp4_dequant(packed: torch.Tensor, scales: torch.Tensor, dtype=torch.float32) -> torch.Tensor: + nv = NVFP4Tensor(packed, scales, block_size=BLOCK, orig_dtype=dtype) + to_dtype = getattr(nv, "to_dtype", None) + if callable(to_dtype): + return to_dtype(dtype) + return nv.dequantize(dtype) + + +# --------------------------------------------------------------------------- +# Config resolution +# --------------------------------------------------------------------------- + + +def test_format_nvfp4_resolves_to_gptq_config(): + cfg = QuantizeConfig(bits=4, group_size=16, format="nvfp4") + assert isinstance(cfg, GPTQConfig) + assert cfg.method == METHOD.GPTQ + assert cfg.format == FORMAT.NVFP4 + assert cfg.sym is True + assert cfg.desc_act is False + assert cfg.act_group_aware is False + + payload = cfg.to_dict() + reloaded = QuantizeConfig.from_quant_config(payload) + assert isinstance(reloaded, GPTQConfig) + assert reloaded.format == FORMAT.NVFP4 + + +def test_method_nvfp4_resolves_to_weight_only_config(): + cfg = QuantizeConfig(quant_method="nvfp4") + assert isinstance(cfg, NVFP4Config) + assert cfg.uses_weight_only_lifecycle() is True + assert cfg.bits == 4 + assert cfg.weight_block_size == BLOCK + + reloaded = QuantizeConfig.from_quant_config(cfg.to_dict()) + assert isinstance(reloaded, NVFP4Config) + + shorthand = QuantizeConfig(weight_only="nvfp4") + assert isinstance(shorthand, NVFP4Config) + + +def test_from_quant_config_accepts_fp4_alias(): + cfg = QuantizeConfig.from_quant_config({"quant_method": "nvfp4", "format": "fp4", "bits": 4}) + assert isinstance(cfg, NVFP4Config) + assert cfg.format == FORMAT.NVFP4 + + +@pytest.mark.parametrize( + "kwargs", + [ + {"bits": 8, "group_size": 16}, + {"bits": 4, "group_size": 128}, + {"bits": 4, "group_size": 16, "sym": False}, + {"bits": 4, "group_size": 16, "desc_act": True}, + {"bits": 4, "group_size": 16, "act_group_aware": True}, + # non-RTN fallback strategies quantize off the NVFP4 grid + {"bits": 4, "group_size": 16, "fallback": {"strategy": "mean"}}, + # fallback smoothing breaks E4M3-exact scales, so packing stops being lossless + {"bits": 4, "group_size": 16, "fallback": {"strategy": "rtn", "smooth": "mse"}}, + # the mock loop rounds on an integer grid, off the NVFP4 grid + {"bits": 4, "group_size": 16, "mock_quantization": True}, + ], +) +def test_format_nvfp4_rejects_incompatible_gptq_settings(kwargs): + with pytest.raises(ValueError): + QuantizeConfig(format="nvfp4", **kwargs) + + +# --------------------------------------------------------------------------- +# Quantizer grid exactness +# --------------------------------------------------------------------------- + + +def test_nvfp4_quantizer_requires_primed_global_scale(): + """Single-level (unprimed) scale derivation was removed: absolute amax/6 + block scales floor-clamp at E4M3's 2^-6 on realistic weights. An unprimed + find_params is a caller bug and must raise, not silently degrade.""" + + qcfg = QuantizeConfig(bits=4, group_size=16, format="nvfp4") + quantizer = NVFP4Quantizer(qcfg=qcfg) + quantizer.configure(perchannel=True) + + with pytest.raises(RuntimeError, match="prime_global_scale"): + quantizer.find_params(torch.randn(64, BLOCK), weight=True) + + +# --------------------------------------------------------------------------- +# Kernel packing +# --------------------------------------------------------------------------- + + +def test_direct_pack_matches_torchao(): + """RTN pack must match torchao's TWO-level path: per-tensor scale amax/(6*448) + plus E4M3 block scales, matching the ModelOpt/Quark/tore-quant layout.""" + + torch.manual_seed(1) + lin = nn.Linear(128, 64, bias=True) + + module = TorchNVFP4Linear( + bits=4, + group_size=-1, + sym=True, + desc_act=False, + in_features=128, + out_features=64, + bias=True, + register_buffers=True, + ) + module.pack_original(linear=lin, scales=None, zeros=None) + + w = lin.weight.detach().float() + gs_ref = (w.abs().max() / (6.0 * 448.0)).reshape(()) + scales_ref, packed_ref = nvfp4_quantize(w.contiguous(), block_size=BLOCK, per_tensor_scale=gs_ref) + assert torch.equal(module.weight, packed_ref.view(torch.uint8)) + assert torch.equal(module.weight_scale.view(torch.uint8), scales_ref.view(torch.uint8)) + assert torch.equal(module.weight_scale_2, gs_ref) + + deq = module.dequantize_weight(dtype=torch.float32).T + ref = NVFP4Tensor( + packed_ref, scales_ref, BLOCK, torch.float32, per_tensor_scale=gs_ref + ).dequantize(torch.float32) + assert torch.allclose(deq, ref, atol=1e-3) + + +def test_gptq_scale_pack_is_lossless(): + """Error feedback shrinks/grows columns after the group scale is fixed; packing + against the loop's scales must reproduce the GPTQ output weight exactly.""" + + torch.manual_seed(2) + IN, OUT = 128, 64 + qcfg = QuantizeConfig(bits=4, group_size=16, format="nvfp4") + quantizer = NVFP4Quantizer(qcfg=qcfg) + quantizer.configure(perchannel=True) + + W = torch.randn(OUT, IN, dtype=torch.float32) + quantizer.prime_global_scale(W) + Q = torch.empty_like(W) + scales = [] + for g in range(0, IN, BLOCK): + block = W[:, g : g + BLOCK] + quantizer.find_params(block, weight=True) + # simulate GPTQ error feedback mutating the block after scale is fixed + block = block + 0.05 * torch.randn_like(block) + Q[:, g : g + BLOCK] = quantizer.quantize(block) + scales.append(quantizer.scale) + scales = torch.cat(scales, dim=1) + + lin = nn.Linear(IN, OUT, bias=False) + lin.weight.data = Q.clone() + module = TorchNVFP4Linear( + bits=4, + group_size=16, + sym=True, + desc_act=False, + in_features=IN, + out_features=OUT, + bias=False, + register_buffers=True, + ) + module.pack_original(linear=lin, scales=scales, zeros=None) + + deq = module.dequantize_weight(dtype=torch.float32).T + assert torch.equal(deq, Q) + + +def test_quantize_nvfp4_weight_rejects_bad_shapes(): + with pytest.raises(ValueError): + quantize_nvfp4_weight(torch.randn(4, 24)) # 24 % 16 != 0 + with pytest.raises(ValueError): + quantize_nvfp4_weight(torch.randn(4, 16, 2)) + + +# --------------------------------------------------------------------------- +# End-to-end: GPTQ quantize -> save -> reload -> forward +# --------------------------------------------------------------------------- + +_CALIBRATION_TEXTS = [ + "tiny nvfp4 calibration sample one with enough tokens to survive minimum length filtering", + "tiny nvfp4 calibration sample two exercising the gptq loop over the fp4 grid cleanly", + "another synthetic calibration example that is intentionally verbose so filtering keeps it", +] * 2 + + +def _build_local_tokenizer(model_dir: Path) -> PreTrainedTokenizerFast: + tokenizer = Tokenizer(WordLevel(unk_token="[UNK]")) + tokenizer.pre_tokenizer = Whitespace() + trainer = WordLevelTrainer(special_tokens=["[PAD]", "[UNK]", "[BOS]", "[EOS]"]) + tokenizer.train_from_iterator(_CALIBRATION_TEXTS, trainer=trainer) + + fast_tokenizer = PreTrainedTokenizerFast( + tokenizer_object=tokenizer, + bos_token="[BOS]", + eos_token="[EOS]", + unk_token="[UNK]", + pad_token="[PAD]", + ) + fast_tokenizer.save_pretrained(model_dir) + return fast_tokenizer + + +def _build_tiny_llama_fixture(model_dir: Path) -> tuple[LlamaConfig, PreTrainedTokenizerFast]: + config = LlamaConfig( + num_hidden_layers=2, + hidden_size=64, + intermediate_size=128, + num_attention_heads=4, + num_key_value_heads=4, + vocab_size=128, + pad_token_id=0, + bos_token_id=1, + eos_token_id=2, + ) + model = LlamaForCausalLM(config) + model.save_pretrained(model_dir) + tokenizer = _build_local_tokenizer(model_dir) + return config, tokenizer + + +def _build_calibration_dataset(tokenizer: PreTrainedTokenizerFast) -> list[dict[str, object]]: + dataset = [] + for text in _CALIBRATION_TEXTS: + encoded = tokenizer(text, return_tensors="pt") + dataset.append( + { + "input_ids": encoded["input_ids"], + "attention_mask": encoded["attention_mask"], + } + ) + return dataset + + +@pytest.mark.slow +def test_gptq_nvfp4_end_to_end(tmp_path: Path): + model_dir = tmp_path / "native" + quantized_dir = tmp_path / "quantized" + + config, tokenizer = _build_tiny_llama_fixture(model_dir) + calibration = _build_calibration_dataset(tokenizer) + + quantize_config = QuantizeConfig( + bits=4, + group_size=16, + format="nvfp4", + device="cpu", + ) + assert isinstance(quantize_config, GPTQConfig) + + model = GPTQModel.load( + str(model_dir), + quantize_config=quantize_config, + backend=BACKEND.TORCH, + ) + model.quantize( + calibration, + batch_size=1, + backend=BACKEND.TORCH, + calibration_data_min_length=1, + ) + model.save(quantized_dir) + + # Checkpoint layout: packed uint8 fp4 weight + float8_e4m3fn weight_scale. + shards = sorted(quantized_dir.glob("*.safetensors")) + assert shards, "no safetensors shard written" + weight_key = "model.layers.0.self_attn.q_proj.weight" + scale_key = "model.layers.0.self_attn.q_proj.weight_scale" + with safe_open(shards[0], framework="pt", device="cpu") as f: + keys = set(f.keys()) + assert weight_key in keys and scale_key in keys, sorted(keys) + weight = f.get_tensor(weight_key) + scale = f.get_tensor(scale_key) + hidden = config.hidden_size + assert weight.dtype is torch.uint8 + assert weight.shape == (hidden, hidden // 2) + assert scale.dtype is torch.float8_e4m3fn + assert scale.shape == (hidden, hidden // BLOCK) + + # The saved tensors must decode with the torchao NVFP4 layout. + _ = _nvfp4_dequant(weight, scale) + + reloaded = GPTQModel.load(str(quantized_dir), device="cpu") + modules = dict(reloaded.named_modules()) + for suffix in ("q_proj", "k_proj", "v_proj", "o_proj"): + name = f"model.model.layers.0.self_attn.{suffix}" + assert isinstance(modules[name], TorchNVFP4Linear), name + for suffix in ("gate_proj", "up_proj", "down_proj"): + name = f"model.model.layers.0.mlp.{suffix}" + assert isinstance(modules[name], TorchNVFP4Linear), name + + encoded = tokenizer("nvfp4 forward smoke", return_tensors="pt") + with torch.inference_mode(): + out = reloaded.model(input_ids=encoded["input_ids"]) + assert out.logits.shape[-1] == config.vocab_size + assert torch.isfinite(out.logits).all() + + +@pytest.mark.slow +def test_weight_only_nvfp4_end_to_end(tmp_path: Path): + """RTN-style direct NVFP4 export (`method=nvfp4`) without calibration.""" + + model_dir = tmp_path / "native" + quantized_dir = tmp_path / "quantized" + + config, tokenizer = _build_tiny_llama_fixture(model_dir) + + quantize_config = QuantizeConfig(quant_method="nvfp4", device="cpu") + assert isinstance(quantize_config, NVFP4Config) + + model = GPTQModel.load( + str(model_dir), + quantize_config=quantize_config, + backend=BACKEND.TORCH, + ) + model.quantize(calibration=None, backend=BACKEND.TORCH) + model.save(quantized_dir) + + reloaded = GPTQModel.load(str(quantized_dir), device="cpu") + modules = dict(reloaded.named_modules()) + name = "model.model.layers.0.mlp.down_proj" + assert isinstance(modules[name], TorchNVFP4Linear), name + + encoded = tokenizer("nvfp4 weight only smoke", return_tensors="pt") + with torch.inference_mode(): + out = reloaded.model(input_ids=encoded["input_ids"]) + assert torch.isfinite(out.logits).all() + + +def test_primed_quantizer_matches_torchao_two_level_grid(): + """With a primed global scale, find_params must land on torchao's two-level + grid -- the fix for 96.8% of block scales floor-clamping at E4M3's 2^-6 on + realistic weight magnitudes (1.4x reconstruction error vs the donor RTN).""" + + torch.manual_seed(3) + qcfg = QuantizeConfig(bits=4, group_size=16, format="nvfp4") + quantizer = NVFP4Quantizer(qcfg=qcfg) + quantizer.configure(perchannel=True) + + W = torch.randn(32, 64, dtype=torch.float32) * 0.02 + gs = quantizer.prime_global_scale(W) + assert float(gs) > 0 and (torch.log2(gs) == torch.log2(gs).round()), "global scale must be a power of two" + + # collect the loop's per-group effective scales + ours = [] + for g in range(0, 64, BLOCK): + quantizer.find_params(W[:, g : g + BLOCK], weight=True) + ours.append(quantizer.scale.flatten()) + ours = torch.stack(ours, dim=1) + + ref_scales, _ = nvfp4_quantize(W.contiguous(), block_size=BLOCK, per_tensor_scale=gs.reshape(())) + ref = ref_scales.to(torch.float32) * gs + assert torch.equal(ours, ref), "primed find_params diverged from torchao two-level scales" + + # the floor-clamp regression itself: on these magnitudes the one-level path + # pins ~100% of blocks at 2^-6; two-level must not. + floored = (ours == torch.finfo(torch.float8_e4m3fn).tiny).float().mean() + assert floored < 0.5, f"{floored:.0%} of scales at the E4M3 floor -- still single-level" + + +def test_primed_scales_pack_losslessly_with_recovered_global_scale(): + """pack must re-derive exactly the primed global scale from the effective + scales, so the two-level split reproduces the loop's grid bit-for-bit.""" + + torch.manual_seed(4) + IN, OUT = 128, 32 + qcfg = QuantizeConfig(bits=4, group_size=16, format="nvfp4") + quantizer = NVFP4Quantizer(qcfg=qcfg) + quantizer.configure(perchannel=True) + + W = torch.randn(OUT, IN, dtype=torch.float32) * 0.02 + gs = quantizer.prime_global_scale(W) + scales = [] + for g in range(0, IN, BLOCK): + quantizer.find_params(W[:, g : g + BLOCK], weight=True) + scales.append(quantizer.scale.reshape(-1, 1)) + scales = torch.cat(scales, dim=1) + + module = TorchNVFP4Linear( + bits=4, group_size=BLOCK, sym=True, desc_act=False, + in_features=IN, out_features=OUT, bias=False, + ) + packed, block_scale, ws2 = module._pack_with_gptq_scales(W, scales) + # The invariant is exactness of the split, not equality of the decomposition: + # if the max block ratio E4M3-rounds down to exactly 224, pack legitimately + # recovers gs/2 -- the product is identical either way. + assert torch.equal(block_scale.to(torch.float32) * ws2, scales), "two-level split not exact" + lg = torch.log2(ws2.reshape(())) + assert lg == lg.round(), "recovered global scale must stay a power of two" + assert float(lg) in (float(torch.log2(gs)), float(torch.log2(gs)) - 1.0), ( + "recovered global scale should be gs or gs/2 (boundary rounding)" + ) diff --git a/tests/test_w4a4_hessian.py b/tests/test_w4a4_hessian.py new file mode 100644 index 000000000..97ffac8ee --- /dev/null +++ b/tests/test_w4a4_hessian.py @@ -0,0 +1,292 @@ +# SPDX-FileCopyrightText: 2026 ModelCloud.ai +# SPDX-License-Identifier: Apache-2.0 + +"""w4a4: the GPTQ Hessian must be accumulated on quantized activations. + +Under w4a4 the kernel consumes ``Q(X)``, so a Hessian built from ``X`` solves for +inputs that never occur. The load-bearing test here is +``test_hessian_differs_from_clean_activations``: the natural way to get this +wrong is to capture activations with a ``forward_pre_hook``, which fires before +any input quantizer and therefore silently accumulates clean activations. That +bug leaves every config flag looking correct, so it is only detectable by +asserting the Hessian actually changed. + +See `docs/w4a4_hessian.md`. +""" + +import json +import os + + +# Keep this test on CPU so it works on CPU-only runners. +os.environ.setdefault("CUDA_VISIBLE_DEVICES", "") + +import pytest +import torch +import torch.nn as nn + +from gptqmodel.quantization import QuantizeConfig +from gptqmodel.quantization.config import FORMAT +from gptqmodel.quantization.dtype import fake_quantize_nvfp4 +from gptqmodel.quantization.gptq import GPTQ + + +IN_FEATURES = 64 +OUT_FEATURES = 32 +BLOCK = 16 + + +def _qcfg(act: bool) -> QuantizeConfig: + kwargs = dict(bits=4, group_size=BLOCK, format="nvfp4", sym=True, desc_act=False) + if act: + kwargs.update(act_format=FORMAT.NVFP4, act_group_size=BLOCK) + return QuantizeConfig(**kwargs) + + +def _hessian_for(act: bool, inp: torch.Tensor) -> torch.Tensor: + torch.manual_seed(0) + module = nn.Linear(IN_FEATURES, OUT_FEATURES, bias=False) + gptq = GPTQ(module=module, qcfg=_qcfg(act)) + gptq.add_batch(inp, torch.empty(0)) + return gptq.finalize_hessian().clone() + + +@pytest.fixture(scope="module") +def calib_input() -> torch.Tensor: + torch.manual_seed(1234) + return torch.randn(4, 128, IN_FEATURES) + + +def test_fake_quantize_nvfp4_round_trip(): + """Quantize+dequantize preserves layout but lands values on the FP4 grid.""" + + torch.manual_seed(0) + x = torch.randn(8, IN_FEATURES) + q = fake_quantize_nvfp4(x, block_size=BLOCK) + + assert q.shape == x.shape + assert q.dtype == x.dtype + assert torch.isfinite(q).all() + assert not torch.equal(q, x), "fake quantization was a no-op" + # E2M1 has 16 codes, so one block can never hold more distinct values. + assert len(torch.unique(q[0, :BLOCK])) <= 16 + + +def test_fake_quantize_nvfp4_rejects_ragged_last_dim(): + """A last dim that is not block-divisible must raise, not silently mis-block.""" + + with pytest.raises(ValueError, match="multiple of block_size"): + fake_quantize_nvfp4(torch.randn(4, BLOCK + 1), block_size=BLOCK) + + +def test_weight_only_config_leaves_activations_untouched(): + """Without `act_format`, no activation quantizer exists at all.""" + + gptq = GPTQ(module=nn.Linear(IN_FEATURES, OUT_FEATURES, bias=False), qcfg=_qcfg(act=False)) + assert gptq._act_quantizer is None + + +def test_act_format_installs_quantizer(): + gptq = GPTQ(module=nn.Linear(IN_FEATURES, OUT_FEATURES, bias=False), qcfg=_qcfg(act=True)) + assert gptq._act_quantizer is not None + + +def test_hessian_differs_from_clean_activations(calib_input): + """The regression that matters: `act_format` must actually change the Hessian. + + A hook-based implementation would see pre-quantizer activations and produce a + Hessian identical to the weight-only one while appearing configured. + """ + + h_clean = _hessian_for(act=False, inp=calib_input) + h_quant = _hessian_for(act=True, inp=calib_input) + + assert h_clean.shape == h_quant.shape + assert not torch.allclose(h_clean, h_quant), ( + "Hessian is identical with and without activation quantization — " + "activations are not being quantized before accumulation" + ) + rel = ((h_clean - h_quant).norm() / h_clean.norm()).item() + assert rel > 1e-3, f"Hessian changed by only {rel:.2%}; expected a visible FP4 effect" + + +def test_quantized_hessian_is_well_formed(calib_input): + """Quantizing activations must not break the properties the solve relies on.""" + + h = _hessian_for(act=True, inp=calib_input) + + assert torch.isfinite(h).all() + assert torch.allclose(h, h.T, atol=1e-4), "Hessian lost symmetry" + assert (torch.diag(h) > 0).all(), "Hessian gained a dead column under activation quantization" + + +def test_hessian_is_deterministic(calib_input): + """Same inputs must give the same Hessian — the solve is not robust to drift.""" + + assert torch.equal(_hessian_for(act=True, inp=calib_input), _hessian_for(act=True, inp=calib_input)) + + +@pytest.mark.parametrize( + "kwargs, match", + [ + (dict(bits=4, group_size=128, format="gptq", act_format=FORMAT.NVFP4), "requires `format=nvfp4`"), + (dict(bits=4, group_size=BLOCK, format="nvfp4", act_format=FORMAT.NVFP4, act_group_size=0), "act_group_size"), + (dict(bits=4, group_size=BLOCK, format="nvfp4", act_amax_path="x.json"), "require `act_format`"), + ], +) +def test_config_rejects_unsupported_activation_settings(kwargs, match): + with pytest.raises(ValueError, match=match): + QuantizeConfig(**kwargs) + + +# --------------------------------------------------------------- amax sidecar -- + + +def _sidecar(tmp_path, payload) -> str: + path = tmp_path / "amax_per_layer.json" + path.write_text(json.dumps(payload)) + return str(path) + + +def _gptq_named(name: str, qcfg: QuantizeConfig) -> GPTQ: + torch.manual_seed(0) + module = nn.Linear(IN_FEATURES, OUT_FEATURES, bias=False) + gptq = GPTQ(module=module, qcfg=qcfg) + gptq.name = name + return gptq + + +def _qcfg_sidecar(path, rules=None) -> QuantizeConfig: + return QuantizeConfig( + bits=4, group_size=BLOCK, format="nvfp4", sym=True, desc_act=False, + act_format=FORMAT.NVFP4, act_group_size=BLOCK, + act_amax_path=path, act_amax_key_rules=rules, + ) + + +def test_global_scale_matches_pipeline_formula(tmp_path): + """global_scale must be amax / 6 / 448 — the NVFP4 second-level scale.""" + + amax = 12.0 + path = _sidecar(tmp_path, {"layer0.moe_input": amax}) + gptq = _gptq_named("layer0.moe_input", _qcfg_sidecar(path)) + + scale = gptq._resolve_act_global_scale() + assert scale is not None + assert scale.item() == pytest.approx(amax / 6.0 / 448.0) + + +def test_sidecar_accepts_nested_amax_mapping(tmp_path): + """The pipeline emits {'amax': {...}}; a flat mapping must work too.""" + + path = _sidecar(tmp_path, {"amax": {"layer0.moe_input": 6.0}, "meta": {"ignored": 1}}) + gptq = _gptq_named("layer0.moe_input", _qcfg_sidecar(path)) + assert gptq._resolve_act_global_scale().item() == pytest.approx(6.0 / 6.0 / 448.0) + + +def test_key_rules_share_one_scale_across_sibling_modules(tmp_path): + """w1 and w3 consume the same MoE input, so they must resolve to one entry. + + Getting this wrong gives siblings different activation scales than the kernel + applies, which is exactly the mismatch the sidecar exists to prevent. + """ + + path = _sidecar(tmp_path, {"layer7.moe_input": 8.0, "layer7.w2_input": 2.0}) + rules = [ + (r"^layers\.(\d+)\..*\.(?:w1|w3)$", r"layer\1.moe_input"), + (r"^layers\.(\d+)\..*\.w2$", r"layer\1.w2_input"), + ] + cfg = _qcfg_sidecar(path, rules) + + w1 = _gptq_named("layers.7.ffn.experts.3.w1", cfg)._resolve_act_global_scale() + w3 = _gptq_named("layers.7.ffn.experts.3.w3", cfg)._resolve_act_global_scale() + w2 = _gptq_named("layers.7.ffn.experts.3.w2", cfg)._resolve_act_global_scale() + + assert w1.item() == w3.item(), "w1 and w3 share an input and must share a scale" + assert w2.item() != w1.item(), "w2 sees the post-SwiGLU activation and needs its own" + assert w2.item() == pytest.approx(2.0 / 6.0 / 448.0) + + +def test_missing_amax_key_fails_loud(tmp_path): + """A missing entry must raise, not silently fall back to block-only scales.""" + + path = _sidecar(tmp_path, {"layer0.moe_input": 6.0}) + gptq = _gptq_named("layers.99.ffn.experts.0.w1", _qcfg_sidecar(path)) + with pytest.raises(KeyError, match="not found"): + gptq._resolve_act_global_scale() + + +@pytest.mark.parametrize("bad", [0.0, -1.0, float("inf"), float("nan")]) +def test_invalid_amax_rejected(tmp_path, bad): + path = _sidecar(tmp_path, {"k": bad}) + gptq = _gptq_named("k", _qcfg_sidecar(path)) + with pytest.raises(ValueError, match="finite and positive"): + gptq._resolve_act_global_scale() + + +def test_key_rules_resolve_against_named_module_full_name(tmp_path): + """The looper names modules layer-relative (`mlp.experts.1.gate_proj`); + only NamedModule.full_name carries the layer index the key rules capture. + Resolving against `self.name` fails on every real looper run.""" + + from types import SimpleNamespace + + path = _sidecar(tmp_path, {"layer3.moe_input": 6.0}) + rules = [(r".*\.layers\.(\d+)\.mlp\.experts\.\d+\.(?:gate_proj|up_proj)$", r"layer\1.moe_input")] + gptq = _gptq_named("mlp.experts.1.gate_proj", _qcfg_sidecar(path, rules)) + gptq._named_module = SimpleNamespace( + full_name="model.language_model.layers.3.mlp.experts.1.gate_proj" + ) + assert gptq._resolve_act_global_scale().item() == pytest.approx(6.0 / 6.0 / 448.0) + + +def test_no_sidecar_leaves_global_scale_unset(): + gptq = _gptq_named("anything", _qcfg(act=True)) + assert gptq._resolve_act_global_scale() is None + + +def test_global_scale_changes_the_hessian(tmp_path, calib_input): + """The sidecar must actually reach the quantizer, not just parse.""" + + torch.manual_seed(0) + inp = calib_input + + def hessian(cfg, name="k"): + torch.manual_seed(0) + g = GPTQ(module=nn.Linear(IN_FEATURES, OUT_FEATURES, bias=False), qcfg=cfg) + g.name = name + g._act_quantizer = g._create_act_quantizer() + g.add_batch(inp, torch.empty(0)) + return g.finalize_hessian().clone() + + block_only = hessian(_qcfg(act=True)) + # An amax far from the data's own range forces a visibly different grid. + with_scale = hessian(_qcfg_sidecar(_sidecar(tmp_path, {"k": 40.0}))) + + assert not torch.allclose(block_only, with_scale), ( + "global scale had no effect on the Hessian — sidecar is not reaching the quantizer" + ) + + +def test_dynamic_fallback_override_survives_caller_baseline(): + """base.py threads the global quantize_config.fallback into loop(); it must be + the baseline, not a clobber -- per-module dynamic `fallback` overrides (the + per-projection rank thresholds) previously never took effect.""" + + from gptqmodel.looper.gptq_processor import clone_gptq_config_for_module + from gptqmodel.quantization.config import Fallback, FallbackStrategy + + qcfg = QuantizeConfig( + bits=4, group_size=BLOCK, format="nvfp4", sym=True, desc_act=False, + dynamic={r".*\.down_proj$": {"fallback": {"strategy": "rtn", "threshold": 3072}}}, + fallback=Fallback(strategy=FallbackStrategy.RTN, threshold=6144), + ) + caller_baseline = Fallback(strategy=FallbackStrategy.RTN, threshold=6144) + + clone = clone_gptq_config_for_module( + qcfg, "model.layers.3.mlp.experts.0.down_proj", fallback=caller_baseline) + assert clone.fallback.threshold == 3072, "dynamic override clobbered by caller fallback" + + clone2 = clone_gptq_config_for_module( + qcfg, "model.layers.3.mlp.experts.0.gate_proj", fallback=caller_baseline) + assert clone2.fallback.threshold == 6144, "non-overridden module must keep the baseline"