#!/usr/bin/env python3 """Quantize a GGUF to llama.cpp NVFP4, or repack one that is already NVFP4. The learned-rounding flags match hf_to_nvfp4_gguf.py. Vocabulary, tensor names, and architecture metadata are copied from the source GGUF. --outtype nvfp4 copies an existing NVFP4 file without fitting it again. --no-quant with f32, f16, bf16, or auto rewrites the tensors as float, or leaves them as stored. """ from __future__ import annotations import argparse import hashlib import logging import re import shutil from pathlib import Path import numpy as np 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_SCALE_OPTIMIZATION, DEFAULT_SCALE_REFINEMENT_ROUNDS, QUANT_VERSION, TensorQuantizer, bind_exclude, eligible_weight, ensure_cuda_interpreter, import_gguf, load_learned_converter, must_bake_nvfp4_scale, mtp_layer_tensor, bake_packed_nvfp4, preserve_scale_tensor, quantize_and_pack, report_reconstruction, require_quant_settings, resolve_optimizer, scale_bake_notice, ) from nvfp4gguf.bundle import ( flatten_chat_template, normalize_chat_template, is_model_context_key, outtype as _outtype, refuse_existing_bundle, require_context_length, scalar_context_length, warn_missing_gguf_metadata, write_simple_modelfile, ) log = logging.getLogger("gguf_to_nvfp4_gguf") def _checkpoint_path(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 _pythonize(value): if isinstance(value, np.generic): return value.item() if isinstance(value, np.ndarray): return value.tolist() if isinstance(value, list): return [_pythonize(item) for item in value] return value def _text(value) -> str: value = _pythonize(value) if isinstance(value, bytes): return value.decode("utf-8", "replace") if isinstance(value, list): return "".join(_text(item) for item in value) return str(value) def _logical_shape(tensor) -> tuple[int, ...]: return tuple(int(dim) for dim in tensor.shape[::-1]) def _to_float(tensor, gguf_module) -> np.ndarray: qtype = tensor.tensor_type if qtype == gguf_module.GGMLQuantizationType.F32: values = np.asarray(tensor.data, dtype=np.float32) elif qtype == gguf_module.GGMLQuantizationType.F16: values = np.asarray(tensor.data, dtype=np.float32) else: values = np.asarray(gguf_module.quants.dequantize(tensor.data, qtype), dtype=np.float32) # The reader maps the file read-only. Torch refuses a non-writable view. return np.array(values, dtype=np.float32, copy=True) def _open_reader(gguf, path: Path): if not path.is_file(): raise SystemExit(f"GGUF file does not exist: {path}") reader = gguf.GGUFReader(path) if reader.byte_order != "I": raise SystemExit(f"{path} is not little-endian GGUF") return reader def _architecture(reader) -> str: field = reader.fields.get("general.architecture") if field is None: return "unknown" return _text(field.contents()) def _file_type_name(reader, gguf_module) -> str: field = reader.fields.get("general.file_type") if field is None: return "" try: return gguf_module.LlamaFileType(int(field.contents())).name except (TypeError, ValueError): return str(field.contents()) def _is_nvfp4(reader, gguf_module) -> bool: if _file_type_name(reader, gguf_module) == "MOSTLY_NVFP4": return True return any(tensor.tensor_type == gguf_module.GGMLQuantizationType.NVFP4 for tensor in reader.tensors) def _copy_metadata(reader, writer, gguf_module, file_type: int | None, model_name: str | None, context_length: int | None) -> None: requested = require_context_length(context_length) source_context = None wrote_context = False for field in reader.fields.values(): if field.name.startswith("GGUF.") or field.name == "general.architecture" or not field.types: continue value = _pythonize(field.contents()) if value is None: continue value_type = field.types[0] if value_type == gguf_module.GGUFValueType.ARRAY and not value: continue if field.name == "general.file_type" and file_type is not None: value = int(file_type) if field.name == "general.name" and model_name: value = model_name if field.name == "general.alignment": writer.data_alignment = int(value) subtype = field.types[-1] if value_type == gguf_module.GGUFValueType.ARRAY else None if field.name == "tokenizer.chat_template" or field.name.startswith("tokenizer.chat_template."): value = normalize_chat_template(value) if is_model_context_key(field.name): source_context = scalar_context_length(value) if requested is not None: value = requested value_type = gguf_module.GGUFValueType.UINT32 subtype = None wrote_context = True writer.add_key_value(field.name, value, value_type, sub_type=subtype) if file_type is not None and "general.file_type" not in reader.fields: writer.add_file_type(int(file_type)) if model_name and "general.name" not in reader.fields: writer.add_name(model_name) if requested is not None and not wrote_context: writer.add_context_length(requested) if requested is not None: log.info("context length %d", requested) elif source_context is not None: log.info("context length %d from the source GGUF", source_context) def _replaced_scale(name: str, quant_names: set[str], architecture: str) -> bool: if not (name.endswith(".scale") or name.endswith(".input_scale")): return False stem = name.rsplit(".", 1)[0] weight = stem + ".weight" if weight not in quant_names: return False return not (name.endswith(".scale") and preserve_scale_tensor(architecture, weight)) def _keep_f32(name: str, rank: int) -> bool: return rank < 2 or "norm" in name.lower() def _add_float(writer, gguf_module, name: str, values: np.ndarray, outtype: str) -> None: values = np.ascontiguousarray(values, dtype=np.float32) if outtype == "f16": writer.add_tensor(name, values.astype(np.float16)) return if outtype == "bf16": raw = gguf_module.quants.quantize(values, gguf_module.GGMLQuantizationType.BF16) writer.add_tensor(name, raw, raw_dtype=gguf_module.GGMLQuantizationType.BF16) return writer.add_tensor(name, values) def _copy_tensor(writer, tensor) -> None: writer.add_tensor(tensor.name, tensor.data, raw_dtype=tensor.tensor_type) def _load_checkpoint(directory: Path, name: str, shape: tuple[int, ...]): path = _checkpoint_path(directory, name) if not path.is_file(): return None with np.load(path) as blob: if int(blob["version"]) != QUANT_VERSION: return None if tuple(int(v) for v in blob["shape"]) != tuple(shape): return None raw = np.array(blob["raw"], copy=True) scale = np.array(blob["scale"], copy=True) bias = np.array(blob["bias"], copy=True) return raw, None if scale.size == 0 else scale, None if bias.size == 0 else bias def _save_checkpoint(directory: Path, name: str, shape: tuple[int, ...], raw, scale, bias) -> None: path = _checkpoint_path(directory, name) temporary = path.with_name(path.name + ".tmp") with temporary.open("wb") as handle: np.savez_compressed( handle, version=np.int32(QUANT_VERSION), shape=np.asarray(shape, dtype=np.int64), raw=raw, scale=np.zeros((0,), np.float32) if scale is None else np.asarray(scale, dtype=np.float32), bias=np.zeros((0,), np.float32) if bias is None else np.asarray(bias, dtype=np.float32), ) temporary.replace(path) def _assign_output(args) -> None: source = Path(args.gguf) stem = source.stem passthrough = args.no_quant or args.outtype == "nvfp4" if args.output is None: if passthrough and args.outtype == "auto": directory = Path.cwd() / stem gguf_name = f"{stem}.gguf" elif passthrough: label = "NVFP4" if args.outtype == "nvfp4" else args.outtype.upper() directory = Path.cwd() / f"{stem}-{label}" gguf_name = f"{directory.name}.gguf" else: directory = Path.cwd() / f"{stem}-NVFP4" gguf_name = f"{directory.name}.gguf" else: output = Path(args.output) if output.suffix == ".gguf": output.parent.mkdir(parents=True, exist_ok=True) args.output = str(output.resolve()) return directory = output gguf_name = f"{directory.name}.gguf" directory = directory.resolve() directory.mkdir(parents=True, exist_ok=True) args.output = str(directory / gguf_name) def _refuse_same_file(args) -> None: if Path(args.output).resolve() == Path(args.gguf).resolve(): raise SystemExit("Output GGUF is the same file as the input. Choose a different --output.") def _target_file_type(gguf_module, outtype: str, source_name: str): if outtype == "nvfp4": return gguf_module.LlamaFileType.MOSTLY_NVFP4 if outtype == "f32": return gguf_module.LlamaFileType.ALL_F32 if outtype == "f16": return gguf_module.LlamaFileType.MOSTLY_F16 if outtype == "bf16": return gguf_module.LlamaFileType.MOSTLY_BF16 if source_name == "ALL_F32": return gguf_module.LlamaFileType.ALL_F32 if source_name == "MOSTLY_F16": return gguf_module.LlamaFileType.MOSTLY_F16 if source_name == "MOSTLY_BF16": return gguf_module.LlamaFileType.MOSTLY_BF16 if source_name == "MOSTLY_NVFP4": return gguf_module.LlamaFileType.MOSTLY_NVFP4 return None def _chat_template(reader) -> str: parts = [] for name, field in reader.fields.items(): if name == "tokenizer.chat_template" or name.startswith("tokenizer.chat_template."): parts.append(flatten_chat_template(field.contents())) return "\n".join(part for part in parts if part) def _kv_int(reader, key: str) -> int: field = reader.fields.get(key) if field is None: return 0 try: return int(field.contents()) except (TypeError, ValueError): return 0 def _mtp_counts(reader, architecture: str) -> tuple[int, int]: return _kv_int(reader, f"{architecture}.block_count"), _kv_int(reader, f"{architecture}.nextn_predict_layers") def _folded_scales(reader, gguf_module, architecture: str, n_blocks: int, n_nextn: int) -> dict[str, float]: """Per-tensor NVFP4 scales the llama.cpp graph never multiplies.""" by_name = {tensor.name: tensor for tensor in reader.tensors} nvfp4 = gguf_module.GGMLQuantizationType.NVFP4 scales: dict[str, float] = {} for tensor in reader.tensors: if not tensor.name.endswith(".scale"): continue weight_name = tensor.name[: -len(".scale")] + ".weight" weight = by_name.get(weight_name) if weight is None or weight.tensor_type != nvfp4: continue if preserve_scale_tensor(architecture, weight_name): continue bias_name = weight_name[: -len(".weight")] + ".bias" fold = mtp_layer_tensor(weight_name, n_blocks, n_nextn) or must_bake_nvfp4_scale( architecture, weight_name, bias_name in by_name ) if not fold: continue values = np.asarray(tensor.data, dtype=np.float32).reshape(-1) if values.size != 1: log.warning( "Leaving %s in place. Baking expects one per-tensor scale, got %d values.", tensor.name, int(values.size), ) continue scales[tensor.name] = float(values[0]) return scales def export_copy(args) -> None: """Rewrite the GGUF without learned rounding.""" gguf = import_gguf(args.llama_cpp) source = Path(args.gguf).resolve() output = Path(args.output).resolve() reader = _open_reader(gguf, source) if args.outtype == "nvfp4" and not _is_nvfp4(reader, gguf): raise SystemExit( f"{source} is not an NVFP4 GGUF. " "Use --outtype f16, bf16, f32, or auto to write float weights, " "or omit --outtype nvfp4 to fit NVFP4 from these weights." ) architecture = _architecture(reader) source_type = _file_type_name(reader, gguf) file_type = _target_file_type(gguf, args.outtype, source_type) if args.outtype == "nvfp4": log.info("Copying existing NVFP4 tensors. Learned rounding is skipped.") elif args.outtype == "auto": log.info("Copying tensors as stored (%s). Learned rounding is skipped.", source_type or "unknown file type") else: log.info("Writing float GGUF (%s). Learned rounding is skipped.", args.outtype) writer = gguf.GGUFWriter(output, architecture, use_temp_file=not args.no_temp_file) _copy_metadata(reader, writer, gguf, None if file_type is None else int(file_type), args.model_name, args.context_length) n_blocks, n_nextn = _mtp_counts(reader, architecture) folded = _folded_scales(reader, gguf, architecture, n_blocks, n_nextn) if args.outtype in ("auto", "nvfp4") else {} if folded: log.info( "Folding %d scale tensors into their NVFP4 blocks. " "llama.cpp does not claim those sidecars.", len(folded), ) for tensor in reader.tensors: if tensor.name in folded: continue scale_name = tensor.name[: -len(".weight")] + ".scale" if tensor.name.endswith(".weight") else "" if scale_name in folded and tensor.tensor_type == gguf.GGMLQuantizationType.NVFP4: baked = bake_packed_nvfp4(np.array(tensor.data, copy=True), folded[scale_name]) writer.add_tensor(tensor.name, baked, raw_dtype=gguf.GGMLQuantizationType.NVFP4) continue if args.outtype in ("auto", "nvfp4"): _copy_tensor(writer, tensor) continue if _keep_f32(tensor.name, len(_logical_shape(tensor))): if tensor.tensor_type == gguf.GGMLQuantizationType.F32: _copy_tensor(writer, tensor) else: _add_float(writer, gguf, tensor.name, _to_float(tensor, gguf), "f32") continue _add_float(writer, gguf, tensor.name, _to_float(tensor, gguf), args.outtype) log.info("Writing %s", output) warn_missing_gguf_metadata(writer) writer.write_header_to_file() writer.write_kv_data_to_file() writer.write_tensors_to_file(progress=True) writer.close() write_simple_modelfile(output, args.overwrite, args.mmproj, _chat_template(reader)) log.info("Wrote %s", output) def _emit_bias(writer, gguf_module, tensor, correction: np.ndarray | None) -> bool: if correction is None: _copy_tensor(writer, tensor) return False values = _to_float(tensor, gguf_module) flat = values.reshape(-1) if flat.shape != correction.shape: log.warning(" bias %s shape %s does not match correction %s", tensor.name, values.shape, correction.shape) _copy_tensor(writer, tensor) return False values = (flat - correction).reshape(values.shape) log.info(" bias %s adjusted (mean %+.3e)", tensor.name, float(correction.mean())) if tensor.tensor_type == gguf_module.GGMLQuantizationType.F16: writer.add_tensor(tensor.name, np.ascontiguousarray(values, dtype=np.float32).astype(np.float16)) return True if tensor.tensor_type == gguf_module.GGMLQuantizationType.BF16: raw = gguf_module.quants.quantize( np.ascontiguousarray(values, dtype=np.float32), gguf_module.GGMLQuantizationType.BF16, ) writer.add_tensor(tensor.name, raw, raw_dtype=gguf_module.GGMLQuantizationType.BF16) return True writer.add_tensor(tensor.name, np.ascontiguousarray(values, dtype=np.float32)) return True def export_quant(args) -> None: gguf = import_gguf(args.llama_cpp) source = Path(args.gguf).resolve() output = Path(args.output).resolve() ensure_cuda_interpreter(args.device) reader = _open_reader(gguf, source) architecture = _architecture(reader) bind_exclude(args, architecture) notice = scale_bake_notice(architecture) if notice: log.warning(notice) file_type_name = _file_type_name(reader, gguf) if file_type_name not in {"ALL_F32", "MOSTLY_F16", "MOSTLY_BF16"}: log.warning( "Source file type is %s. NVFP4 will be fit to the dequantized weights, " "so detail already discarded by the source quant stays discarded.", file_type_name or "quantized", ) names = {tensor.name for tensor in reader.tensors} n_blocks, n_nextn = _mtp_counts(reader, architecture) quant_names = [ tensor.name for tensor in reader.tensors if eligible_weight(tensor.name, _logical_shape(tensor), args, gguf) ] quant_name_set = set(quant_names) log.info("%d matrices queued for NVFP4, %d tensors copied", len(quant_names), len(reader.tensors) - len(quant_names)) if not quant_names: log.warning("No matrices are eligible for NVFP4. Tensors are copied and the file type stays unchanged.") work = Path(args.checkpoint_dir) if args.checkpoint_dir else Path(str(output) + ".nvfp4-work") if args.fresh and work.exists(): shutil.rmtree(work) work.mkdir(parents=True, exist_ok=True) require_quant_settings(work, args) _converter_cls, unpack_uint4 = load_learned_converter(args.convert_to_quant) del _converter_cls quantizer = TensorQuantizer(args, gguf, unpack_uint4) writer = gguf.GGUFWriter(output, architecture, use_temp_file=not args.no_temp_file) _copy_metadata( reader, writer, gguf, int(gguf.LlamaFileType.MOSTLY_NVFP4) if quant_names else None, args.model_name, args.context_length, ) pending: dict[str, object] = {} corrections: dict[str, np.ndarray] = {} done = 0 baked = 0 corrected = 0 try: for tensor in reader.tensors: if _replaced_scale(tensor.name, quant_name_set, architecture): continue weight_name = tensor.name[: -len(".bias")] + ".weight" if tensor.name.endswith(".bias") else "" if weight_name in quant_name_set and not args.no_bias_correction: if tensor.name not in corrections: pending[tensor.name] = tensor continue if _emit_bias(writer, gguf, tensor, corrections[tensor.name]): corrected += 1 continue if tensor.name not in quant_name_set: _copy_tensor(writer, tensor) continue done += 1 logical = _logical_shape(tensor) bias_name = tensor.name[: -len(".weight")] + ".bias" bake = must_bake_nvfp4_scale(architecture, tensor.name, bias_name in names) or mtp_layer_tensor( tensor.name, n_blocks, n_nextn ) cached = _load_checkpoint(work, tensor.name, logical) if cached is not None and (cached[1] is None) != bake: log.info(" checkpoint scale layout does not match %s; recomputing", tensor.name) cached = None log.info( "NVFP4 [%d/%d] %s %s from %s%s", done, len(quant_names), tensor.name, logical, tensor.tensor_type.name, " (checkpoint)" if cached else "", ) if cached is None: weight = _to_float(tensor, gguf) raw, scale = quantize_and_pack(quantizer, weight, bake, tensor.name) report_reconstruction(quantizer, weight, raw, scale) bias = None if weight.ndim == 2 and not args.no_bias_correction and bias_name in names: per_tensor = None if scale is None else float(np.reshape(scale, -1)[0]) runtime = quantizer.runtime_dequant(raw if raw.ndim == 2 else raw[0], per_tensor) sample = weight if weight.ndim == 2 else weight[0] bias = quantizer.bias_correction(sample, runtime) _save_checkpoint(work, tensor.name, logical, raw, scale, bias) del weight else: raw, scale, bias = cached if bias is not None: corrections[bias_name] = bias pending_tensor = pending.pop(bias_name, None) if pending_tensor is not None and _emit_bias(writer, gguf, pending_tensor, bias): corrected += 1 if bake and scale is None: baked += 1 writer.add_tensor(tensor.name, raw, raw_dtype=gguf.GGMLQuantizationType.NVFP4) if scale is not None: writer.add_tensor(tensor.name[: -len(".weight")] + ".scale", np.ascontiguousarray(scale, dtype=np.float32)) del raw for name, tensor in list(pending.items()): if _emit_bias(writer, gguf, tensor, corrections.get(name)): corrected += 1 pending.clear() except KeyboardInterrupt: log.warning("Interrupted. Checkpoints are in %s; rerun the same command to resume.", work) raise log.info("Writing %s", output) warn_missing_gguf_metadata(writer) writer.write_header_to_file() writer.write_kv_data_to_file() writer.write_tensors_to_file(progress=True) writer.close() if not args.keep_checkpoints: shutil.rmtree(work, ignore_errors=True) write_simple_modelfile(output, args.overwrite, args.mmproj, _chat_template(reader)) log.info( "Wrote %s (%d NVFP4 tensors, %d with scale folded into the blocks, %d biases adjusted)", output, done, baked, corrected, ) def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser( description="Quantize a GGUF to NVFP4, or repack one that is already NVFP4, with the same options as hf_to_nvfp4_gguf.py.", ) parser.add_argument("gguf", nargs="?", help="Input GGUF") parser.add_argument("-o", "--output", help="Output directory. Default: ./-NVFP4, or ./ with --no-quant and auto. A path ending in .gguf is the GGUF file itself.") parser.add_argument("--modelfile-only", action="store_true", help="Write the Modelfile and skip quantization and GGUF creation") parser.add_argument("--overwrite", action="store_true", help="Replace an existing GGUF, Modelfile, or copied projector") parser.add_argument("--mmproj", action="append", type=Path, default=None, help="Vision projector GGUF. Copied into the output directory under its own name and added as another FROM line. Repeat for more than one projector.") parser.add_argument("--context-length", type=int, default=None, help="Max context stored in the GGUF. Replaces a source value, or sets one when the source has no single context length.") parser.add_argument("--no-quant", action="store_true", help="Skip learned rounding. Writes a float GGUF, or copies an existing NVFP4 GGUF with --outtype nvfp4") parser.add_argument("--outtype", default="auto", type=_outtype, choices=("auto", "f32", "f16", "bf16", "nvfp4"), help="Type used when learned rounding is skipped. nvfp4 copies a GGUF that is already NVFP4. auto leaves tensors as stored (default: auto)") parser.add_argument("--llama-cpp", type=Path, default=DEFAULT_LLAMA_CPP, help="llama.cpp checkout used for the GGUF reader and writer (default: ~/llama.cpp)") parser.add_argument("--convert-to-quant", type=Path, default=None, help="convert_to_quant checkout, if it is not installed in the environment") parser.add_argument("--model-name", default=None, help="Override the model name stored in the GGUF") parser.add_argument("--device", default="auto", help="auto, cpu, cuda, or cuda:N") parser.add_argument("--simple", action="store_true", help="Round each value onto the E2M1 grid without learned rounding") parser.add_argument("--optimizer", default="auto", choices=("auto", "prodigy", "adamw", "radam", "original"), help="Learned-rounding optimizer. auto uses prodigy when it is installed, otherwise AdamW") parser.add_argument("--num-iter", type=int, default=CTQ_NUM_ITER, help="Learned-rounding iterations per matrix (convert_to_quant default: 4000)") parser.add_argument("--lr", type=float, default=1.0, help="Initial learning rate") parser.add_argument("--lr-min", type=float, default=1e-8, help="Lowest learning rate. The flat-loss check starts here (default: 1e-8).") parser.add_argument("--lr-schedule", default="plateau", choices=("plateau", "exponential", "adaptive")) parser.add_argument( "--scale-optimization", default=DEFAULT_SCALE_OPTIMIZATION, choices=("fixed", "iterative", "joint"), help="fixed keeps the initial scale. iterative refreshes it on the original optimizer. " "joint trains the block scales with the codes (default: joint)", ) parser.add_argument( "--scale-refinement-rounds", type=int, default=DEFAULT_SCALE_REFINEMENT_ROUNDS, help="Scale refreshes when --scale-optimization is iterative (default: 2). Ignored for fixed and joint.", ) parser.add_argument("--full-matrix", action="store_true", help="Use a full SVD instead of a low-rank SVD") parser.add_argument("--top-p", type=float, default=0.2) parser.add_argument("--min-k", type=int, default=128) parser.add_argument("--max-k", type=int, default=1280) parser.add_argument("--early-stop-loss", type=float, default=5e-9) parser.add_argument( "--early-stop-stall", type=int, default=DEFAULT_EARLY_STOP_STALL, help="Stop after this many steps without a new best, at any learning rate (default: 2000)", ) parser.add_argument( "--target-loss", type=float, default=DEFAULT_TARGET_LOSS, help="Stop when the best loss reaches this value, at any learning rate (default: 1e-5). 0 disables it.", ) parser.add_argument( "--early-stop-floor-stall", type=int, default=DEFAULT_EARLY_STOP_FLOOR_STALL, help="If the loss never reaches --target-loss, stop after this many steps without a new best once it has otherwise flattened. 0 disables it (default: 64)", ) parser.add_argument("--calib-samples", type=int, default=3072, help="Random inputs used to correct a sibling bias") parser.add_argument("--seed", type=int, default=42) parser.add_argument("--no-bias-correction", action="store_true") parser.add_argument("--quantize-output", action="store_true", help="Also quantize the output (lm_head) matrix") parser.add_argument("--exclude", default=None, help="Regex of GGUF tensor names to leave in their source type") parser.add_argument("--checkpoint-dir", type=Path, default=None, help="Per-tensor resume directory") parser.add_argument("--fresh", action="store_true", help="Delete the checkpoint directory before starting") parser.add_argument("--keep-checkpoints", action="store_true") parser.add_argument("--eager", action="store_true", help="Accepted for the same command line as hf_to_nvfp4_gguf. The GGUF reader maps the file either way.") parser.add_argument("--no-temp-file", action="store_true", help="Hold the finished GGUF tensors in RAM instead of a temp file") parser.add_argument("--verbose", action="store_true") parser.add_argument("--self-test", action="store_true", help="Check NVFP4 packing against llama.cpp's dequantizer and exit") args = parser.parse_args(argv) if args.self_test: if args.device == "auto": args.device = "cpu" return args if not args.gguf: parser.error("GGUF file is required") _assign_output(args) _refuse_same_file(args) if not args.modelfile_only: refuse_existing_bundle(Path(args.output), args.overwrite, args.mmproj) else: refuse_existing_bundle(Path(args.output), args.overwrite, args.mmproj, skip_gguf=True) args.exclude_re = re.compile(args.exclude) if args.exclude else None 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: from hf_to_nvfp4_gguf import self_test self_test(args) return logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO, format="%(message)s") if args.modelfile_only: output = Path(args.output) output.parent.mkdir(parents=True, exist_ok=True) log.info("Skipping quantization and GGUF creation; writing Modelfile only") reader = _open_reader(import_gguf(args.llama_cpp), Path(args.gguf)) write_simple_modelfile(output, args.overwrite, args.mmproj, _chat_template(reader)) return if args.no_quant or args.outtype == "nvfp4": export_copy(args) return args.optimizer = resolve_optimizer(args.optimizer) export_quant(args) if __name__ == "__main__": main()