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

533 lines
24 KiB
Python

#!/usr/bin/env python3
"""Quantize a safetensors file to NVFP4 GGUF without a Hugging Face model directory.
The learned rounding is the same convert_to_quant pass the other scripts use,
so a file you are already quantizing with convert_to_quant can be packed into
GGUF and compared tensor by tensor. Tensor names are kept. There is no
tokenizer and no architecture config; the GGUF is a weight container.
By default, tensors that convert_to_quant's base exclusion list would skip
(norms, biases, embeddings, lm_head, and the other names in that list) stay
in their original dtype. Pass --all-weights to quantize every 2D matrix whose
last dimension is a multiple of 64. A sibling bias is corrected the same way
as hf_to_nvfp4_gguf.py. Repeat --mmproj to copy projector GGUFs beside the
output and add a FROM line for each one.
--no-quant skips that rounding and writes the safetensors into a GGUF as
float. --outtype chooses f32, f16, bf16, or auto, which keeps each tensor's
stored dtype. --outtype nvfp4 repacks a ComfyUI NVFP4 safetensors file
(packed weight, weight_scale, and weight_scale_2) without fitting it again.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import logging
import shutil
from pathlib import Path
import numpy as np
import torch
from safetensors import safe_open
from nvfp4gguf import (
DEFAULT_LLAMA_CPP,
TensorQuantizer,
add_quant_arguments,
bind_exclude,
ctq_skips_key,
ensure_cuda_interpreter,
import_gguf,
load_learned_converter,
looks_like_spark,
quantize_and_pack,
report_reconstruction,
require_quant_settings,
)
from nvfp4gguf.bundle import (
outtype as _outtype,
refuse_existing_bundle,
require_context_length,
warn_missing_gguf_metadata,
write_simple_modelfile,
)
log = logging.getLogger("safetensors_to_nvfp4_gguf")
def _checkpoint_file(directory: Path, name: str) -> Path:
digest = hashlib.sha1(name.encode("utf-8")).hexdigest()[:16]
safe = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in 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 tuple(int(v) for v in blob["shape"]) != tuple(shape):
return None
if need_bias and "bias" not in blob.files:
return None
raw = np.array(blob["raw"], copy=True)
scale = np.array(blob["scale"], copy=True)
bias = np.array(blob["bias"], copy=True) if "bias" in blob.files else np.zeros((0,), np.float32)
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_file(directory, name)
temporary = path.with_name(path.name + ".tmp")
with temporary.open("wb") as handle:
np.savez_compressed(
handle,
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 _should_quantize(
key: str,
shape: tuple[int, ...],
all_weights: bool,
convert_to_quant_dir: Path | None,
exclude_re,
) -> bool:
if len(shape) != 2 or not key.endswith(".weight"):
return False
if shape[-1] % 64 != 0 or shape[-1] < 64:
return False
if exclude_re is not None and exclude_re.search(key):
return False
if not all_weights and ctq_skips_key(key, convert_to_quant_dir):
return False
return True
def _apply_bias(name: str, tensor: torch.Tensor, correction: np.ndarray | None) -> torch.Tensor:
if correction is None:
return tensor
values = tensor.detach().cpu().float()
flat = values.reshape(-1)
if tuple(flat.shape) != tuple(correction.shape):
log.warning(" bias %s shape %s does not match correction %s", name, tuple(flat.shape), tuple(correction.shape))
return tensor
flat = flat - torch.from_numpy(np.ascontiguousarray(correction, dtype=np.float32))
values = flat.reshape(values.shape).to(dtype=tensor.dtype)
log.info(" bias %s adjusted (mean %+.3e)", name, float(np.mean(correction)))
return values
def _float_file_type(gguf, outtype: str):
return {
"f32": gguf.LlamaFileType.ALL_F32,
"f16": gguf.LlamaFileType.MOSTLY_F16,
"bf16": gguf.LlamaFileType.MOSTLY_BF16,
}[outtype]
def _auto_file_type(gguf, dtypes: list[str]):
names = {dtype.upper() for dtype in dtypes}
if "BF16" in names:
return gguf.LlamaFileType.MOSTLY_BF16
if "F16" in names:
return gguf.LlamaFileType.MOSTLY_F16
if "F32" in names:
return gguf.LlamaFileType.ALL_F32
return gguf.LlamaFileType.GUESSED
def _cast_float(tensor: torch.Tensor, outtype: str) -> torch.Tensor:
if outtype == "auto" or not tensor.is_floating_point():
return tensor
dtype = {"f32": torch.float32, "f16": torch.float16, "bf16": torch.bfloat16}[outtype]
if tensor.dtype == dtype:
return tensor
return tensor.to(dtype)
def _default_output(source: Path, skip_quant: bool, outtype: str) -> Path:
if not skip_quant:
return source.with_name(source.stem + "-NVFP4.gguf")
if outtype == "auto":
return source.with_name(source.stem + ".gguf")
label = "NVFP4" if outtype == "nvfp4" else outtype.upper()
return source.with_name(f"{source.stem}-{label}.gguf")
def _add_original(writer, gguf, name: str, tensor: torch.Tensor) -> None:
tensor = tensor.detach().cpu().contiguous()
if tensor.dtype == torch.bfloat16:
raw = tensor.view(torch.uint16).numpy().view(np.uint8)
writer.add_tensor(name, np.ascontiguousarray(raw), raw_dtype=gguf.GGMLQuantizationType.BF16)
return
if tensor.dtype == torch.float16:
writer.add_tensor(name, tensor.numpy())
return
if tensor.dtype == torch.float32:
writer.add_tensor(name, tensor.numpy())
return
writer.add_tensor(name, tensor.float().numpy())
def _prepare_output(args) -> tuple[Path, Path, bool]:
source = Path(args.safetensors).resolve()
if not source.is_file():
raise SystemExit(f"Not a file: {source}")
skip_quant = bool(args.no_quant or args.outtype == "nvfp4")
output = Path(args.output).resolve() if args.output else _default_output(source, skip_quant, args.outtype)
return source, output, skip_quant
def _comfy_formats(handle) -> dict[str, str]:
"""Map a weight prefix to the format named in its .comfy_quant sidecar."""
found: dict[str, str] = {}
suffix = ".comfy_quant"
for key in handle.keys():
if not key.endswith(suffix):
continue
raw = handle.get_tensor(key).detach().cpu().numpy().tobytes()
try:
payload = json.loads(raw.decode("utf-8"))
except (UnicodeError, json.JSONDecodeError):
continue
fmt = payload.get("format") if isinstance(payload, dict) else None
if isinstance(fmt, str):
found[key[: -len(suffix)]] = fmt
return found
def _pack_comfy_nvfp4(weight: torch.Tensor, scale: torch.Tensor) -> np.ndarray:
"""Repack ComfyUI / ModelOpt NVFP4 into ggml super-blocks.
The code bytes are already nibble-packed the way ggml stores them. Each
group of four 16-value blocks becomes 4 UE4M3 scale bytes followed by
those 32 code bytes.
"""
if scale.ndim != 2 or weight.ndim != 2:
raise ValueError(f"NVFP4 weight {tuple(weight.shape)} and scale {tuple(scale.shape)} must be matrices")
rows, n_blocks = scale.shape
if weight.shape != (rows, n_blocks * 8):
raise ValueError(f"NVFP4 weight {tuple(weight.shape)} does not match scale {tuple(scale.shape)}")
if n_blocks % 4 != 0:
raise ValueError(f"NVFP4 scale count {n_blocks} is not a multiple of 4")
n_super = n_blocks // 4
codes = np.ascontiguousarray(weight.detach().cpu().contiguous().view(torch.uint8).numpy()).reshape(rows, n_super, 32)
scales = np.ascontiguousarray(scale.detach().cpu().contiguous().view(torch.uint8).numpy()).reshape(rows, n_super, 4)
scales = scales & np.uint8(0x7F)
return np.ascontiguousarray(np.concatenate([scales, codes], axis=-1).reshape(rows, n_super * 36))
def _scale_is_trivial(scale: torch.Tensor) -> bool:
return scale.numel() <= 1 and abs(float(scale.detach().float().sum()) - 1.0) < 1e-6
def _float8_to_bf16(tensor: torch.Tensor, scale: torch.Tensor | None) -> torch.Tensor:
values = tensor.detach().float()
if scale is not None:
factor = scale.detach().float()
if factor.numel() == 1:
values = values * factor.reshape(())
elif tuple(factor.shape) == tuple(values.shape):
values = values * factor
else:
raise ValueError(f"float8 scale {tuple(factor.shape)} does not match weight {tuple(values.shape)}")
return values.to(torch.bfloat16)
def convert_packed_nvfp4(args) -> None:
"""Copy ComfyUI NVFP4 blocks into a GGUF. Float tensors are stored as stored."""
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO, format="%(message)s")
source, output, _skip = _prepare_output(args)
overwrite = bool(args.force or args.overwrite)
if output.exists() and not overwrite:
raise SystemExit(f"{output} already exists. Pass --overwrite to replace it.")
output.parent.mkdir(parents=True, exist_ok=True)
if args.mmproj:
modelfile = output.parent / "Modelfile"
if modelfile.exists() and not overwrite:
raise SystemExit(f"{modelfile} already exists. Pass --overwrite to replace it.")
refuse_existing_bundle(output, overwrite, args.mmproj, skip_gguf=True)
gguf = import_gguf(args.llama_cpp)
with safe_open(source, framework="pt", device="cpu") as handle:
keys = list(handle.keys())
formats = _comfy_formats(handle)
packed = [name for name, fmt in formats.items() if fmt == "nvfp4"]
if not packed:
raise SystemExit(
f"{source} has no ComfyUI NVFP4 weights to repack. "
"Use --no-quant with --outtype auto, f16, bf16, or f32 to write float weights without quantizing them."
)
present = set(keys)
for name in packed:
if name + ".weight" not in present or name + ".weight_scale" not in present:
raise SystemExit(f"{name} is marked nvfp4 but is missing weight or weight_scale")
log.info("Repacking %d NVFP4 tensors from %s. Learned rounding is skipped.", len(packed), source.name)
writer = gguf.GGUFWriter(output, "safetensors", use_temp_file=not args.no_temp_file)
writer.add_file_type(int(gguf.LlamaFileType.MOSTLY_NVFP4))
writer.add_string("general.name", source.stem)
writer.add_string("general.description", "NVFP4 weights repacked from safetensors without refitting")
writer.add_uint32("general.quantization_version", 2)
context_length = require_context_length(args.context_length)
if context_length is not None:
writer.add_context_length(context_length)
log.info("context length %d", context_length)
skip = {name + ".comfy_quant" for name in formats}
for name, fmt in formats.items():
if fmt in ("nvfp4", "float8_e4m3fn"):
skip.add(name + ".weight_scale")
skip.add(name + ".weight_scale_2")
promoted = 0
with safe_open(source, framework="pt", device="cpu") as handle:
for key in keys:
if key in skip:
continue
if key.endswith(".weight") and formats.get(key[: -len(".weight")]) == "nvfp4":
base = key[: -len(".weight")]
raw = _pack_comfy_nvfp4(handle.get_tensor(key), handle.get_tensor(base + ".weight_scale"))
writer.add_tensor(key, raw, raw_dtype=gguf.GGMLQuantizationType.NVFP4)
scale2_name = base + ".weight_scale_2"
if scale2_name in present:
scale2 = handle.get_tensor(scale2_name)
if not _scale_is_trivial(scale2):
writer.add_tensor(base + ".scale", scale2.detach().float().numpy().reshape(-1).astype(np.float32))
continue
if key.endswith(".weight") and formats.get(key[: -len(".weight")]) == "float8_e4m3fn":
base = key[: -len(".weight")]
scale_name = base + ".weight_scale"
scale = handle.get_tensor(scale_name) if scale_name in present else None
_add_original(writer, gguf, key, _float8_to_bf16(handle.get_tensor(key), scale))
promoted += 1
log.info("Stored %s as BF16 (source is float8_e4m3fn)", key)
continue
_add_original(writer, gguf, key, handle.get_tensor(key))
if promoted:
log.info("%d float8 tensors were stored as BF16. GGUF has no float8 weight type.", promoted)
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 args.mmproj:
write_simple_modelfile(output, overwrite, args.mmproj)
log.info("Wrote %s", output)
def convert_float(args) -> None:
"""Write the safetensors into a GGUF without learned rounding."""
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO, format="%(message)s")
if args.outtype == "nvfp4":
convert_packed_nvfp4(args)
return
source, output, _skip = _prepare_output(args)
overwrite = bool(args.force or args.overwrite)
if output.exists() and not overwrite:
raise SystemExit(f"{output} already exists. Pass --overwrite to replace it.")
output.parent.mkdir(parents=True, exist_ok=True)
if args.mmproj:
modelfile = output.parent / "Modelfile"
if modelfile.exists() and not overwrite:
raise SystemExit(f"{modelfile} already exists. Pass --overwrite to replace it.")
refuse_existing_bundle(output, overwrite, args.mmproj, skip_gguf=True)
gguf = import_gguf(args.llama_cpp)
with safe_open(source, framework="pt", device="cpu") as handle:
keys = list(handle.keys())
dtypes = [handle.get_slice(key).get_dtype() for key in keys]
file_type = _float_file_type(gguf, args.outtype) if args.outtype != "auto" else _auto_file_type(gguf, dtypes)
log.info("Writing float GGUF (%s). NVFP4 quantization is skipped.", args.outtype)
writer = gguf.GGUFWriter(output, "safetensors", use_temp_file=not args.no_temp_file)
writer.add_file_type(int(file_type))
writer.add_string("general.name", source.stem)
writer.add_string("general.description", "Weights converted from safetensors without quantization")
context_length = require_context_length(args.context_length)
if context_length is not None:
writer.add_context_length(context_length)
log.info("context length %d", context_length)
with safe_open(source, framework="pt", device="cpu") as handle:
for key in keys:
_add_original(writer, gguf, key, _cast_float(handle.get_tensor(key), 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()
if args.mmproj:
write_simple_modelfile(output, overwrite, args.mmproj)
log.info("Wrote %s", output)
def convert(args) -> None:
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO, format="%(message)s")
if args.no_quant or args.outtype == "nvfp4":
convert_float(args)
return
ensure_cuda_interpreter(args.device)
source, output, _skip = _prepare_output(args)
overwrite = bool(args.force or args.overwrite)
if output.exists() and not overwrite:
raise SystemExit(f"{output} already exists. Pass --overwrite to replace it.")
output.parent.mkdir(parents=True, exist_ok=True)
if args.mmproj:
modelfile = output.parent / "Modelfile"
if modelfile.exists() and not overwrite:
raise SystemExit(f"{modelfile} already exists. Pass --overwrite to replace it.")
refuse_existing_bundle(output, overwrite, args.mmproj, skip_gguf=True)
gguf = import_gguf(args.llama_cpp)
with safe_open(source, framework="pt", device="cpu") as handle:
keys = list(handle.keys())
shapes = {key: tuple(handle.get_slice(key).get_shape()) for key in keys}
bind_exclude(args, "spark2_5" if looks_like_spark(keys) else None)
chosen = [
key for key in keys
if _should_quantize(key, shapes[key], args.all_weights, args.convert_to_quant, args.exclude_re)
]
log.info("%d of %d tensors queued for NVFP4", len(chosen), len(keys))
if not chosen:
raise SystemExit("No 2D .weight tensor has a last dimension divisible by 64.")
work = Path(args.checkpoint_dir) if args.checkpoint_dir else output.with_name(output.name + ".nvfp4-work")
if args.fresh and work.exists():
shutil.rmtree(work)
work.mkdir(parents=True, exist_ok=True)
require_quant_settings(work, args)
quantizer = TensorQuantizer(args, gguf, load_learned_converter(args.convert_to_quant)[1])
writer = gguf.GGUFWriter(output, "safetensors", use_temp_file=not args.no_temp_file)
writer.add_file_type(int(gguf.LlamaFileType.MOSTLY_NVFP4))
writer.add_string("general.name", source.stem)
writer.add_string("general.description", "NVFP4 weights converted from safetensors for comparison with convert_to_quant")
writer.add_uint32("general.quantization_version", 2)
context_length = require_context_length(args.context_length)
if context_length is not None:
writer.add_context_length(context_length)
log.info("context length %d", context_length)
done = 0
chosen_set = set(chosen)
pending_bias: dict[str, torch.Tensor] = {}
corrections: dict[str, np.ndarray] = {}
def _write_bias(key: str, tensor: torch.Tensor) -> None:
weight_name = key[: -len(".bias")] + ".weight"
correction = corrections.get(key) if weight_name in chosen_set and not args.no_bias_correction else None
_add_original(writer, gguf, key, _apply_bias(key, tensor, correction))
with safe_open(source, framework="pt", device="cpu") as handle:
for key in keys:
tensor = handle.get_tensor(key)
weight_name = key[: -len(".bias")] + ".weight" if key.endswith(".bias") else ""
if weight_name in chosen_set and not args.no_bias_correction and key not in corrections:
pending_bias[key] = tensor
continue
if key not in chosen_set:
if len(shapes[key]) == 2 and key.endswith(".weight") and shapes[key][-1] % 64 != 0:
log.info("Keeping %s %s (last dimension is not a multiple of 64)", key, shapes[key])
if key.endswith(".bias") and weight_name in chosen_set:
_write_bias(key, tensor)
else:
_add_original(writer, gguf, key, tensor)
continue
done += 1
bias_name = key[: -len(".weight")] + ".bias"
need_bias = not args.no_bias_correction and bias_name in shapes and len(shapes[key]) == 2
cached = _load_checkpoint(work, key, shapes[key], need_bias)
log.info(
"NVFP4 [%d/%d] %s %s%s",
done, len(chosen), key, shapes[key], " (checkpoint)" if cached else "",
)
if cached is None:
weight = tensor.float().numpy()
raw, scale = quantize_and_pack(quantizer, weight, False, key)
report_reconstruction(quantizer, weight, raw, scale)
bias = None
if need_bias:
per_tensor = None if scale is None else float(np.reshape(scale, -1)[0])
runtime = quantizer.runtime_dequant(raw, per_tensor)
bias = quantizer.bias_correction(weight, runtime)
_save_checkpoint(work, key, shapes[key], raw, scale, bias)
del weight
else:
raw, scale, bias = cached
if bias is not None:
corrections[bias_name] = bias
pending = pending_bias.pop(bias_name, None)
if pending is not None:
_write_bias(bias_name, pending)
writer.add_tensor(key, raw, raw_dtype=gguf.GGMLQuantizationType.NVFP4)
if scale is not None:
writer.add_tensor(key[: -len(".weight")] + ".scale", scale)
del tensor, raw
for key, tensor in pending_bias.items():
_write_bias(key, tensor)
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)
if args.mmproj:
write_simple_modelfile(output, overwrite, args.mmproj)
log.info("Wrote %s", output)
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Quantize a safetensors file to an NVFP4 GGUF weight container.")
parser.add_argument("safetensors", nargs="?", help="Input .safetensors file")
parser.add_argument("-o", "--output", help="Output GGUF path. Default: <stem>-NVFP4.gguf, or <stem>.gguf with --no-quant and auto")
parser.add_argument("--no-quant", action="store_true",
help="Skip learned rounding. Writes a float GGUF, or repacks ComfyUI NVFP4 weights 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 repacks ComfyUI NVFP4 weights. auto keeps each tensor's stored dtype (default: auto)")
parser.add_argument("--all-weights", action="store_true",
help="Quantize every eligible 2D weight. By default, convert_to_quant's exclusion list is skipped too.")
parser.add_argument("--llama-cpp", type=Path, default=DEFAULT_LLAMA_CPP,
help="llama.cpp checkout used for the GGUF writer (default: ~/llama.cpp)")
parser.add_argument("--overwrite", action="store_true", help="Replace an existing GGUF, Modelfile, or copied projector")
parser.add_argument("--force", action="store_true", help="Same as --overwrite")
parser.add_argument("--mmproj", action="append", type=Path, default=None,
help="Vision projector GGUF. Copied next to the output 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. A safetensors file has no context length to copy.")
parser.add_argument("--checkpoint-dir", type=Path, default=None, help="Per-tensor resume directory")
parser.add_argument("--fresh", action="store_true")
parser.add_argument("--keep-checkpoints", action="store_true")
parser.add_argument("--no-temp-file", action="store_true")
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")
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.safetensors:
parser.error("safetensors file is required")
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
convert(args)
if __name__ == "__main__":
main()