"""Quantize a Hugging Face model directory to NVFP4 Hugging Face format. Reads a Hugging Face model directory (config.json + safetensors) and writes a quantized Hugging Face model directory. By default (--format hf), writes standard Hugging Face compressed-tensors format with .weight_packed, .weight_scale, and .weight_global_scale natively supported by Hugging Face transformers, vLLM, SGLang, and llama.cpp. Optionally (--format modelopt / comfy), outputs raw weight tensors (.weight), weight_scale, weight_scale_2, and .comfy_quant for ModelOpt and ComfyUI. Weight matrices are quantized using convert_to_quant's learned rounding (or simple round-to-grid with --simple). Biases are adjusted using calibration data unless --no-bias-correction is specified. Embeddings, layer norms, and excluded layers stay in their float format. """ from __future__ import annotations import argparse import hashlib import json import logging import os import re import shutil import sys from pathlib import Path import numpy as np import torch from safetensors import safe_open from safetensors.torch import save_file from nvfp4gguf import ( CTQ_NUM_ITER, DEFAULT_CONVERT_TO_QUANT, DEFAULT_LLAMA_CPP, DEFAULT_EARLY_STOP_FLOOR_STALL, DEFAULT_TARGET_LOSS, DEFAULT_EARLY_STOP_STALL, DEFAULT_LR_FLOOR, DEFAULT_LR_PATIENCE, DEFAULT_LR_THRESHOLD, DEFAULT_SCALE_OPTIMIZATION, DEFAULT_SCALE_REFINEMENT_ROUNDS, QUANT_VERSION, TensorQuantizer, add_quant_arguments, bind_exclude, coordinate_descent_refine, ctq_skips_key, ensure_cuda_interpreter, load_learned_converter, quant_profile, report_reconstruction, resolve_optimizer, sweep_per_tensor_scale, ue4m3_bits, ) INDEX_NAME = "model.safetensors.index.json" NON_WEIGHT_PATTERNS = { ".safetensors", ".bin", ".pt", ".pth", ".h5", ".msgpack", ".ot", ".tmp", } log = logging.getLogger("hf_to_nvfp4_hf") def _checkpoint_file(directory: Path, name: str) -> Path: digest = hashlib.sha1(name.encode("utf-8")).hexdigest()[:16] safe = re.sub(r"[^A-Za-z0-9._-]+", "_", name)[:80] return directory / f"{safe}.{digest}.npz" def _load_checkpoint(directory: Path, name: str, shape: tuple[int, ...], need_bias: bool): path = _checkpoint_file(directory, name) if not path.is_file(): return None with np.load(path) as blob: if "version" in blob.files and int(blob["version"]) != QUANT_VERSION: return None if tuple(int(v) for v in blob["shape"]) != tuple(shape): return None if need_bias and "bias" not in blob.files: return None codes = np.array(blob["codes"], copy=True) block_scale = np.array(blob["block_scale"], copy=True) per_tensor = float(blob["per_tensor"]) bias = np.array(blob["bias"], copy=True) if "bias" in blob.files else None return codes, block_scale, per_tensor, bias def _save_checkpoint(directory: Path, name: str, shape: tuple[int, ...], codes, block_scale, per_tensor, bias) -> None: path = _checkpoint_file(directory, name) temporary = path.with_name(path.name + ".tmp") with temporary.open("wb") as handle: save_dict = { "version": np.int32(QUANT_VERSION), "shape": np.asarray(shape, dtype=np.int64), "codes": np.asarray(codes, dtype=np.uint8), "block_scale": np.asarray(block_scale, dtype=np.float32), "per_tensor": np.float32(per_tensor), } if bias is not None: save_dict["bias"] = np.asarray(bias, dtype=np.float32) np.savez_compressed(handle, **save_dict) temporary.replace(path) def discover_shards(model_dir: Path) -> list[str]: """Return safetensors shard filenames in the checkpoint directory.""" index_path = model_dir / INDEX_NAME if index_path.is_file(): try: payload = json.loads(index_path.read_text(encoding="utf-8")) weight_map = payload.get("weight_map") if isinstance(weight_map, dict) and weight_map: names = sorted(set(weight_map.values())) missing = [name for name in names if not (model_dir / name).is_file()] if not missing: return names except Exception: pass single = model_dir / "model.safetensors" if single.is_file(): return ["model.safetensors"] shards = sorted(path.name for path in model_dir.glob("*.safetensors") if path.is_file()) if not shards: raise SystemExit(f"No safetensors shards found in {model_dir}") return shards def _assign_output(args) -> None: source = Path(args.model).resolve() stem = source.name passthrough = args.no_quant or args.outtype == "nvfp4" if not args.output: if passthrough and args.outtype == "auto": directory = Path.cwd() / stem elif passthrough: label = "NVFP4" if args.outtype == "nvfp4" else args.outtype.upper() directory = Path.cwd() / f"{stem}-{label}" else: directory = Path.cwd() / f"{stem}-NVFP4" else: directory = Path(args.output).resolve() args.output = str(directory) def _apply_directory_profile(args, source: Path) -> None: config_path = source / "config.json" if not config_path.is_file(): return try: config = json.loads(config_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return names: list[str] = [] architectures = config.get("architectures") if isinstance(architectures, list): names.extend(str(item) for item in architectures) elif isinstance(architectures, str): names.append(architectures) model_type = config.get("model_type") if isinstance(model_type, str) and model_type: names.append(model_type) for name in names: profile = quant_profile(name) if profile is None: continue if not args.exclude: args.exclude = profile.skip log.info("%s profile: leaving %s unquantized.", profile.arch, profile.label) else: log.info("%s profile: --exclude replaces default skip of %s.", profile.arch, profile.label) return def _copy_non_weight_files(source_dir: Path, output_dir: Path) -> None: """Copy configs, tokenizers, chat templates, and documentation into output.""" output_dir.mkdir(parents=True, exist_ok=True) for path in source_dir.iterdir(): if path.is_dir() or path.name.startswith("."): continue if path.name == "config.json" or path.name == INDEX_NAME: continue if any(path.name.endswith(ext) for ext in NON_WEIGHT_PATTERNS): continue dest = output_dir / path.name if not dest.exists() or dest.stat().st_mtime < path.stat().st_mtime: shutil.copy2(path, dest) def _write_config_json(source_dir: Path, output_dir: Path, args, is_quantized: bool) -> None: config_path = source_dir / "config.json" if not config_path.is_file(): return try: config = json.loads(config_path.read_text(encoding="utf-8")) except Exception as exc: log.warning("Could not parse config.json: %s", exc) return fmt = getattr(args, "format", "hf") def _build_ct_quant_config(): ignore = [] if not getattr(args, "quantize_output", False): ignore.append("lm_head") if getattr(args, "exclude", None): ignore.append(args.exclude) return { "quant_method": "compressed-tensors", "format": "nvfp4-pack-quantized", "quantization_status": "compressed", "ignore": ignore, "config_groups": { "group_0": { "weights": { "num_bits": 4, "type": "float", "strategy": "tensor_group", "group_size": 16, "symmetric": True, "dynamic": False, "actorder": None, }, "input_activations": None, "targets": ["Linear"], "format": "nvfp4-pack-quantized", } }, } if is_quantized: if fmt == "hf": config["quantization_config"] = _build_ct_quant_config() else: config["quantization_config"] = { "quant_method": "modelopt", "quant_algo": "NVFP4", "format": "nvfp4-pack-quantized", "group_size": 16, "bits": 4, } config["torch_dtype"] = getattr(args, "output_dtype", "bfloat16") elif args.no_quant: if args.outtype in ("f32", "f16", "bf16"): config.pop("quantization_config", None) config["torch_dtype"] = "bfloat16" if args.outtype == "bf16" else ("float16" if args.outtype == "f16" else "float32") elif args.outtype == "nvfp4": if fmt == "hf": config["quantization_config"] = _build_ct_quant_config() else: config.setdefault("quantization_config", { "quant_method": "modelopt", "quant_algo": "NVFP4", "format": "nvfp4-pack-quantized", "group_size": 16, "bits": 4, }) if getattr(args, "context_length", None): for key in ("max_position_embeddings", "seq_length", "max_sequence_length"): if key in config: config[key] = int(args.context_length) out_file = output_dir / "config.json" out_file.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8") def pack_nvfp4_hf(codes: np.ndarray, block_scales: np.ndarray, per_tensor: float): """Pack codes and scales into NVFP4 tensors.""" # Packed weights: low nibble is element 0, high nibble is element 1 packed = (codes[:, 0::2] & 0x0F) | ((codes[:, 1::2] & 0x0F) << 4) packed_tensor = torch.from_numpy(np.ascontiguousarray(packed, dtype=np.uint8)) scale_bits = ue4m3_bits(block_scales) scale_tensor = torch.from_numpy(np.ascontiguousarray(scale_bits, dtype=np.uint8)).view(torch.float8_e4m3fn) scale_2_tensor = torch.tensor(float(per_tensor), dtype=torch.float32) return packed_tensor, scale_tensor, scale_2_tensor def _should_quantize( key: str, shape: tuple[int, ...], args, convert_to_quant_dir: Path | None, ) -> bool: if len(shape) != 2: return False if not (key.endswith(".weight") or key.endswith(".weight_packed")): return False if shape[-1] % 64 != 0 or shape[-1] < 64: return False if key in ("lm_head.weight", "lm_head.weight_packed") and not getattr(args, "quantize_output", False): return False lowered = key.lower() if "embed_tokens" in lowered or "wte" in lowered or "word_embeddings" in lowered: return False if "norm" in lowered or "ln" in lowered: return False if getattr(args, "exclude_re", None) and args.exclude_re.search(key): return False if ctq_skips_key(key, convert_to_quant_dir): return False return True def _target_dtype(dtype_str: str) -> torch.dtype: mapping = { "float32": torch.float32, "f32": torch.float32, "float16": torch.float16, "f16": torch.float16, "bfloat16": torch.bfloat16, "bf16": torch.bfloat16, } return mapping.get(dtype_str.lower(), torch.bfloat16) def export_directory(args) -> None: source_dir = Path(args.model).resolve() if not source_dir.is_dir(): raise FileNotFoundError(f"Model directory does not exist: {source_dir}") _assign_output(args) output_dir = Path(args.output).resolve() if output_dir == source_dir: raise SystemExit("Output directory cannot be the same as the input directory.") if output_dir.exists() and any(output_dir.iterdir()) and not args.overwrite: raise SystemExit(f"Output directory {output_dir} already exists and is not empty. Use --overwrite to replace.") shards = discover_shards(source_dir) _apply_directory_profile(args, source_dir) args.exclude_re = re.compile(args.exclude) if args.exclude else None logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO, format="%(message)s") log.info("Loading %s", source_dir) log.info("Found %d safetensors shard(s)", len(shards)) log.info("Target format: %s", "Hugging Face (compressed-tensors)" if args.format == "hf" else "ModelOpt / ComfyUI") output_dir.mkdir(parents=True, exist_ok=True) _copy_non_weight_files(source_dir, output_dir) is_passthrough = args.no_quant or args.outtype == "nvfp4" float_dtype = _target_dtype(args.output_dtype if not is_passthrough else (args.outtype if args.outtype != "auto" else "bfloat16")) if is_passthrough: log.info("Quantization skipped. Converting tensors to %s", args.outtype) weight_map: dict[str, str] = {} total_size = 0 for shard_name in shards: log.info("Processing %s", shard_name) src_shard = source_dir / shard_name out_shard = output_dir / shard_name out_tensors: dict[str, torch.Tensor] = {} with safe_open(src_shard, framework="pt", device="cpu") as handle: for key in handle.keys(): t = handle.get_tensor(key) if args.outtype in ("f32", "f16", "bf16") and t.is_floating_point(): t = t.to(dtype=float_dtype) out_tensors[key] = t weight_map[key] = shard_name total_size += t.numel() * t.element_size() save_file(out_tensors, out_shard) _write_config_json(source_dir, output_dir, args, is_quantized=False) if len(shards) > 1: (output_dir / INDEX_NAME).write_text( json.dumps({"metadata": {"total_size": total_size}, "weight_map": weight_map}, indent=2) + "\n", encoding="utf-8", ) log.info("Wrote %s", output_dir) return # Learned NVFP4 Quantization ensure_cuda_interpreter(args.device) args.optimizer = resolve_optimizer(args.optimizer) # Prepare checkpoint directory for per-tensor resume checkpoint_dir = Path(args.checkpoint_dir) if args.checkpoint_dir else output_dir / ".nvfp4-work" if args.fresh and checkpoint_dir.exists(): shutil.rmtree(checkpoint_dir) checkpoint_dir.mkdir(parents=True, exist_ok=True) # Initialize quantizer import gguf quantizer = TensorQuantizer(args, gguf, load_learned_converter(args.convert_to_quant)[1]) # First pass: collect all tensor keys and shapes across shards all_keys: dict[str, tuple[str, tuple[int, ...]]] = {} for shard_name in shards: with safe_open(source_dir / shard_name, framework="pt", device="cpu") as handle: for key in handle.keys(): slice_header = handle.get_slice(key) all_keys[key] = (shard_name, tuple(int(d) for d in slice_header.get_shape())) quant_count = 0 passthrough_count = 0 bias_count = 0 weight_map: dict[str, str] = {} total_size = 0 all_quant_meta: dict[str, dict] = {} for s_idx, shard_name in enumerate(shards, start=1): log.info("\n[%d/%d] Processing shard: %s", s_idx, len(shards), shard_name) src_shard = source_dir / shard_name out_shard = output_dir / shard_name shard_tensors: dict[str, torch.Tensor] = {} with safe_open(src_shard, framework="pt", device="cpu") as handle: keys_in_shard = list(handle.keys()) for key in keys_in_shard: tensor = handle.get_tensor(key) shape = tuple(int(d) for d in tensor.shape) base_key = key.removesuffix(".weight_packed").removesuffix(".weight") if _should_quantize(key, shape, args, args.convert_to_quant): log.info(" Quantizing [%s] %s", "x".join(str(d) for d in shape), key) weight_np = tensor.detach().float().cpu().numpy() # Check for sibling bias bias_key = f"{base_key}.bias" has_bias = bias_key in all_keys and not args.no_bias_correction bias_shard = all_keys[bias_key][0] if has_bias else None # Check checkpoint cache cached = _load_checkpoint(checkpoint_dir, key, shape, need_bias=has_bias) if cached is not None: codes, block_scale, per_tensor, bias_corr = cached log.info(" Loaded from checkpoint: %s", key) else: rounds = getattr(args, "scale_refinement_rounds", DEFAULT_SCALE_REFINEMENT_ROUNDS) codes, block_scale, per_tensor, dequant = quantizer.quantize_matrix(weight_np, key) codes, block_scale = coordinate_descent_refine( weight_np, codes, block_scale, per_tensor, bake=False, rounds=rounds, device=quantizer.device, ) codes, block_scale, per_tensor = sweep_per_tensor_scale( weight_np, codes, block_scale, per_tensor, bake=False, device=quantizer.device, ) bias_corr = None if has_bias: bias_corr = quantizer.bias_correction(weight_np, dequant) _save_checkpoint(checkpoint_dir, key, shape, codes, block_scale, per_tensor, bias_corr) packed_w, scale_1, scale_2 = pack_nvfp4_hf(codes, block_scale, per_tensor) if args.format == "hf": # Standard Hugging Face compressed-tensors layout shard_tensors[f"{base_key}.weight_packed"] = packed_w shard_tensors[f"{base_key}.weight_scale"] = scale_1 shard_tensors[f"{base_key}.weight_global_scale"] = torch.tensor(1.0 / float(per_tensor), dtype=torch.float32) else: # ModelOpt / ComfyUI layout shard_tensors[f"{base_key}.weight"] = packed_w shard_tensors[f"{base_key}.weight_scale"] = scale_1 shard_tensors[f"{base_key}.weight_scale_2"] = scale_2 shard_tensors[f"{base_key}.comfy_quant"] = torch.tensor([1], dtype=torch.uint8) quant_count += 1 all_quant_meta[base_key] = { "quant_type": "nvfp4", "block_size": 16, "scale_type": "float8_e4m3fn", } # Bias correction application if bias is in the same shard if has_bias and bias_shard == shard_name and bias_corr is not None: bias_t = handle.get_tensor(bias_key).float() corrected_bias = bias_t - torch.from_numpy(bias_corr).to(bias_t.device) shard_tensors[bias_key] = corrected_bias.to(dtype=float_dtype) bias_count += 1 else: # Non-quantized tensors (or bias already processed) if key in shard_tensors: continue if tensor.is_floating_point(): if tensor.ndim <= 1 and key.endswith("norm.weight"): tensor = tensor.float() else: tensor = tensor.to(dtype=float_dtype) shard_tensors[key] = tensor passthrough_count += 1 shard_metadata = {} if args.format != "hf": shard_metadata["_quantization_metadata"] = json.dumps({"format_version": "1.0", "layers": all_quant_meta}, separators=(",", ":")) for k, t in shard_tensors.items(): weight_map[k] = shard_name total_size += t.numel() * t.element_size() save_file(shard_tensors, out_shard, metadata=shard_metadata if shard_metadata else None) _write_config_json(source_dir, output_dir, args, is_quantized=True) if len(shards) > 1: index_payload = { "metadata": {"total_size": total_size}, "weight_map": weight_map, } (output_dir / INDEX_NAME).write_text(json.dumps(index_payload, indent=2) + "\n", encoding="utf-8") if not args.keep_checkpoints and quant_count: shutil.rmtree(checkpoint_dir, ignore_errors=True) log.info( "\nWrote %s (%d NVFP4 tensors, %d biases adjusted, %d other tensors)", output_dir, quant_count, bias_count, passthrough_count, ) def self_test(args) -> None: """Check that packed blocks roundtrip accurately with dequantizer.""" logging.basicConfig(level=logging.INFO, format="%(message)s") from nvfp4gguf import FP4_TABLE import gguf log.info("Running NVFP4 roundtrip self-test...") _converter_cls, unpack_uint4 = load_learned_converter(args.convert_to_quant) quantizer = TensorQuantizer(args, gguf, unpack_uint4) generator = np.random.default_rng(0) for shape in ((64, 128), (128, 256)): weight = generator.normal(size=shape).astype(np.float32) * 0.05 codes, block_scale, per_tensor, dequant = quantizer.quantize_matrix(weight) packed_w, scale_1, scale_2 = pack_nvfp4_hf(codes, block_scale, per_tensor) # Dequantize packed tensor w_u = packed_w.numpy() c0 = w_u & 0x0F c1 = (w_u >> 4) & 0x0F unpacked_codes = np.empty(shape, dtype=np.uint8) unpacked_codes[:, 0::2] = c0 unpacked_codes[:, 1::2] = c1 assert np.array_equal(codes, unpacked_codes), "Nibble unpacking mismatch!" # Check values fp4_vals = FP4_TABLE[unpacked_codes] b_scales = block_scale.repeat(16, axis=1) recon = fp4_vals * b_scales * per_tensor max_diff = np.max(np.abs(recon - dequant)) assert max_diff < 1e-4, f"Reconstruction difference {max_diff} exceeded tolerance!" log.info("Shape %s roundtrip ok (max diff %.3e)", shape, max_diff) log.info("self-test passed successfully.") def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser( description="Quantize a Hugging Face model directory to NVFP4 Hugging Face format.", ) parser.add_argument("model", nargs="?", help="Hugging Face model directory (config.json + safetensors)") parser.add_argument("-o", "--output", help="Output directory. Default: ./-NVFP4") parser.add_argument("--format", default="hf", choices=("hf", "modelopt", "comfy"), help="Safetensors tensor layout: 'hf' (default) uses Hugging Face compressed-tensors (.weight_packed, .weight_scale, .weight_global_scale). 'modelopt' (or 'comfy') uses raw .weight.") parser.add_argument("--overwrite", action="store_true", help="Replace existing files in the output directory") parser.add_argument("--no-quant", action="store_true", help="Skip learned rounding. Rewrites or repacks tensors") parser.add_argument("--outtype", default="auto", choices=("auto", "f32", "f16", "bf16", "nvfp4"), help="Float type for tensors when --no-quant is used (default: auto)") parser.add_argument("--output-dtype", default="bfloat16", choices=("bfloat16", "float16"), help="Dtype for unquantized 2D weights and embeddings (default: bfloat16)") parser.add_argument("--context-length", type=int, default=None, help="Override context length stored in config.json") parser.add_argument("--model-name", default=None, help="Override model name") parser.add_argument("--checkpoint-dir", type=Path, default=None, help="Per-tensor resume directory") parser.add_argument("--fresh", action="store_true", help="Delete checkpoint directory before starting") parser.add_argument("--keep-checkpoints", action="store_true", help="Keep per-tensor checkpoints upon completion") parser.add_argument("--eager", action="store_true", help="Eager loading") parser.add_argument("--low-memory", action="store_true", help="Stream each shard instead of loading all at once") parser.add_argument("--heur", action="store_true", help="Skip layers heuristic rejects") parser.add_argument("--verbose", action="store_true", help="Enable verbose logging") parser.add_argument("--self-test", action="store_true", help="Run NVFP4 packing self-test and exit") parser.add_argument("--llama-cpp", type=Path, default=DEFAULT_LLAMA_CPP, help="llama.cpp checkout directory (default: ~/llama.cpp)") add_quant_arguments(parser) args = parser.parse_args(argv) if args.self_test: if args.device == "auto": args.device = "cpu" return args if not args.model: parser.error("model directory is required") if args.convert_to_quant is None and DEFAULT_CONVERT_TO_QUANT.is_dir(): args.convert_to_quant = DEFAULT_CONVERT_TO_QUANT return args def main(argv: list[str] | None = None) -> None: args = parse_args(argv) if args.self_test: self_test(args) return export_directory(args) if __name__ == "__main__": main()