Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 115 additions & 19 deletions examples/hf_ptq/example_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.

import contextlib
import copy
import glob
import hashlib
Expand Down Expand Up @@ -53,6 +54,61 @@
SPECULATIVE_MODEL_LIST = ["Eagle", "Medusa"]


class _FP8BF16Fallback:
"""BF16 dequant fallback for block-scaled FP8 matmul when the kernels package is absent.

Calibration amax collection only — not accurate for production inference.
"""

@staticmethod
def matmul(input, weight, weight_scale_inv, block_size, output_dtype=None, activation_scale=None):
out_f, in_f = weight.shape[-2], weight.shape[-1]
nb_out, nb_in = weight_scale_inv.shape[-2], weight_scale_inv.shape[-1]
scale = (
weight_scale_inv.float()
.repeat_interleave(out_f // nb_out, -2)
.repeat_interleave(in_f // nb_in, -1)
)
w_bf16 = (weight.float() * scale).to(torch.bfloat16)
out = torch.nn.functional.linear(input.to(torch.bfloat16), w_bf16)
return out if output_dtype is None else out.to(output_dtype)


def _install_transformers_compat_shims() -> None:
"""Patch transformers so older remote-code models (e.g. DeepSeek-R1) load on
newer/partial installs. Call once before loading a trust_remote_code checkpoint."""
import transformers.utils as _tu
import transformers.utils.import_utils as _tui

# transformers >=5 removed is_torch_fx_available; older bundled model files still import it.
if not hasattr(_tui, "is_torch_fx_available"):
_tui.is_torch_fx_available = lambda: False # type: ignore[attr-defined]

# Broken flash_attn installs (.so undefined-symbol) crash at import time, not find_spec time.
# Force transformers' availability checks to False so bundled models skip the flash-attn path.
try:
import flash_attn # noqa: F401
except Exception:
for _mod in (_tu, _tui):
for _fn in ("is_flash_attn_2_available", "is_flash_attn_available",
"is_flash_attn_greater_or_equal_2_10"):
setattr(_mod, _fn, lambda: False)

# No `kernels` package → block-scaled FP8 matmul fails; swap in lossy BF16 fallback.
with contextlib.suppress(Exception):
import transformers.integrations.finegrained_fp8 as _ff8
try:
_ff8._load_finegrained_fp8_kernel()
except ImportError:
warnings.warn(
"finegrained-fp8 kernel unavailable; using a lossy BF16 dequant fallback "
"for FP8 matmul. Suitable for calibration amax collection only.",
UserWarning,
stacklevel=2,
)
_ff8._load_finegrained_fp8_kernel = lambda: _FP8BF16Fallback # type: ignore[attr-defined]


def run_nemotron_vl_preview(
full_model,
tokenizer,
Expand Down Expand Up @@ -587,16 +643,40 @@ def _apply_dtype_to_config(model_kwargs, config_dtype, architecture, apply_confi
return model_kwargs


def _fmt_max_memory(max_memory: dict) -> str:
"""Format a ``{device: bytes}`` budget dict into a human-readable string."""
parts = []
for key in sorted(max_memory.keys(), key=lambda k: (isinstance(k, str), k)):
val = max_memory[key]
label = f"{val / 1024 ** 3:.1f} GiB" if isinstance(val, int) else str(val)
key_str = f"GPU {key}" if isinstance(key, int) else str(key)
parts.append(f" {key_str}: {label}")
return "\n".join(parts)


def get_model(
ckpt_path,
device="cuda",
gpu_mem_percentage=0.8,
trust_remote_code=False,
use_seq_device_map=False,
attn_implementation=None,
offload_folder=None,
max_cpu_memory_gb=None,
max_gpu_memory_gb=None,
):
_install_transformers_compat_shims()
print(f"Initializing model from {ckpt_path}")

_disk_offload = offload_folder is not None
if _disk_offload and max_cpu_memory_gb is None:
warnings.warn(
"offload_folder is set but max_cpu_memory_gb is not specified. "
"CPU memory usage during model load will be unbounded. "
"Pass max_cpu_memory_gb to cap CPU usage.",
UserWarning,
)

device_map = "auto"
if device == "cpu":
device_map = "cpu"
Expand Down Expand Up @@ -700,12 +780,11 @@ def has_pack_quantized_config(config):
raise ValueError(f"Model config at {ckpt_path} has no architectures defined")
architecture = hf_config.architectures[0]

if not hasattr(transformers, architecture) or "Deepseek" in architecture:
if not hasattr(transformers, architecture):
warnings.warn(
f"Architecture {architecture} not found in transformers: {transformers.__version__}. "
"Falling back to AutoModelForCausalLM (or AutoModel for non-causal architectures)."
)
if not hasattr(transformers, architecture):
warnings.warn(
f"Architecture {architecture} not found in transformers: {transformers.__version__}. "
"Falling back to AutoModelForCausalLM (or AutoModel for non-causal architectures)."
)
assert trust_remote_code, (
"Please set trust_remote_code to True if you want to use this architecture"
)
Expand Down Expand Up @@ -737,24 +816,41 @@ def has_pack_quantized_config(config):
model = from_config(config_for_init, **model_kwargs2)

max_memory = get_max_memory()
inferred_device_map = infer_auto_device_map(model, max_memory=max_memory)

on_cpu = "cpu" in inferred_device_map.values()

if on_cpu:
for _device in max_memory:
if isinstance(_device, int):
max_memory[_device] *= gpu_mem_percentage

if _disk_offload:
for _k in max_memory:
if isinstance(_k, int):
if max_gpu_memory_gb is not None:
max_memory[_k] = int(max_gpu_memory_gb * 1024**3)
else:
max_memory[_k] = int(max_memory[_k] * gpu_mem_percentage)
if max_cpu_memory_gb is not None:
max_memory["cpu"] = int(max_cpu_memory_gb * 1024**3)
model_kwargs["max_memory"] = max_memory
print(
"Model does not fit to the GPU mem. "
f"We apply the following memory limit for calibration: \n{max_memory}\n"
"If you hit GPU OOM issue, please adjust `gpu_mem_percentage` or "
"reduce the calibration `batch_size` manually."
"Disk-offload mode enabled. "
f"Memory budgets: {_fmt_max_memory(max_memory)}\n"
f"Offload folder: {offload_folder}\n"
"Weights exceeding GPU+CPU budgets will be streamed from disk."
)
model_kwargs["max_memory"] = max_memory
else:
inferred_device_map = infer_auto_device_map(model, max_memory=max_memory)
if "cpu" in inferred_device_map.values():
for _device in max_memory:
if isinstance(_device, int):
max_memory[_device] *= gpu_mem_percentage

print(
"Model does not fit to the GPU mem. "
f"We apply the following memory limit for calibration: \n{max_memory}\n"
"If you hit GPU OOM issue, please adjust `gpu_mem_percentage` or "
"reduce the calibration `batch_size` manually."
)
model_kwargs["max_memory"] = max_memory

model_kwargs2 = _apply_dtype_to_config(model_kwargs, config_dtype, architecture)
if _disk_offload:
model_kwargs2["offload_folder"] = offload_folder
model = auto_model_module.from_pretrained(
ckpt_path,
device_map=device_map,
Expand Down
45 changes: 45 additions & 0 deletions examples/hf_ptq/hf_ptq.py
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,9 @@ def load_model(args: argparse.Namespace):
trust_remote_code=args.trust_remote_code,
use_seq_device_map=args.use_seq_device_map,
attn_implementation=args.attn_implementation,
offload_folder=args.offload_folder,
max_cpu_memory_gb=args.max_cpu_memory_gb,
max_gpu_memory_gb=args.max_gpu_memory_gb,
)
else:
assert args.qformat in QUANT_CFG_CHOICES, (
Expand Down Expand Up @@ -1563,6 +1566,38 @@ def parse_args() -> argparse.Namespace:
"openai/gpt-oss-20b) and the target qformat is NVFP4-family."
),
)
parser.add_argument(
"--offload_folder",
type=str,
default=None,
help=(
"Path to a local folder for disk-offloaded model weights. "
"When set, activates disk-offload mode: model weights that exceed the GPU+CPU "
"budgets are streamed from disk during calibration and export. "
"Pair with --max_cpu_memory_gb to cap CPU RAM usage. "
"Incompatible with --low_memory_mode and --use_seq_device_map."
),
)
parser.add_argument(
"--max_cpu_memory_gb",
type=float,
default=None,
help=(
"Maximum CPU RAM budget in GiB for disk-offload model loading. "
"Only effective when --offload_folder is set. "
"Weights beyond this limit are streamed from disk."
),
)
parser.add_argument(
"--max_gpu_memory_gb",
type=float,
default=None,
help=(
"Maximum GPU memory budget per device in GiB for disk-offload model loading. "
"Only effective when --offload_folder is set. "
"Defaults to 80%% of available GPU memory when not specified."
),
)

args = parser.parse_args()
if args.moe_calib_experts_ratio is not None and not (0.0 < args.moe_calib_experts_ratio <= 1.0):
Expand All @@ -1584,6 +1619,16 @@ def parse_args() -> argparse.Namespace:
"the low-memory loader initializes quantizers from --qformat/--kv_cache_qformat."
)

if args.offload_folder is not None and args.low_memory_mode:
parser.error("--offload_folder (disk-offload) is not compatible with --low_memory_mode.")

if args.offload_folder is not None and args.use_seq_device_map:
parser.error(
"--offload_folder (disk-offload) is not compatible with --use_seq_device_map; "
"device_map=auto is used for disk-offload to let accelerate place layers across "
"GPU, CPU, and disk."
)

return args


Expand Down
120 changes: 86 additions & 34 deletions modelopt/torch/export/quant_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -959,6 +959,89 @@ def from_quantized_weight(
raise NotImplementedError(f"quantization format {quantization} not supported")


_KV_CACHE_REPLACEMENTS: dict[str, str] = {
"k_bmm_quantizer._amax": "k_proj.k_scale",
"v_bmm_quantizer._amax": "v_proj.v_scale",
"k_bmm_quantizer._bias_value": "k_proj.k_bias",
"v_bmm_quantizer._bias_value": "v_proj.v_bias",
"input_quantizer._pre_quant_scale": "pre_quant_scale",
}
_QLORA_REPLACEMENTS: dict[str, str] = {
**_KV_CACHE_REPLACEMENTS,
"base_layer.weight": "weight",
"base_layer.input_scale": "input_scale",
"base_layer.weight_scale": "weight_scale",
}
_BASE_SKIP_KEYS: tuple[str, ...] = (
"output_quantizer",
"_amax",
"_bias_value",
"input_quantizer._pre_quant_scale",
"weight_shape",
)
_QLORA_SKIP_KEYS: tuple[str, ...] = (*_BASE_SKIP_KEYS, "base_layer")


def _maybe_squeeze_scale(key: str, value: Any) -> Any:
"""Squeeze a leading dim=1 from 3-D scale tensors of shape (1, n, m)."""
if "scale" in key and isinstance(value, torch.Tensor) and value.dim() == 3 and value.shape[0] == 1:
return value.squeeze(0)
return value


def _postprocess_single_tensor(
key: str,
value: torch.Tensor,
kv_cache_max_bound: float,
kv_cache_format: str | None,
is_modelopt_qlora: bool = False,
) -> tuple[str | None, torch.Tensor | None]:
"""Per-tensor subset of :func:`postprocess_state_dict`, for streaming export.

Returns ``(new_key, new_value)`` to emit, or ``(None, None)`` to skip.
Tied-weight dedup is NOT performed here; callers should pre-compute alias
keys from ``model._tied_weights_keys`` and filter them at the call site.
"""
replacements = _QLORA_REPLACEMENTS if is_modelopt_qlora else _KV_CACHE_REPLACEMENTS
skip_keys = _QLORA_SKIP_KEYS if is_modelopt_qlora else _BASE_SKIP_KEYS

# Skip problematic VL model parameters
if key == "vision_model.radio_model.summary_idxs":
return None, None

# Skip real quant parameters
if any(key.endswith("weight_quantizer." + q) for q in RealQuantLinear.list_of_scale_tensors):
return None, None

# Skip LoRA adapters for QLoRA models
if is_modelopt_qlora and "lora" in key:
return None, None

# Keys not related to quantizers: keep as-is
if all(sk not in key for sk in skip_keys):
return key, _maybe_squeeze_scale(key, value)

# Apply replacements if the key matches any suffix in the replacements dict
for old_suffix, new_suffix in replacements.items():
if key.endswith(old_suffix):
prefix = key[: -len(old_suffix)]
if "_amax" in key:
assert kv_cache_format in [KV_CACHE_FP8, KV_CACHE_NVFP4, KV_CACHE_NVFP4_AFFINE], (
"Invalid KV cache quantization format."
)
assert kv_cache_max_bound > 0, "Maxbound must be greater than zero."
value = value.float() / kv_cache_max_bound
if kv_cache_format == KV_CACHE_FP8 and value.item() > 0.5:
logger.warning(
"Large KV activations detected. Quantized KV cache may lead to higher accuracy drop."
)
new_key = prefix + new_suffix
return new_key, _maybe_squeeze_scale(new_key, value)

# Key has a skip_key but no replacement matched — drop it
return None, None


def postprocess_state_dict(
state_dict: dict,
maxbound: float,
Expand All @@ -976,31 +1059,8 @@ def postprocess_state_dict(
Returns:
The filtered state_dict without unnecessary keys like '_amax' and non KV cache output quantizers.
"""
replacements = {
"k_bmm_quantizer._amax": "k_proj.k_scale",
"v_bmm_quantizer._amax": "v_proj.v_scale",
"k_bmm_quantizer._bias_value": "k_proj.k_bias",
"v_bmm_quantizer._bias_value": "v_proj.v_bias",
"input_quantizer._pre_quant_scale": "pre_quant_scale",
}
skip_keys = [
"output_quantizer",
"_amax",
"_bias_value",
"input_quantizer._pre_quant_scale",
"weight_shape",
]

# For modelopt-trained LoRA models, we need to remove the base_layer prefix from the keys for deployment
if is_modelopt_qlora:
replacements.update(
{
"base_layer.weight": "weight",
"base_layer.input_scale": "input_scale",
"base_layer.weight_scale": "weight_scale",
}
)
skip_keys.append("base_layer")
replacements = _QLORA_REPLACEMENTS if is_modelopt_qlora else _KV_CACHE_REPLACEMENTS
skip_keys = _QLORA_SKIP_KEYS if is_modelopt_qlora else _BASE_SKIP_KEYS

post_state_dict = {}

Expand Down Expand Up @@ -1036,15 +1096,7 @@ def postprocess_state_dict(
post_state_dict[prefix + new_suffix] = value
break

# Squeeze scales with a leading dimension of 1
for key, value in post_state_dict.items():
if (
"scale" in key
and isinstance(value, torch.Tensor)
and value.dim() == 3
and value.shape[0] == 1
):
post_state_dict[key] = value.squeeze(0)
post_state_dict = {k: _maybe_squeeze_scale(k, v) for k, v in post_state_dict.items()}

# remove real quant parameters from the state dict
keys_to_delete = []
Expand Down
Loading
Loading