Files
nvfp4_gguf_quant/gguf_to_nvfp4_gguf.py
T
2026-11-01 16:31:33 -05:00

668 lines
29 KiB
Python

#!/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: ./<name>-NVFP4, or ./<name> 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()