From db7f28d087fc4777fdbcc8593553466b8b6511aa Mon Sep 17 00:00:00 2001 From: zefie Date: Sun, 1 Nov 2026 16:31:33 -0500 Subject: [PATCH] initial commit --- Dockerfile | 20 + __pycache__/gguf_info.cpython-313.pyc | Bin 0 -> 20582 bytes .../gguf_to_nvfp4_gguf.cpython-313.pyc | Bin 0 -> 42267 bytes __pycache__/hf_info.cpython-313.pyc | Bin 0 -> 65013 bytes __pycache__/hf_to_nvfp4_gguf.cpython-313.pyc | Bin 0 -> 70546 bytes __pycache__/hf_to_nvfp4_gguf.cpython-314.pyc | Bin 0 -> 42370 bytes .../hf_to_nvfp4_safetensors.cpython-313.pyc | Bin 0 -> 20745 bytes __pycache__/ollama_nvfp4.cpython-313.pyc | Bin 0 -> 109193 bytes __pycache__/ollama_nvfp4.cpython-314.pyc | Bin 0 -> 57905 bytes .../safetensors_to_nvfp4_gguf.cpython-313.pyc | Bin 0 -> 37272 bytes .../safetensors_to_nvfp4_gguf.cpython-314.pyc | Bin 0 -> 14691 bytes docker/README.txt | 22 + docker/gguf_to_nvfp4_gguf | 2 + docker/hf_to_nvfp4_gguf | 2 + docker/hf_to_nvfp4_safetensors | 2 + docker/safetensors_to_nvfp4_gguf | 2 + gguf_info.py | 382 ++++ gguf_to_nvfp4_gguf.py | 667 ++++++ hf_info.py | 1105 ++++++++++ hf_to_nvfp4_gguf.py | 1118 ++++++++++ hf_to_nvfp4_safetensors.py | 335 +++ llama.cpp | 1 + nvfp4gguf/__init__.py | 75 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 1301 bytes nvfp4gguf/__pycache__/bundle.cpython-313.pyc | Bin 0 -> 17441 bytes nvfp4gguf/__pycache__/quant.cpython-313.pyc | Bin 0 -> 59016 bytes nvfp4gguf/bundle.py | 340 +++ nvfp4gguf/quant.py | 1000 +++++++++ ollama_nvfp4.py | 1838 +++++++++++++++++ requirements.txt | 19 + safetensors_to_nvfp4_gguf.py | 532 +++++ 31 files changed, 7462 insertions(+) create mode 100644 Dockerfile create mode 100644 __pycache__/gguf_info.cpython-313.pyc create mode 100644 __pycache__/gguf_to_nvfp4_gguf.cpython-313.pyc create mode 100644 __pycache__/hf_info.cpython-313.pyc create mode 100644 __pycache__/hf_to_nvfp4_gguf.cpython-313.pyc create mode 100644 __pycache__/hf_to_nvfp4_gguf.cpython-314.pyc create mode 100644 __pycache__/hf_to_nvfp4_safetensors.cpython-313.pyc create mode 100644 __pycache__/ollama_nvfp4.cpython-313.pyc create mode 100644 __pycache__/ollama_nvfp4.cpython-314.pyc create mode 100644 __pycache__/safetensors_to_nvfp4_gguf.cpython-313.pyc create mode 100644 __pycache__/safetensors_to_nvfp4_gguf.cpython-314.pyc create mode 100644 docker/README.txt create mode 100755 docker/gguf_to_nvfp4_gguf create mode 100755 docker/hf_to_nvfp4_gguf create mode 100755 docker/hf_to_nvfp4_safetensors create mode 100755 docker/safetensors_to_nvfp4_gguf create mode 100644 gguf_info.py create mode 100644 gguf_to_nvfp4_gguf.py create mode 100644 hf_info.py create mode 100644 hf_to_nvfp4_gguf.py create mode 100644 hf_to_nvfp4_safetensors.py create mode 160000 llama.cpp create mode 100644 nvfp4gguf/__init__.py create mode 100644 nvfp4gguf/__pycache__/__init__.cpython-313.pyc create mode 100644 nvfp4gguf/__pycache__/bundle.cpython-313.pyc create mode 100644 nvfp4gguf/__pycache__/quant.cpython-313.pyc create mode 100644 nvfp4gguf/bundle.py create mode 100644 nvfp4gguf/quant.py create mode 100644 ollama_nvfp4.py create mode 100644 requirements.txt create mode 100644 safetensors_to_nvfp4_gguf.py diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7e8ac45 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM runpod/pytorch:1.5.0-cu1320-torch2130-ubuntu2404 +WORKDIR /root + +RUN mkdir llama.cpp +COPY llama.cpp ./llama.cpp/ +RUN git clone https://github.com/silveroxides/convert_to_quant && cd convert_to_quant && pip install -r requirements.txt && pip install . + +RUN mkdir nvfp4_gguf_quant +WORKDIR /root/nvfp4_gguf_quant +COPY --exclude=__pycache__ nvfp4gguf ./nvfp4gguf +COPY --chmod=0644 gguf_to_nvfp4_gguf.py hf_to_nvfp4_gguf.py hf_to_nvfp4_safetensors.py requirements.txt safetensors_to_nvfp4_gguf.py . + +RUN pip install -r requirements.txt +COPY --chmod=0644 docker/README.txt /workspace/README.txt +COPY --chmod=0755 docker/hf_to_nvfp4_gguf docker/hf_to_nvfp4_safetensors docker/gguf_to_nvfp4_gguf docker/safetensors_to_nvfp4_gguf /usr/local/bin/ + +RUN apt remove -y python3-cryptography && pip install heretic-llm + +WORKDIR /workspace + diff --git a/__pycache__/gguf_info.cpython-313.pyc b/__pycache__/gguf_info.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1bdc4c0301f3d81d09132c4bf0613dd3dddd761d GIT binary patch literal 20582 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 MZfqAd6|Cd_AMjjfcmMzZ literal 0 HcmV?d00001 diff --git a/__pycache__/hf_info.cpython-313.pyc b/__pycache__/hf_info.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..30e5c86ffdb73e1008fff3b4e4d4ef0ff5024a89 GIT binary patch literal 65013 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 OQo>QZ)KBpidi%fnq9Wn| literal 0 HcmV?d00001 diff --git a/__pycache__/hf_to_nvfp4_gguf.cpython-313.pyc b/__pycache__/hf_to_nvfp4_gguf.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1914030679d43736bc6d48d3925cccc0a284bfc7 GIT binary patch literal 70546 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 Dzzg1q literal 0 HcmV?d00001 diff --git a/__pycache__/hf_to_nvfp4_gguf.cpython-314.pyc b/__pycache__/hf_to_nvfp4_gguf.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e06aad3c00b41ad7e8dcca79721514b3917bc0d4 GIT binary patch literal 42370 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 literal 0 HcmV?d00001 diff --git a/__pycache__/hf_to_nvfp4_safetensors.cpython-313.pyc b/__pycache__/hf_to_nvfp4_safetensors.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..55285c18291235e16bbffdc08ecd020ed9a8eafb GIT binary patch literal 20745 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

#&mGsQ*?HG-$1xv` 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 MdT`?Fs~BGYKOZU_EdT%j literal 0 HcmV?d00001 diff --git a/__pycache__/ollama_nvfp4.cpython-313.pyc b/__pycache__/ollama_nvfp4.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0d6eac5b88694bb480f0de006829a97b4c9219b0 GIT binary patch literal 109193 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 literal 0 HcmV?d00001 diff --git a/__pycache__/ollama_nvfp4.cpython-314.pyc b/__pycache__/ollama_nvfp4.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b859ef70929a3dda3838af55f06a2cf879b36acf GIT binary patch literal 57905 zcmcG%33yxAl_vNAJnY~O?i;v@l(+;h)8=iGBQq^DUpxcpy#_}q`CIqpBx55=)J9H;sC z9WBSra02J&&T@k0u;!4~uPvvr_OQ;cV^7}Cv!~v#XHSFQz@A3Gkv&a*6MLHdW;}I= zEr+ar>mi%p#>(LjryWZ7rf_G}PSCe$+v1{kei2;anEZ6Y>zwXW@LI0O3LwE)a?k zE@t6Ep#=4!?yn%%)ga(8gS-4VY%IEx5LNj7EvY2XN6JlzF7R0o&m|CF?;ms_( zPS}F*Ru--kwjsQoh3kcOgmVN8Hwt?Z-p9gC!hVDg zuyC``iSR)d-Y6VG_%I7^5{@9;#lkH@H^MzE+$!`Ue3XUTgg%6ivG8U=K=?QdZxQ+t zKEc9U1t-EMS$LapR(Kk}PSLM%jek3SpT_SqvES{_><|XFJ2?6O(d3GEPeU_zu z);BtN=~=YOH#vUMgB+7Sk7Ly38};~_9M5_#dnZMtyd;kKETU%w?ZbO~1hs5x-0yJtQ0#=~QmlLBjza(ROsZWj_ld)Wes+yhH(ybE*6pY} z);8ER*wf%Rwry~uT$OLqF*#A+B-id3V|_M3ZL;7S`-Xw){zU~X{Y z;;^@Ey3*zKj*ktojLjD(+)eV^rm+jI5l<5X$LXfzH15fv^B%EjXmSGIF@cbr`r@Vy z&-o@NrfZVZuPW2;x!@gl`8`on|AC&q?vDNg(^Zem;JbqHy%04VKXn{ArmG*B7~kTV z@Oh%f?!)^I^d5g)*Luzx?fS+s6q7jr^E?Sj~{Py z7$pPEb(h~qC00f^HjhqT@H9_*Fo?}=&&6goY=a{sQ^SJ{+?o@Iv&nlUY8xDk!GdRS zP|QIob@=m9V21mYGisV1kQdm`9v>r`8zN6}XsP@K^i4C|fD+I3#a0(J0)akic9Ih~ zWi|BT``5+Zx^2`014bpWQ%zNs5G!cZyc#EZs==zHM_uN4SP3aBomva)XD9l1z@Vh* zi%|;7Z+gb@vY<<>L*NsCVU+aIw4>eK9X%a`JxBK+=srH3-pnS2FV^o-JwvY3#@NJ( z8syjYhNyPZ7d3h&E{=(l6H(oW$Ir$%Y8dkk0_8<{8d%I!9g;*1GLZYIiyaOz8;@wV z+cWF}IvbQrq)eg%FW7LNL12bkvDoI;UtK@fbhRl^9=6oa?EBDYS;@$m-}mYj$yBhC zmL+9ZhSREMI+so9^ZEBo1rehoWOPJ~m3NJm%Ne<|X)D%@x$UoPe|1No@Se3Y`1Cz% zqr^9gc9g#guz5tFb2T43Sk0*cThIZxvCP%;VmgvV4f0wO)%pCQn1L8E6JEVW%) zaQ3YXOqth0FNnw@Sk-6-G!K{`XuZ##!B+WqynRT@oZ7y#_(>D5PnnXj1oVi5rV22a z7bTRMz&^>*R8>eSEZsUMXX9Gf*L`M`7t{8r2LBtCpN$w!i_h;8{k}_MK($8E15`KU z!3S5OZ1%ym^5CY+Ju~Q^9Hcc^+<^D&+czOF!!56CjI7%hTDR?1VPxIDyX*GNZkgMC zb$6gD;0s%;B>v2ut~V7&2WI_qIVEGG3gV zlp&S1S*$|-CmXsgNa?F!)zQ$gk!qdaPXpH)tYVLaY8t*)xw(nxL>)bv&@_4impBmz zl+WUvDuPIS4~Wx<`N)a~^<=$PY{EnCb*iT}*6n7Rt%F{ygW^U+vnJ;ufF8GI%&+^# z?l*cDwuDPs?pa$TzGZEew%i{bI9ZK%FD;QLxdx&|+Tbrm*R%!uSY$+S)j6LqV){cz88kdpOR_ zl_{z9Y*rbgn!Qn7|JdGNarlI pH+B7`PI}lZ`7dInkRM$0zG(F@U9NR0>_+ifJ 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 literal 0 HcmV?d00001 diff --git a/__pycache__/safetensors_to_nvfp4_gguf.cpython-313.pyc b/__pycache__/safetensors_to_nvfp4_gguf.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..86d4f193b7ed2d67e37d412015bd095ddcec3669 GIT binary patch literal 37272 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 ix`My&(wTKdcZxWpsl literal 0 HcmV?d00001 diff --git a/__pycache__/safetensors_to_nvfp4_gguf.cpython-314.pyc b/__pycache__/safetensors_to_nvfp4_gguf.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..82fac2657f2ec7f1a5e3c30dee25be6bd52581eb GIT binary patch literal 14691 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 literal 0 HcmV?d00001 diff --git a/docker/README.txt b/docker/README.txt new file mode 100644 index 0000000..25b8ecd --- /dev/null +++ b/docker/README.txt @@ -0,0 +1,22 @@ +Convert-to-Quant (ctq) is installed for normal quantizations. + +For GGUF quantizations, try: + +hf_to_nvfp4_quant +gguf_to_nvfp4_quant +safetensors_to_nvfp4_quant +hf_to_nvfp4_safetensors + +Depending on the source material. + +hf cli command is also installed, eg you can: + + +hf download coder3101/gemma-4-12B-it-heretic --local-dir gemma-4-12B-it-heretic +hf_to_nvfp4_gguf -o gemma-4-12B-it-heretic_nvfp4 gemma-4-12B-it-heretic + +The same directory can be quantized to one ComfyUI NVFP4 safetensors file: + +hf_to_nvfp4_safetensors -o gemma-4-12B-it-heretic-NVFP4.safetensors gemma-4-12B-it-heretic + +This would create a NVFP4 GGUF for Ollama/llama.cpp from https://huggingface.co/coder3101/gemma-4-12B-it-heretic diff --git a/docker/gguf_to_nvfp4_gguf b/docker/gguf_to_nvfp4_gguf new file mode 100755 index 0000000..d3495a4 --- /dev/null +++ b/docker/gguf_to_nvfp4_gguf @@ -0,0 +1,2 @@ +#!/bin/bash +python /root/nvfp4_gguf_quant/gguf_to_nvfp4_gguf.py --llama-cpp /root/llama.cpp "${@}" diff --git a/docker/hf_to_nvfp4_gguf b/docker/hf_to_nvfp4_gguf new file mode 100755 index 0000000..c6ca43e --- /dev/null +++ b/docker/hf_to_nvfp4_gguf @@ -0,0 +1,2 @@ +#!/bin/bash +python /root/nvfp4_gguf_quant/hf_to_nvfp4_gguf.py --llama-cpp /root/llama.cpp "${@}" diff --git a/docker/hf_to_nvfp4_safetensors b/docker/hf_to_nvfp4_safetensors new file mode 100755 index 0000000..7db788f --- /dev/null +++ b/docker/hf_to_nvfp4_safetensors @@ -0,0 +1,2 @@ +#!/bin/bash +python /root/nvfp4_gguf_quant/hf_to_nvfp4_safetensors.py --convert-to-quant /root/convert_to_quant "${@}" diff --git a/docker/safetensors_to_nvfp4_gguf b/docker/safetensors_to_nvfp4_gguf new file mode 100755 index 0000000..67e4326 --- /dev/null +++ b/docker/safetensors_to_nvfp4_gguf @@ -0,0 +1,2 @@ +#!/bin/bash +python /root/nvfp4_gguf_quant/safetensors_to_nvfp4_gguf.py --llama-cpp /root/llama.cpp "${@}" diff --git a/gguf_info.py b/gguf_info.py new file mode 100644 index 0000000..5a7a8a0 --- /dev/null +++ b/gguf_info.py @@ -0,0 +1,382 @@ +#!/usr/bin/env python3 +"""Print a summary of a GGUF file from its header. + +The weight bytes are not read. Metadata and the tensor directory are enough +for the architecture, quantization, tokenizer, and tensor list. +""" + +from __future__ import annotations + +import argparse +import struct +from collections import Counter +from pathlib import Path + +# GGML type id -> name, and (values per block, bytes per block). +GGML_TYPES = { + 0: ("F32", 1, 4), + 1: ("F16", 1, 2), + 2: ("Q4_0", 32, 18), + 3: ("Q4_1", 32, 20), + 6: ("Q5_0", 32, 22), + 7: ("Q5_1", 32, 24), + 8: ("Q8_0", 32, 34), + 9: ("Q8_1", 32, 36), + 10: ("Q2_K", 256, 84), + 11: ("Q3_K", 256, 110), + 12: ("Q4_K", 256, 144), + 13: ("Q5_K", 256, 176), + 14: ("Q6_K", 256, 210), + 15: ("Q8_K", 256, 292), + 16: ("IQ2_XXS", 256, 66), + 17: ("IQ2_XS", 256, 74), + 18: ("IQ3_XXS", 256, 98), + 19: ("IQ1_S", 256, 50), + 20: ("IQ4_NL", 32, 18), + 21: ("IQ3_S", 256, 110), + 22: ("IQ2_S", 256, 82), + 23: ("IQ4_XS", 256, 136), + 24: ("I8", 1, 1), + 25: ("I16", 1, 2), + 26: ("I32", 1, 4), + 27: ("I64", 1, 8), + 28: ("F64", 1, 8), + 29: ("IQ1_M", 256, 56), + 30: ("BF16", 1, 2), + 34: ("TQ1_0", 256, 54), + 35: ("TQ2_0", 256, 66), + 39: ("MXFP4", 32, 17), + 40: ("NVFP4", 64, 36), + 41: ("Q1_0", 128, 18), + 42: ("Q2_0", 64, 18), +} + +FILE_TYPES = { + 0: "ALL_F32", + 1: "MOSTLY_F16", + 2: "MOSTLY_Q4_0", + 3: "MOSTLY_Q4_1", + 7: "MOSTLY_Q8_0", + 8: "MOSTLY_Q5_0", + 9: "MOSTLY_Q5_1", + 10: "MOSTLY_Q2_K", + 11: "MOSTLY_Q3_K_S", + 12: "MOSTLY_Q3_K_M", + 13: "MOSTLY_Q3_K_L", + 14: "MOSTLY_Q4_K_S", + 15: "MOSTLY_Q4_K_M", + 16: "MOSTLY_Q5_K_S", + 17: "MOSTLY_Q5_K_M", + 18: "MOSTLY_Q6_K", + 19: "MOSTLY_IQ2_XXS", + 20: "MOSTLY_IQ2_XS", + 21: "MOSTLY_Q2_K_S", + 22: "MOSTLY_IQ3_XS", + 23: "MOSTLY_IQ3_XXS", + 24: "MOSTLY_IQ1_S", + 25: "MOSTLY_IQ4_NL", + 26: "MOSTLY_IQ3_S", + 27: "MOSTLY_IQ3_M", + 28: "MOSTLY_IQ2_S", + 29: "MOSTLY_IQ2_M", + 30: "MOSTLY_IQ4_XS", + 31: "MOSTLY_IQ1_M", + 32: "MOSTLY_BF16", + 36: "MOSTLY_TQ1_0", + 37: "MOSTLY_TQ2_0", + 38: "MOSTLY_MXFP4_MOE", + 39: "MOSTLY_NVFP4", + 40: "MOSTLY_Q1_0", + 41: "MOSTLY_Q2_0", + 1024: "GUESSED", +} + +_SCALAR = {0: 1, 1: 1, 2: 2, 3: 2, 4: 4, 5: 4, 6: 4, 7: 1, 10: 8, 11: 8, 12: 8} +_INTEGER = {0: " str: + size = float(num_bytes) + for unit in ("B", "KiB", "MiB", "GiB", "TiB"): + if size < 1024 or unit == "TiB": + return f"{int(size)} B" if unit == "B" else f"{size:.2f} {unit}" + size /= 1024 + return f"{size:.2f} TiB" + + +def _read(handle, n: int) -> bytes: + data = handle.read(n) + if len(data) != n: + raise ValueError("truncated GGUF header") + return data + + +def _read_string(handle) -> bytes: + size = struct.unpack(" 64 * 1024 * 1024: + raise ValueError("GGUF string is too long") + return _read(handle, size) + + +def _read_scalar(handle, value_type: int): + if value_type == 8: + return _read_string(handle).decode("utf-8", "replace") + if value_type not in _SCALAR: + raise ValueError(f"unknown GGUF value type {value_type}") + raw = _read(handle, _SCALAR[value_type]) + if value_type in _INTEGER: + return struct.unpack(_INTEGER[value_type], raw)[0] + if value_type in _FLOAT: + return struct.unpack(_FLOAT[value_type], raw)[0] + return None + + +def _read_array(handle, keep: bool): + subtype = struct.unpack(" limit: + values.append(f"... {count} items") + return values + + +def _type_name(value_type: int) -> str: + names = { + 0: "u8", 1: "i8", 2: "u16", 3: "i16", 4: "u32", 5: "i32", + 6: "f32", 7: "bool", 8: "string", 10: "u64", 11: "i64", 12: "f64", + } + return names.get(value_type, str(value_type)) + + +def load_gguf(path: Path) -> tuple[dict, list[dict], int]: + metadata: dict = {} + tensors: list[dict] = [] + with path.open("rb") as handle: + magic, version = struct.unpack(" str: + for key, value in metadata.items(): + if key.endswith(".context_length") and ".rope." not in key and isinstance(value, int) and value > 0: + return str(value) + return "" + + +def _tensor_bytes(tensor: dict) -> int | None: + spec = GGML_TYPES.get(tensor["type"]) + if spec is None or not tensor["dims"]: + return None + _name, block, type_size = spec + elements = 1 + for dim in tensor["dims"]: + elements *= dim + if elements % block != 0: + return None + return elements // block * type_size + + +def _preview(value, limit: int = 160) -> str: + if isinstance(value, str): + flat = value.replace("\n", "\\n") + if len(flat) > limit: + return f"{flat[:limit]}... ({len(value)} chars)" + return flat + if isinstance(value, list): + text = ", ".join(_preview(item, 40) for item in value[:12]) + if len(value) > 12: + text += f", ... ({len(value)})" + return "[" + text + "]" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, float): + return f"{value:.6g}" + return str(value) + + +def _filetype(value) -> str: + if not isinstance(value, int): + return _preview(value) + name = FILE_TYPES.get(value) + return f"{name} ({value})" if name else str(value) + + +def print_summary(path: Path, metadata: dict, tensors: list[dict], version: int) -> None: + arch = str(metadata.get("general.architecture") or "") + print(f"file {path}") + print(f"size {format_size(path.stat().st_size)}") + print(f"gguf version {version}") + print(f"architecture {arch or '-'}") + for label, key in ( + ("name", "general.name"), + ("basename", "general.basename"), + ("size label", "general.size_label"), + ("finetune", "general.finetune"), + ("type", "general.type"), + ): + if key in metadata and metadata[key] not in ("", None): + print(f"{label:<14}{_preview(metadata[key])}") + if "general.file_type" in metadata: + print(f"filetype {_filetype(metadata['general.file_type'])}") + if "general.quantization_version" in metadata: + print(f"quant version {metadata['general.quantization_version']}") + context = _context(metadata) + if context: + print(f"context {context}") + fields = ( + ("blocks", "block_count"), + ("embedding", "embedding_length"), + ("feed forward", "feed_forward_length"), + ("heads", "attention.head_count"), + ("kv heads", "attention.head_count_kv"), + ("head dim", "attention.key_length"), + ("experts", "expert_count"), + ("vocab", "vocab_size"), + ("sliding window", "attention.sliding_window"), + ) + for label, suffix in fields: + value = _meta(metadata, suffix) + if isinstance(value, int) and value > 0: + print(f"{label:<14}{value}") + swa = _meta(metadata, "attention.sliding_window_pattern") + if isinstance(swa, list) and swa and all(isinstance(item, (bool, int)) for item in swa): + flags = [bool(item) for item in swa] + print(f"swa layers {sum(flags)} sliding, {len(flags) - sum(flags)} full") + tok_model = metadata.get("tokenizer.ggml.model") + if tok_model: + print(f"tokenizer {tok_model}") + for label, key in ( + ("bos", "tokenizer.ggml.bos_token_id"), + ("eos", "tokenizer.ggml.eos_token_id"), + ): + if key in metadata: + print(f"{label:<14}{metadata[key]}") + template = metadata.get("tokenizer.chat_template") + if isinstance(template, str) and template: + print(f"chat template yes ({len(template)} chars)") + elif isinstance(template, list) and template: + print(f"chat template {len(template)} templates") + else: + print("chat template no") + if isinstance(metadata.get("general.description"), str): + print(f"description {_preview(metadata['general.description'], 200)}") + + counts: Counter[str] = Counter() + known_bytes = 0 + unknown = 0 + for tensor in tensors: + spec = GGML_TYPES.get(tensor["type"]) + counts[spec[0] if spec else f"type {tensor['type']}"] += 1 + nbytes = _tensor_bytes(tensor) + if nbytes is None: + unknown += 1 + else: + known_bytes += nbytes + print(f"tensors {len(tensors)}") + for name, count in counts.most_common(): + print(f" {name:<10} {count}") + print(f"tensor bytes {format_size(known_bytes)}") + if unknown: + print(f"unmeasured {unknown} tensors have an unknown type or shape") + + +def print_metadata(metadata: dict) -> None: + print() + print("metadata") + width = max(len(key) for key in metadata) if metadata else 8 + for key in metadata: + print(f" {key:<{width}} {_preview(metadata[key])}") + + +def print_tensors(tensors: list[dict]) -> None: + print() + print(f"{'NAME':<48} {'TYPE':<8} {'SHAPE'}") + for tensor in tensors: + spec = GGML_TYPES.get(tensor["type"]) + kind = spec[0] if spec else str(tensor["type"]) + # File order: the first dimension is the contiguous one. + shape = " x ".join(str(dim) for dim in tensor["dims"]) or "-" + print(f" {tensor['name']:<46} {kind:<8} {shape}") + + +def inspect(path: Path, metadata_dump: bool, tensor_dump: bool) -> None: + if not path.is_file(): + raise SystemExit(f"Not a file: {path}") + try: + metadata, tensors, version = load_gguf(path) + except (OSError, ValueError, struct.error, UnicodeError) as exc: + raise SystemExit(f"{path}: {exc}") from exc + print_summary(path, metadata, tensors, version) + if metadata_dump: + print_metadata(metadata) + if tensor_dump: + print_tensors(tensors) + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Print architecture, quantization, and tensor info from a GGUF header.") + parser.add_argument("gguf", nargs="+", type=Path, help="One or more .gguf files") + parser.add_argument("--metadata", action="store_true", help="Print every metadata key. Long strings and the vocabulary are shortened.") + parser.add_argument("--tensors", action="store_true", help="Print every tensor name, type, and shape.") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> None: + args = parse_args(argv) + for index, path in enumerate(args.gguf): + if index: + print() + inspect(path, args.metadata, args.tensors) + + +if __name__ == "__main__": + main() diff --git a/gguf_to_nvfp4_gguf.py b/gguf_to_nvfp4_gguf.py new file mode 100644 index 0000000..1c61087 --- /dev/null +++ b/gguf_to_nvfp4_gguf.py @@ -0,0 +1,667 @@ +#!/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() diff --git a/hf_info.py b/hf_info.py new file mode 100644 index 0000000..db48505 --- /dev/null +++ b/hf_info.py @@ -0,0 +1,1105 @@ +#!/usr/bin/env python3 +"""Print a summary of a Hugging Face checkpoint. + +Weight bytes are not read. config.json, the tokenizer files, and safetensors +headers are enough for the architecture, quantization, and tensor list. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import struct +import sys +from collections import Counter +from dataclasses import dataclass +from pathlib import Path + +# Same round-trip check as nvfp4gguf.bundle.chat_template_has_tools. +_TOOL_RESULT_MARKERS = ( + "tool_response", + "tool_results", + "role'] == 'tool'", + "role'] == \"tool\"", + "role\"] == 'tool'", + "role\"] == \"tool\"", + "message.role == 'tool'", + "message.role == \"tool\"", + "ipython", +) + +_NESTED_CONFIGS = ( + "text_config", + "llm_config", + "language_config", + "thinker_config", + "decoder", +) + +_INDEX_NAMES = ( + "model.safetensors.index.json", + "diffusion_pytorch_model.safetensors.index.json", +) + +_MAX_HEADER = 64 * 1024 * 1024 + + +@dataclass +class TensorInfo: + name: str + dtype: str + shape: list[int] + nbytes: int | None + file: str + + +@dataclass +class ShardInfo: + path: Path + header_size: int + data_end: int + metadata: dict + + +@dataclass +class Checkpoint: + path: Path + root: Path + config: dict + config_name: str + generation: dict + tokenizer: dict + quant: dict + hf_quant: dict | None + recipe: str | None + base_model: str | None + tensors: list[TensorInfo] + shards: list[ShardInfo] + shard_total: int + partial: bool + index_meta: dict + template: str + template_where: str + template_count: int + nvfp4: bool + comfy: bool + + +def format_size(num_bytes: int) -> str: + size = float(num_bytes) + for unit in ("B", "KiB", "MiB", "GiB", "TiB"): + if size < 1024 or unit == "TiB": + return f"{int(size)} B" if unit == "B" else f"{size:.2f} {unit}" + size /= 1024 + return f"{size:.2f} TiB" + + +def format_params(count: int) -> str: + if count >= 1_000_000_000: + return f"{count} ({count / 1_000_000_000:.2f}B)" + if count >= 1_000_000: + return f"{count} ({count / 1_000_000:.2f}M)" + return str(count) + + +def format_number(value) -> str: + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, int): + return str(value) + if isinstance(value, float): + if value.is_integer() and abs(value) < 1e15: + return str(int(value)) + return f"{value:.6g}" + return _preview(value) + + +def _preview(value, limit: int = 160) -> str: + if isinstance(value, str): + flat = value.replace("\n", "\\n") + if len(flat) > limit: + return f"{flat[:limit]}... ({len(value)} chars)" + return flat + if isinstance(value, dict): + text = json.dumps(value, ensure_ascii=False, default=str, separators=(", ", ": ")) + if len(text) > limit: + return f"{text[:limit]}... ({len(text)} chars)" + return text + if isinstance(value, list): + text = ", ".join(_preview(item, 40) for item in value[:12]) + if len(value) > 12: + text += f", ... ({len(value)})" + return "[" + text + "]" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, float): + return format_number(value) + if value is None: + return "null" + return str(value) + + +def _line(label: str, value) -> None: + print(f"{label:<16}{value}") + + +def read_json(path: Path): + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeError) as exc: + raise ValueError(f"{path}: {exc}") from exc + try: + return json.loads(text) + except json.JSONDecodeError as exc: + raise ValueError(f"{path}: invalid JSON ({exc.msg})") from exc + + +def read_header(path: Path) -> tuple[dict, int]: + with path.open("rb") as handle: + raw = handle.read(8) + if len(raw) != 8: + raise ValueError(f"{path} is not a safetensors file") + header_size = struct.unpack(" _MAX_HEADER: + raise ValueError(f"{path} is not a safetensors file") + payload = handle.read(header_size) + if len(payload) != header_size: + raise ValueError(f"{path} has a truncated safetensors header") + try: + header = json.loads(payload) + except json.JSONDecodeError as exc: + raise ValueError(f"{path} has an unreadable safetensors header") from exc + if not isinstance(header, dict): + raise ValueError(f"{path} has an unreadable safetensors header") + return header, header_size + + +def _sections(config: dict) -> list[dict]: + found = [config] + for key in _NESTED_CONFIGS: + nested = config.get(key) + if isinstance(nested, dict): + found.append(nested) + return found + + +def _present(config: dict, keys: tuple[str, ...]): + for section in _sections(config): + for key in keys: + if key in section and section[key] not in (None, ""): + return section[key] + return None + + +def _positive_int(config: dict, keys: tuple[str, ...], limit: int = 10**12) -> int | None: + value = _present(config, keys) + if isinstance(value, bool): + return None + if isinstance(value, int) and 0 < value <= limit: + return value + if isinstance(value, float) and value.is_integer() and 0 < value <= limit: + return int(value) + return None + + +def _text(value) -> str: + return value if isinstance(value, str) and value else "" + + +def chat_template_has_tools(template: str) -> bool: + if not template: + return False + if "tools" not in template and "tool_call" not in template: + return False + if "tool_calls" not in template and "assistant_tool_call" not in template: + return False + return any(marker in template for marker in _TOOL_RESULT_MARKERS) + + +def _template_parts(value) -> list[str]: + if isinstance(value, str): + return [value] if value.strip() else [] + if not isinstance(value, list): + return [] + parts = [] + for item in value: + if isinstance(item, str) and item.strip(): + parts.append(item) + elif isinstance(item, dict) and isinstance(item.get("template"), str) and item["template"].strip(): + parts.append(item["template"]) + return parts + + +def load_chat_template(root: Path, tokenizer: dict) -> tuple[str, str, int]: + parts = _template_parts(tokenizer.get("chat_template")) if tokenizer else [] + if parts: + count = len(tokenizer.get("chat_template")) if isinstance(tokenizer.get("chat_template"), list) else 1 + return "\n".join(parts), "tokenizer_config.json", count + path = root / "chat_template.jinja" + if path.is_file(): + try: + return path.read_text(encoding="utf-8"), path.name, 1 + except (OSError, UnicodeError) as exc: + raise ValueError(f"{path}: {exc}") from exc + return "", "", 0 + + +def _token_text(value) -> str: + if isinstance(value, str): + return value + if isinstance(value, dict) and isinstance(value.get("content"), str): + return value["content"] + return "" + + +def token_label(token_id, tokenizer: dict) -> str: + if isinstance(token_id, list): + return ", ".join(token_label(item, tokenizer) for item in token_id) + name = "" + decoder = tokenizer.get("added_tokens_decoder") if tokenizer else None + if isinstance(decoder, dict): + entry = decoder.get(str(token_id)) + if isinstance(entry, dict): + name = _token_text(entry.get("content")) + if name: + return f"{token_id} ({name})" + return str(token_id) + + +def _token_id(source: dict, key: str): + if not source or key not in source: + return None + value = source.get(key) + if isinstance(value, bool) or value is None or value == "": + return None + if isinstance(value, int): + return value + if isinstance(value, list) and value and all(isinstance(item, int) and not isinstance(item, bool) for item in value): + return value + return None + + +def read_base_model(path: Path) -> str | None: + if not path.is_file(): + return None + try: + text = path.read_text(encoding="utf-8", errors="replace")[:65536] + except OSError: + return None + if not text.startswith("---"): + return None + end = text.find("\n---", 3) + if end < 0: + return None + values: list[str] = [] + grabbing = False + for line in text[3:end].splitlines(): + if not grabbing: + if line.startswith("base_model:"): + rest = line.split(":", 1)[1].strip().strip("'\"") + if rest: + values.append(rest) + else: + grabbing = True + continue + stripped = line.strip() + if stripped.startswith("- "): + values.append(stripped[2:].strip().strip("'\"")) + continue + break + cleaned = [value for value in values if value] + return ", ".join(cleaned) if cleaned else None + + +def read_recipe_scheme(path: Path) -> str | None: + if not path.is_file(): + return None + try: + text = path.read_text(encoding="utf-8", errors="replace")[:65536] + except OSError: + return None + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("scheme:"): + value = stripped.split(":", 1)[1].strip().strip("'\"") + return value or None + return None + + +def load_quant(config: dict, root: Path) -> tuple[dict, dict | None]: + raw = config.get("quantization_config") + quant = dict(raw) if isinstance(raw, dict) else {} + path = root / "hf_quant_config.json" + if not path.is_file(): + return quant, None + extra = read_json(path) + if not isinstance(extra, dict): + raise ValueError(f"{path} is not a JSON object") + block = extra.get("quantization") if isinstance(extra.get("quantization"), dict) else {} + producer = extra.get("producer") if isinstance(extra.get("producer"), dict) else {} + for key, value in block.items(): + quant.setdefault(key, value) + if "quant_method" not in quant and isinstance(producer.get("name"), str) and producer["name"]: + quant["quant_method"] = producer["name"].lower() + return quant, extra + + +def quant_is_nvfp4(quant: dict) -> bool: + algo = str(quant.get("quant_algo") or "") + fmt = str(quant.get("format") or "") + if algo in {"NVFP4", "W4A16_NVFP4"} or algo.endswith("NVFP4"): + return True + if "nvfp4" in fmt.lower(): + return True + groups = quant.get("config_groups") + if isinstance(groups, dict): + for group in groups.values(): + if isinstance(group, dict) and "nvfp4" in str(group.get("format") or "").lower(): + return True + layers = quant.get("quantized_layers") + if isinstance(layers, dict): + for entry in layers.values(): + if isinstance(entry, dict) and str(entry.get("quant_algo") or "").endswith("NVFP4"): + return True + return False + + +def _weight_schemes(quant: dict) -> list[tuple]: + found = [] + groups = quant.get("config_groups") + if isinstance(groups, dict): + for group in groups.values(): + if not isinstance(group, dict): + continue + weights = group.get("weights") if isinstance(group.get("weights"), dict) else {} + acts = group.get("input_activations") if isinstance(group.get("input_activations"), dict) else {} + found.append((weights.get("num_bits"), weights.get("group_size"), acts.get("num_bits"))) + if found: + return found + bits = quant.get("bits", quant.get("num_bits", quant.get("w_bit"))) + group_size = quant.get("group_size", quant.get("groupsize")) + if bits is not None or group_size is not None: + return [(bits, group_size, None)] + return [] + + +def find_index(root: Path) -> Path | None: + for name in _INDEX_NAMES: + path = root / name + if path.is_file(): + return path + found = sorted(path for path in root.glob("*.safetensors.index.json") if path.is_file()) + return found[0] if found else None + + +def shards_from_index(index_path: Path, payload: dict) -> list[Path]: + weight_map = payload.get("weight_map") + if not isinstance(weight_map, dict) or not weight_map: + raise ValueError(f"{index_path} has no weight_map") + root = index_path.parent + names: list[Path] = [] + seen: set[str] = set() + for name in weight_map.values(): + if not isinstance(name, str) or not name or name.startswith(("/", "\\")) or ".." in Path(name).parts: + raise ValueError(f"{index_path} has an invalid weight_map entry") + if name not in seen: + seen.add(name) + names.append(root / name) + return names + + +def discover_shards(root: Path) -> list[Path]: + for name in ("model.safetensors", "diffusion_pytorch_model.safetensors"): + path = root / name + if path.is_file(): + return [path] + return sorted(path for path in root.glob("*.safetensors") if path.is_file()) + + +def parse_tensor(name: str, entry, file_name: str) -> tuple[TensorInfo, int]: + if not isinstance(entry, dict): + raise ValueError(f"{file_name}: tensor {name!r} has an invalid header entry") + dtype = entry.get("dtype") + if not isinstance(dtype, str) or not dtype: + dtype = "?" + shape = entry.get("shape") + if shape is None: + dims: list[int] = [] + elif isinstance(shape, list) and all(isinstance(dim, int) and not isinstance(dim, bool) for dim in shape): + dims = [int(dim) for dim in shape] + else: + raise ValueError(f"{file_name}: tensor {name!r} has an invalid shape") + offsets = entry.get("data_offsets") + nbytes = None + end = 0 + if isinstance(offsets, list) and len(offsets) == 2 and all(isinstance(item, int) and not isinstance(item, bool) for item in offsets): + start, end = int(offsets[0]), int(offsets[1]) + if start < 0 or end < start: + raise ValueError(f"{file_name}: tensor {name!r} has invalid data offsets") + nbytes = end - start + return TensorInfo(name, dtype, dims, nbytes, file_name), end + + +def load_shards(paths: list[Path]) -> tuple[list[TensorInfo], list[ShardInfo]]: + tensors: list[TensorInfo] = [] + shards: list[ShardInfo] = [] + owners: dict[str, str] = {} + for path in paths: + header, header_size = read_header(path) + meta = header.pop("__metadata__", None) + if not isinstance(meta, dict): + meta = {} + data_end = 0 + for name, entry in header.items(): + if name.startswith("__"): + continue + if name in owners: + raise ValueError(f"Tensor {name!r} appears in both {owners[name]} and {path.name}") + owners[name] = path.name + tensor, end = parse_tensor(name, entry, path.name) + data_end = max(data_end, end) + tensors.append(tensor) + shards.append(ShardInfo(path, header_size, data_end, meta)) + return tensors, shards + + +def _natural_key(name: str) -> tuple: + return tuple((0, int(part)) if part.isdigit() else (1, part) for part in re.split(r"(\d+)", name)) + + +def tensor_type(tensor: TensorInfo, quant_method: str, nvfp4: bool, comfy: bool) -> str: + leaf = tensor.name.rsplit(".", 1)[-1] + if nvfp4 and leaf == "weight_packed": + return "NVFP4" + if comfy and leaf == "weight" and tensor.dtype == "U8": + return "NVFP4" + if quant_method.lower() in {"gptq", "awq"} and leaf == "qweight": + return quant_method.upper() + return tensor.dtype + + +def _numel(shape: list[int]) -> int: + count = 1 + for dim in shape: + count *= dim + return count + + +def parameter_count(tensors: list[TensorInfo], type_of) -> int | None: + """Logical weights and biases. + + Packed NVFP4 stores two FP4 values in each byte, so those element counts + are doubled. Scale tensors are not parameters. Other packed layouts are + left uncounted rather than reported as a partial total. + """ + total = 0 + seen = False + for tensor in tensors: + leaf = tensor.name.rsplit(".", 1)[-1] + packed = leaf in {"weight_packed", "qweight"} + if packed and type_of(tensor) != "NVFP4": + return None + if leaf not in {"weight", "bias", "weight_packed"}: + continue + seen = True + count = _numel(tensor.shape) + if type_of(tensor) == "NVFP4": + count *= 2 + total += count + return total if seen else None + + +def _is_model_dir(path: Path) -> bool: + return path.name in {"config.json", "adapter_config.json"} or path.name.endswith(".safetensors.index.json") + + +def _load(path: Path) -> Checkpoint: + if not path.exists(): + raise SystemExit(f"Not found: {path}") + if path.is_file() and path.suffix == ".gguf": + raise SystemExit(f"{path} is a GGUF file. Use gguf_info.py for GGUF files.") + if path.is_file() and _is_model_dir(path): + root = path.parent + selected: list[Path] | None = None + elif path.is_file(): + if path.suffix != ".safetensors": + raise SystemExit(f"{path} is not a model directory or a safetensors file") + root = path.parent + selected = [path] + elif path.is_dir(): + root = path + selected = None + else: + raise SystemExit(f"Not a file or directory: {path}") + + config_path = root / "config.json" + config_name = "config.json" + if not config_path.is_file() and (root / "adapter_config.json").is_file(): + config_path = root / "adapter_config.json" + config_name = config_path.name + config: dict = {} + if config_path.is_file(): + loaded = read_json(config_path) + if not isinstance(loaded, dict): + raise ValueError(f"{config_path} is not a JSON object") + config = loaded + + generation_path = root / "generation_config.json" + generation: dict = {} + if generation_path.is_file(): + loaded = read_json(generation_path) + if isinstance(loaded, dict): + generation = loaded + + tokenizer_path = root / "tokenizer_config.json" + tokenizer: dict = {} + if tokenizer_path.is_file(): + loaded = read_json(tokenizer_path) + if isinstance(loaded, dict): + tokenizer = loaded + + quant, hf_quant = load_quant(config, root) + recipe = read_recipe_scheme(root / "recipe.yaml") + base_model = read_base_model(root / "README.md") + repo = config.get("_name_or_path") + if not base_model and isinstance(repo, str) and "/" in repo and not repo.startswith(("/", ".")): + base_model = repo + + index_path = find_index(root) + index_payload: dict = {} + weight_map: dict = {} + indexed: list[Path] = [] + if index_path is not None: + loaded = read_json(index_path) + if not isinstance(loaded, dict): + raise ValueError(f"{index_path} is not a JSON object") + index_payload = loaded + raw_map = loaded.get("weight_map") + weight_map = raw_map if isinstance(raw_map, dict) else {} + indexed = shards_from_index(index_path, loaded) + if selected is None: + shard_paths = indexed if indexed else discover_shards(root) + missing = [path.name for path in shard_paths if not path.is_file()] + if missing: + raise ValueError(f"Index entries are missing from {root}: {', '.join(missing)}") + else: + shard_paths = selected + + if not config and not shard_paths: + raise SystemExit(f"No config.json or safetensors files in {root}") + + tensors, shards = load_shards(shard_paths) if shard_paths else ([], []) + opened = {shard.path.name for shard in shards} + index_names = {path.name for path in indexed} + partial = bool(index_names) and opened != index_names and opened.issubset(index_names) + if index_names and opened == index_names: + missing = [name for name in weight_map if isinstance(name, str) and name not in {tensor.name for tensor in tensors}] + if missing: + raise ValueError(f"Index lists {missing[0]!r}, which is not in the safetensors headers") + + comfy = any(tensor.name.endswith(".comfy_quant") for tensor in tensors) or any( + "_quantization_metadata" in shard.metadata for shard in shards + ) + packed = any(tensor.name.endswith("weight_packed") for tensor in tensors) + recipe_nvfp4 = bool(recipe) and "NVFP4" in recipe.upper() + nvfp4 = quant_is_nvfp4(quant) or comfy or (recipe_nvfp4 and packed) + template, template_where, template_count = load_chat_template(root, tokenizer) + index_meta = index_payload.get("metadata") if isinstance(index_payload.get("metadata"), dict) else {} + + return Checkpoint( + path=path, + root=root, + config=config, + config_name=config_name, + generation=generation, + tokenizer=tokenizer, + quant=quant, + hf_quant=hf_quant, + recipe=recipe, + base_model=base_model, + tensors=tensors, + shards=shards, + shard_total=len(indexed) if indexed else len(shards), + partial=partial, + index_meta=index_meta, + template=template, + template_where=template_where, + template_count=template_count, + nvfp4=nvfp4, + comfy=comfy, + ) + + +def load_checkpoint(path: Path) -> Checkpoint: + try: + return _load(path) + except (OSError, ValueError, struct.error, UnicodeError) as exc: + raise SystemExit(str(exc)) from exc + + +def _type_of(checkpoint: Checkpoint): + method = str(checkpoint.quant.get("quant_method") or "") + + def kind(tensor: TensorInfo) -> str: + return tensor_type(tensor, method, checkpoint.nvfp4, checkpoint.comfy) + + return kind + + +def _quant_title(checkpoint: Checkpoint) -> str: + quant = checkpoint.quant + algo = quant.get("quant_algo") + if isinstance(algo, str) and algo: + return algo + if checkpoint.nvfp4: + return "NVFP4" + fmt = quant.get("format") + if isinstance(fmt, str) and fmt: + return fmt + method = quant.get("quant_method") + if isinstance(method, str) and method: + return method + if checkpoint.recipe: + return checkpoint.recipe + return "" + + +def print_identity(checkpoint: Checkpoint) -> None: + _line("file", checkpoint.path) + if checkpoint.shards: + size = sum(shard.path.stat().st_size for shard in checkpoint.shards) + _line("size", format_size(size)) + model_type = _text(_present(checkpoint.config, ("model_type",))) + architectures = checkpoint.config.get("architectures") + if isinstance(architectures, list): + arch_text = ", ".join(str(item) for item in architectures if item not in (None, "")) + else: + arch_text = _text(architectures) + if model_type: + _line("architecture", model_type) + if arch_text and arch_text != model_type: + _line("class", arch_text) + elif arch_text: + _line("architecture", arch_text) + if checkpoint.path.is_dir() or checkpoint.config: + _line("name", checkpoint.root.name) + else: + _line("name", checkpoint.path.stem) + if checkpoint.base_model and checkpoint.base_model != checkpoint.root.name: + _line("base model", checkpoint.base_model) + dtype = _text(_present(checkpoint.config, ("dtype", "torch_dtype"))) + if dtype: + _line("dtype", dtype) + + +def print_quantization(checkpoint: Checkpoint) -> None: + title = _quant_title(checkpoint) + if checkpoint.nvfp4 or checkpoint.quant: + if title: + _line("quantization", title) + elif checkpoint.recipe: + _line("recipe", checkpoint.recipe) + return + else: + return + method = _text(checkpoint.quant.get("quant_method")) + fmt = _text(checkpoint.quant.get("format")) + if method and method != title: + _line("quant method", method) + if fmt and fmt != title: + _line("quant format", fmt) + elif checkpoint.comfy and not fmt: + _line("quant format", "comfy") + schemes = _weight_schemes(checkpoint.quant) + bits = [] + groups = [] + act_bits = [] + for weight_bits, group_size, activation_bits in schemes: + if isinstance(weight_bits, int) and not isinstance(weight_bits, bool) and weight_bits not in bits: + bits.append(weight_bits) + if isinstance(group_size, int) and not isinstance(group_size, bool) and group_size > 0 and group_size not in groups: + groups.append(group_size) + if isinstance(activation_bits, int) and not isinstance(activation_bits, bool) and activation_bits not in act_bits: + act_bits.append(activation_bits) + if bits: + _line("bits", ", ".join(str(item) for item in bits)) + if groups: + _line("group size", ", ".join(str(item) for item in groups)) + if act_bits: + _line("act bits", ", ".join(str(item) for item in act_bits)) + status = _text(checkpoint.quant.get("quantization_status")) + if status: + _line("quant status", status) + version = checkpoint.quant.get("version") + if isinstance(version, (str, int, float)) and not isinstance(version, bool) and version != "": + _line("quant version", format_number(version) if isinstance(version, (int, float)) else version) + ignored = checkpoint.quant.get("ignore") + if isinstance(ignored, list) and ignored: + if all(isinstance(item, str) for item in ignored): + text = ", ".join(ignored) + if len(text) > 160: + text = text[:160] + "..." + _line("quant ignore", text) + else: + _line("quant ignore", _preview(ignored)) + elif isinstance(ignored, str) and ignored: + _line("quant ignore", ignored) + layers = _comfy_layers(checkpoint) + if layers: + _line("quant layers", layers) + + +def _comfy_layers(checkpoint: Checkpoint) -> int: + total = 0 + found = False + for shard in checkpoint.shards: + raw = shard.metadata.get("_quantization_metadata") + parsed = None + if isinstance(raw, str): + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + parsed = None + elif isinstance(raw, dict): + parsed = raw + if not isinstance(parsed, dict): + continue + layers = parsed.get("layers") + if isinstance(layers, dict): + total += len(layers) + found = True + return total if found else 0 + + +def _submodule_text(block: dict) -> str: + parts = [] + for label, keys in ( + ("layers", ("num_hidden_layers", "num_layers", "depth")), + ("width", ("hidden_size", "d_model", "embed_dim")), + ("heads", ("num_attention_heads", "num_heads")), + ): + for key in keys: + value = block.get(key) + if isinstance(value, int) and not isinstance(value, bool) and value > 0: + parts.append(f"{value} {label}") + break + return ", ".join(parts) + + +def print_architecture(checkpoint: Checkpoint) -> None: + config = checkpoint.config + context = _positive_int(config, ( + "max_position_embeddings", + "max_seq_len", + "n_ctx", + "n_positions", + "max_sequence_length", + "seq_length", + "model_max_length", + )) + if context: + _line("context", context) + tok_length = _positive_int(checkpoint.tokenizer, ("model_max_length",)) + if tok_length and tok_length != context: + _line("tok length", tok_length) + fields = ( + ("blocks", ("num_hidden_layers", "n_layer", "num_layers", "n_layers")), + ("embedding", ("hidden_size", "n_embd", "d_model", "hidden_dim")), + ("feed forward", ("intermediate_size", "n_inner", "ffn_dim", "intermediate_dim")), + ("heads", ("num_attention_heads", "n_head", "num_heads")), + ("kv heads", ("num_key_value_heads", "num_kv_heads", "n_head_kv")), + ("experts", ("num_experts", "num_local_experts", "n_routed_experts", "moe_num_experts")), + ("experts used", ("num_experts_per_tok", "num_experts_per_token")), + ("expert ffn", ("moe_intermediate_size", "expert_intermediate_size")), + ("vocab", ("vocab_size", "padded_vocab_size")), + ) + values = {label: _positive_int(config, keys) for label, keys in fields} + head_dim = _positive_int(config, ("head_dim", "attention_head_dim", "head_size")) + if head_dim is None and values["embedding"] and values["heads"] and values["embedding"] % values["heads"] == 0: + if _present(config, ("qk_nope_head_dim", "qk_rope_head_dim")) is None: + head_dim = values["embedding"] // values["heads"] + for label, _keys in fields: + if values[label]: + _line(label, values[label]) + if label == "kv heads" and head_dim: + _line("head dim", head_dim) + activation = _text(_present(config, ("hidden_act", "activation_function", "hidden_activation"))) + if activation: + _line("activation", activation) + rope = _present(config, ("rope_theta",)) + if isinstance(rope, (int, float)) and not isinstance(rope, bool) and rope != 0: + _line("rope theta", format_number(rope)) + window = _positive_int(config, ("sliding_window", "sliding_window_size")) + if window and _present(config, ("use_sliding_window",)) is not False: + _line("sliding window", window) + layer_types = _present(config, ("layer_types", "sliding_window_pattern")) + if isinstance(layer_types, list) and layer_types: + if all(isinstance(item, bool) for item in layer_types): + sliding = sum(bool(item) for item in layer_types) + _line("swa layers", f"{sliding} sliding, {len(layer_types) - sliding} full") + elif all(isinstance(item, str) for item in layer_types): + counts = Counter(layer_types) + if not (len(counts) == 1 and next(iter(counts)) in {"full_attention", "full"}): + text = ", ".join(f"{count} {name}" for name, count in counts.most_common()) + _line("layers", text) + for key, label in (("vision_config", "vision"), ("audio_config", "audio"), ("vision_tower", "vision")): + block = config.get(key) + if isinstance(block, dict): + text = _submodule_text(block) + if text: + _line(label, text) + elif isinstance(block, str) and block: + _line(label, block) + if _present(config, ("tie_word_embeddings", "tie_embeddings")) is True: + _line("tied embed", "yes") + + +def print_tokenizer(checkpoint: Checkpoint) -> None: + tokenizer = checkpoint.tokenizer + kind = _text(tokenizer.get("tokenizer_class")) + if not kind and (checkpoint.root / "tokenizer.model").is_file(): + kind = "sentencepiece" + elif not kind and (checkpoint.root / "tokenizer.json").is_file(): + kind = "tokenizer.json" + if kind: + _line("tokenizer", kind) + bos = _token_id(checkpoint.config, "bos_token_id") + if bos is None: + bos = _token_id(checkpoint.generation, "bos_token_id") + eos = _token_id(checkpoint.generation, "eos_token_id") + if eos is None: + eos = _token_id(checkpoint.config, "eos_token_id") + pad = _token_id(checkpoint.generation, "pad_token_id") + if pad is None: + pad = _token_id(checkpoint.config, "pad_token_id") + if bos is not None: + _line("bos", token_label(bos, tokenizer)) + elif _token_text(tokenizer.get("bos_token")): + _line("bos", _token_text(tokenizer.get("bos_token"))) + if eos is not None: + _line("eos", token_label(eos, tokenizer)) + elif _token_text(tokenizer.get("eos_token")): + _line("eos", _token_text(tokenizer.get("eos_token"))) + if pad is not None: + _line("pad", token_label(pad, tokenizer)) + elif _token_text(tokenizer.get("pad_token")): + _line("pad", _token_text(tokenizer.get("pad_token"))) + if checkpoint.template: + detail = f"{len(checkpoint.template)} chars" + if checkpoint.template_count > 1: + detail = f"{checkpoint.template_count} templates, {detail}" + if checkpoint.template_where: + detail = f"{detail}, {checkpoint.template_where}" + if chat_template_has_tools(checkpoint.template): + detail = f"{detail}, tools" + _line("chat template", f"yes ({detail})") + else: + _line("chat template", "no") + + +def print_tensor_summary(checkpoint: Checkpoint) -> None: + if checkpoint.partial: + _line("shards", f"{len(checkpoint.shards)} of {checkpoint.shard_total}") + elif len(checkpoint.shards) > 1: + _line("shards", str(len(checkpoint.shards))) + truncated = [ + shard.path.name + for shard in checkpoint.shards + if shard.path.stat().st_size < 8 + shard.header_size + shard.data_end + ] + if truncated: + shown = ", ".join(truncated[:4]) + if len(truncated) > 4: + shown += f", ... ({len(truncated)})" + _line("truncated", shown) + kind = _type_of(checkpoint) + params = None if checkpoint.partial else parameter_count(checkpoint.tensors, kind) + if params: + _line("parameters", format_params(params)) + _line("tensors", str(len(checkpoint.tensors))) + if not checkpoint.tensors: + return + counts: Counter[str] = Counter() + stored: dict[str, Counter[str]] = {} + known = 0 + unknown = 0 + for tensor in checkpoint.tensors: + name = kind(tensor) + counts[name] += 1 + stored.setdefault(name, Counter())[tensor.dtype] += 1 + if tensor.nbytes is None: + unknown += 1 + else: + known += tensor.nbytes + width = max([10, *(len(name) for name in counts)]) + for name, count in counts.most_common(): + extra = "" + dtypes = stored[name] + if list(dtypes) != [name]: + if len(dtypes) == 1: + extra = f" (stored {next(iter(dtypes))})" + else: + detail = ", ".join(f"{dtype} x {number}" for dtype, number in dtypes.most_common()) + extra = f" (stored {detail})" + print(f" {name:<{width}} {count}{extra}") + _line("tensor bytes", format_size(known)) + if unknown: + _line("unmeasured", f"{unknown} tensors have no data length") + + +def print_summary(checkpoint: Checkpoint) -> None: + print_identity(checkpoint) + print_quantization(checkpoint) + print_architecture(checkpoint) + print_tokenizer(checkpoint) + print_tensor_summary(checkpoint) + + +def _metadata_tokenizer(tokenizer: dict) -> dict: + view = {} + for key, value in tokenizer.items(): + if key == "added_tokens_decoder" and isinstance(value, dict): + view[key] = f"{len(value)} added tokens" + else: + view[key] = value + return view + + +def _metadata_safetensors(metadata: dict) -> dict: + view = {} + for key, value in metadata.items(): + if key == "format" and value == "pt": + continue + if key == "_quantization_metadata": + parsed = None + if isinstance(value, str): + try: + parsed = json.loads(value) + except json.JSONDecodeError: + parsed = None + elif isinstance(value, dict): + parsed = value + layers = parsed.get("layers") if isinstance(parsed, dict) else None + if isinstance(parsed, dict) and isinstance(layers, dict): + version = parsed.get("format_version", "?") + view[key] = f"format {version}, {len(layers)} layers" + continue + view[key] = value + return view + + +def _print_mapping(title: str, mapping: dict) -> None: + if not mapping: + return + print() + print(title) + width = max(len(str(key)) for key in mapping) + for key, value in mapping.items(): + print(f" {key:<{width}} {_preview(value)}") + + +def print_metadata(checkpoint: Checkpoint) -> None: + if checkpoint.config: + _print_mapping(checkpoint.config_name, checkpoint.config) + if checkpoint.hf_quant: + _print_mapping("hf_quant_config.json", checkpoint.hf_quant) + if checkpoint.generation: + _print_mapping("generation_config.json", checkpoint.generation) + if checkpoint.tokenizer: + _print_mapping("tokenizer_config.json", _metadata_tokenizer(checkpoint.tokenizer)) + if checkpoint.template and not _template_parts(checkpoint.tokenizer.get("chat_template") if checkpoint.tokenizer else None): + _print_mapping(checkpoint.template_where or "chat_template", {"chat_template": checkpoint.template}) + if checkpoint.index_meta: + _print_mapping("safetensors index", checkpoint.index_meta) + metas = [] + for shard in checkpoint.shards: + cleaned = _metadata_safetensors(shard.metadata) + if cleaned and cleaned not in metas: + metas.append(cleaned) + title = "safetensors metadata" if len(checkpoint.shards) == 1 else f"safetensors metadata ({shard.path.name})" + if len(checkpoint.shards) > 1 and all(_metadata_safetensors(item.metadata) == cleaned for item in checkpoint.shards): + title = "safetensors metadata" + _print_mapping(title, cleaned) + if len(checkpoint.shards) > 1 and all(_metadata_safetensors(item.metadata) == cleaned for item in checkpoint.shards): + break + + +def _shape_text(tensor: TensorInfo) -> str: + return " x ".join(str(dim) for dim in tensor.shape) or "-" + + +def print_tensors(checkpoint: Checkpoint) -> None: + print() + if not checkpoint.tensors: + print("tensors") + print(" (none)") + return + kind = _type_of(checkpoint) + tensors = sorted(checkpoint.tensors, key=lambda tensor: _natural_key(tensor.name)) + if any(kind(tensor) == "NVFP4" for tensor in tensors): + print("NVFP4 shapes are packed storage, 2 FP4 values per byte.") + name_width = max(len(tensor.name) for tensor in tensors) + type_width = max(8, max(len(kind(tensor)) for tensor in tensors)) + shapes = [_shape_text(tensor) for tensor in tensors] + show_shard = len({tensor.file for tensor in tensors}) > 1 + shape_width = max(len("SHAPE"), *(len(shape) for shape in shapes)) if show_shard else 0 + shape_header = f"{'SHAPE':<{shape_width}}" if show_shard else "SHAPE" + header = f"{'NAME':<{name_width + 2}} {'TYPE':<{type_width}} {shape_header}" + if show_shard: + header += " SHARD" + print(header) + for tensor, shape in zip(tensors, shapes): + shown = f"{shape:<{shape_width}}" if show_shard else shape + row = f" {tensor.name:<{name_width}} {kind(tensor):<{type_width}} {shown}" + if show_shard: + row += f" {tensor.file}" + print(row) + + +def inspect(path: Path, metadata_dump: bool, tensor_dump: bool) -> None: + checkpoint = load_checkpoint(path) + print_summary(checkpoint) + if metadata_dump: + print_metadata(checkpoint) + if tensor_dump: + print_tensors(checkpoint) + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Print architecture, quantization, and tensor info from a Hugging Face checkpoint." + ) + parser.add_argument("model", nargs="+", type=Path, help="A model directory or a .safetensors file") + parser.add_argument( + "--metadata", + action="store_true", + help="Print config, tokenizer, and safetensors header metadata. Long values are shortened.", + ) + parser.add_argument("--tensors", action="store_true", help="Print every tensor name, type, and shape.") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> None: + args = parse_args(argv) + for index, path in enumerate(args.model): + if index: + print() + inspect(path, args.metadata, args.tensors) + + +if __name__ == "__main__": + try: + main() + except BrokenPipeError: + # head and less close the pipe early. Don't turn that into a traceback. + try: + sys.stdout.close() + except BrokenPipeError: + pass + try: + sys.stderr.close() + except BrokenPipeError: + pass + os._exit(0) diff --git a/hf_to_nvfp4_gguf.py b/hf_to_nvfp4_gguf.py new file mode 100644 index 0000000..e2c0011 --- /dev/null +++ b/hf_to_nvfp4_gguf.py @@ -0,0 +1,1118 @@ +#!/usr/bin/env python3 +"""Quantize a Hugging Face model to llama.cpp NVFP4 GGUF. + +Quantization lives in nvfp4gguf. This script loads the model through llama.cpp +so the vocabulary, tensor names, and architecture metadata match a normal GGUF. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import logging +import re +import shutil +import sys +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, + load_gguf_modules, + load_learned_converter, + must_bake_nvfp4_scale, + mtp_layer_tensor, + bake_packed_nvfp4, + preserve_scale_tensor, + quantize_and_pack, + report_reconstruction, + require_quant_settings, + scale_bake_notice, + resolve_optimizer, +) +from nvfp4gguf.bundle import ( + apply_hf_context_length, + chat_template_has_tools, + flatten_chat_template, + normalize_chat_template, + outtype as _outtype, + place_mmprojs as _place_mmprojs, + quote_modelfile_value as _quote_modelfile_value, + refuse_existing_bundle, + tool_modelfile_lines, + warn_missing_gguf_metadata, +) + +log = logging.getLogger("hf_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 _eligible(name: str, tensor, raw_dtype, args, gguf_module) -> bool: + if raw_dtype != gguf_module.GGMLQuantizationType.BF16: + return False + shape = tuple(int(dim) for dim in tensor.shape) + return eligible_weight(name, shape, args, gguf_module) + + +def _count_quant_targets(model, args, gguf_module) -> int: + """Count matrices the writer will quantize, using shapes from the file header.""" + total = 0 + for hf_name, lazy in model.get_tensors(): + try: + shape = tuple(int(dim) for dim in lazy.shape) + except Exception: + continue + if len(shape) < 2 or not str(hf_name).endswith(".weight"): + continue + try: + gguf_name = model.map_tensor_name(hf_name) + except Exception: + gguf_name = str(hf_name) + raw_dtype = gguf_module.GGMLQuantizationType.BF16 + if gguf_name.endswith("_norm.weight"): + raw_dtype = gguf_module.GGMLQuantizationType.F32 + if raw_dtype == gguf_module.GGMLQuantizationType.BF16 and eligible_weight(gguf_name, shape, args, gguf_module): + total += 1 + return total + + +def _materialize(array) -> np.ndarray: + gguf_lazy = sys.modules.get("gguf") + lazy_cls = getattr(gguf_lazy, "LazyNumpyTensor", None) if gguf_lazy else None + if lazy_cls is not None and isinstance(array, lazy_cls): + array = lazy_cls.to_eager(array) + array = np.asarray(array) + if array.dtype != np.float32: + array = array.astype(np.float32, copy=False) + return np.ascontiguousarray(array) + + +def _as_float_tensor(array, gguf_module): + lazy_cls = getattr(gguf_module, "LazyNumpyTensor", None) + chunk_cls = getattr(gguf_module, "LazyChunkedTensor", None) + if chunk_cls is not None and isinstance(array, chunk_cls): + return array + if lazy_cls is not None and isinstance(array, lazy_cls): + return array + return np.asarray(array) + + +class Nvfp4Writer: + def __init__(self, model, quantizer: TensorQuantizer, args, bias_names: set[str]): + self.model = model + self.quantizer = quantizer + self.args = args + self.gguf = quantizer.gguf + self.bias_names = set(bias_names) + self.bias_corrections: dict[str, np.ndarray] = {} + self.pending_bias: dict[str, tuple] = {} + self.quantized = 0 + self.baked = 0 + self.corrected = 0 + self.passthrough = 0 + self.planned = _count_quant_targets(model, args, self.gguf) + self.quant_index = 0 + if self.planned: + log.info("%d weight matrices queued for NVFP4", self.planned) + names = getattr(self.gguf, "MODEL_ARCH_NAMES", {}) + self.architecture = names.get(getattr(model, "model_arch", None), "") or "" + notice = scale_bake_notice(self.architecture) + if notice: + log.warning(notice) + self.ckpt_dir = Path(args.checkpoint_dir) if args.checkpoint_dir else Path(str(args.output) + ".nvfp4-work") + self.ckpt_dir.mkdir(parents=True, exist_ok=True) + self._orig_quantize = self.gguf.quants.quantize + self._orig_add = model.gguf_writer.add_tensor + self._orig_prepare = model.prepare_tensors + self._orig_metadata = model.prepare_metadata + + def install(self) -> None: + gguf_module = self.gguf + + def quantize(data, qtype): + # Keep the source float for matrices we may repack. Everything else + # still goes through llama.cpp's quantizer. + if qtype == gguf_module.GGMLQuantizationType.BF16: + return data + return self._orig_quantize(data, qtype) + + self.gguf.quants.quantize = quantize + self.model.gguf_writer.add_tensor = self._add_tensor + self.model.prepare_tensors = self._prepare_tensors + self.model.prepare_metadata = self._prepare_metadata + + def restore(self) -> None: + self.gguf.quants.quantize = self._orig_quantize + self.model.gguf_writer.add_tensor = self._orig_add + self.model.prepare_tensors = self._orig_prepare + self.model.prepare_metadata = self._orig_metadata + + def _prepare_tensors(self) -> None: + self._orig_prepare() + self._flush_pending() + + def _prepare_metadata(self, vocab_only: bool = False) -> None: + if self.quantized: + self.model.ftype = self.gguf.LlamaFileType.MOSTLY_NVFP4 + self._orig_metadata(vocab_only) + + def _write(self, name, tensor, raw_dtype=None) -> None: + self._orig_add(name, tensor, raw_dtype=raw_dtype) + + def _add_tensor(self, name, tensor, raw_shape=None, raw_dtype=None, tensor_endianess=None) -> None: + del raw_shape, tensor_endianess + if name.endswith(".bias"): + self._handle_bias(name, tensor, raw_dtype) + return + if _eligible(name, tensor, raw_dtype, self.args, self.gguf): + self._quantize_and_write(name, tensor) + return + if raw_dtype == self.gguf.GGMLQuantizationType.BF16: + tensor = self._orig_quantize(tensor, raw_dtype) + self._write(name, tensor, raw_dtype=raw_dtype) + self.passthrough += 1 + + def _handle_bias(self, name: str, tensor, raw_dtype) -> None: + correction = self.bias_corrections.get(name) + if correction is None and not self.args.no_bias_correction: + self.pending_bias[name] = (tensor, raw_dtype) + return + self._emit_bias(name, tensor, raw_dtype, correction) + + def _emit_bias(self, name: str, tensor, raw_dtype, correction: np.ndarray | None) -> None: + if correction is not None: + values = _materialize(tensor).astype(np.float32, copy=True) + flat = values.reshape(-1) + if flat.shape == correction.shape: + flat -= correction + values = flat.reshape(values.shape) + self.corrected += 1 + log.info(" bias %s adjusted (mean %+.3e)", name, float(correction.mean())) + else: + log.warning(" bias %s shape %s does not match correction %s", name, values.shape, correction.shape) + tensor = values.astype(np.float32, copy=False) + if raw_dtype == self.gguf.GGMLQuantizationType.BF16: + tensor = self._orig_quantize(tensor, raw_dtype) + elif raw_dtype not in (None, self.gguf.GGMLQuantizationType.F32): + tensor = self._orig_quantize(tensor, raw_dtype) + elif raw_dtype == self.gguf.GGMLQuantizationType.BF16: + tensor = self._orig_quantize(tensor, raw_dtype) + self._write(name, tensor, raw_dtype=raw_dtype) + self.passthrough += 1 + + def _flush_pending(self) -> None: + for name, (tensor, raw_dtype) in list(self.pending_bias.items()): + self._emit_bias(name, tensor, raw_dtype, self.bias_corrections.get(name)) + self.pending_bias.clear() + + def _load_checkpoint(self, name: str, logical_shape: tuple[int, ...]): + path = _checkpoint_path(self.ckpt_dir, 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(logical_shape): + return None + raw = np.array(blob["raw"], copy=True) + scale = np.array(blob["scale"], copy=True) + bias = np.array(blob["bias"], copy=True) + scale_out = None if scale.size == 0 else scale + bias_out = None if bias.size == 0 else bias + return raw, scale_out, bias_out + + def _save_checkpoint(self, name: str, logical_shape, raw, scale, bias) -> None: + path = _checkpoint_path(self.ckpt_dir, name) + tmp = path.with_name(path.name + ".tmp") + with tmp.open("wb") as handle: + np.savez_compressed( + handle, + version=np.int32(QUANT_VERSION), + shape=np.array(logical_shape, dtype=np.int64), + raw=raw, + scale=np.zeros((0,), np.float32) if scale is None else scale.astype(np.float32, copy=False), + bias=np.zeros((0,), np.float32) if bias is None else bias.astype(np.float32, copy=False), + ) + tmp.replace(path) + + def _quantize_matrix_group(self, name: str, weight: np.ndarray) -> tuple[np.ndarray, np.ndarray | None, np.ndarray | None]: + baked = self._must_bake(name) + raw, scale = quantize_and_pack(self.quantizer, weight, baked, name) + bias = None + bias_name = name.replace(".weight", ".bias") + if weight.ndim == 2 and not self.args.no_bias_correction and bias_name in self.bias_names: + per_tensor = None if scale is None else float(np.reshape(scale, -1)[0]) + runtime = self.quantizer.runtime_dequant(raw, per_tensor) + bias = self.quantizer.bias_correction(weight, runtime) + report_reconstruction(self.quantizer, weight, raw, scale) + return raw, scale, bias + + def _must_bake(self, name: str) -> bool: + bias_name = name.replace(".weight", ".bias") + has_bias = bias_name in self.bias_names or bias_name in self.pending_bias + if must_bake_nvfp4_scale(self.architecture, name, has_bias): + return True + return mtp_layer_tensor(name, *_mtp_depth(self.model)) + + def _progress(self, name: str, shape: tuple[int, ...], checkpoint: bool) -> None: + self.quant_index += 1 + if self.planned: + slot = f"[{self.quant_index}/{self.planned}]" + else: + slot = f"[{self.quant_index}]" + note = " (checkpoint)" if checkpoint else "" + log.info("NVFP4 %s %s %s%s", slot, name, shape, note) + + def _quantize_and_write(self, name: str, tensor) -> None: + weight = _materialize(tensor) + baked = self._must_bake(name) + cached = self._load_checkpoint(name, weight.shape) + if cached is not None and (cached[1] is None) != baked: + log.info(" checkpoint scale layout does not match %s; recomputing", name) + cached = None + self._progress(name, tuple(weight.shape), cached is not None) + if cached is None: + raw, scale, bias = self._quantize_matrix_group(name, weight) + self._save_checkpoint(name, weight.shape, raw, scale, bias) + else: + raw, scale, bias = cached + if bias is not None: + self.bias_corrections[name.replace(".weight", ".bias")] = bias + pending = self.pending_bias.pop(name.replace(".weight", ".bias"), None) + if pending is not None: + self._emit_bias(name.replace(".weight", ".bias"), pending[0], pending[1], bias) + if self._must_bake(name) and scale is None: + self.baked += 1 + self._write(name, raw, raw_dtype=self.gguf.GGMLQuantizationType.NVFP4) + if scale is not None: + scale_name = name[: -len(".weight")] + ".scale" + self._write(scale_name, np.ascontiguousarray(scale, dtype=np.float32)) + self.quantized += 1 + del weight, raw + + +def _accept_list_extra_special_tokens() -> None: + """Let transformers 4.x load a Gemma 4 tokenizer. + + Gemma 4 stores extra_special_tokens as a list of token strings. The 4.x + loader calls .keys() on that field, which is what aborts set_vocab after + the weights have already been packed. + """ + try: + from transformers.tokenization_utils_base import PreTrainedTokenizerBase + except ImportError: + return + original = PreTrainedTokenizerBase._set_model_specific_special_tokens + if getattr(original, "_accepts_token_list", False): + return + + def _set_model_specific_special_tokens(self, special_tokens): + if isinstance(special_tokens, list): + log.info( + "tokenizer extra_special_tokens is a list; treating each entry as its own special token." + ) + special_tokens = {str(token): str(token) for token in special_tokens} + return original(self, special_tokens) + + _set_model_specific_special_tokens._accepts_token_list = True + PreTrainedTokenizerBase._set_model_specific_special_tokens = _set_model_specific_special_tokens + + +def _accept_missing_tokenizer_class() -> None: + """Load tokenizer.json when tokenizer_class is newer than this transformers. + + LFM3 names its tokenizer ``TokenizersBackend``. That class arrived in + transformers 5, and a 4.x install otherwise stops before the vocab is written. + """ + try: + from transformers import AutoTokenizer, PreTrainedTokenizerFast + except ImportError: + return + current = AutoTokenizer.from_pretrained + if getattr(current, "_accepts_missing_class", False): + return + original = current.__func__ + + def from_pretrained(cls, pretrained_model_name_or_path, *args, **kwargs): + try: + return original(cls, pretrained_model_name_or_path, *args, **kwargs) + except ValueError as exc: + if "does not exist or is not currently imported" not in str(exc): + raise + log.info( + "Tokenizer class is not in this transformers build; loading tokenizer.json directly." + ) + return PreTrainedTokenizerFast.from_pretrained( + pretrained_model_name_or_path, *args, **kwargs + ) + + from_pretrained._accepts_missing_class = True + wrapped = classmethod(from_pretrained) + wrapped._accepts_missing_class = True + AutoTokenizer.from_pretrained = wrapped + + +def _install_tokenizer_fallback(model) -> None: + """Use tokenizer.json when sentencepiece is not installed and no tokenizer.model is present.""" + _accept_list_extra_special_tokens() + _accept_missing_tokenizer_class() + try: + import sentencepiece # noqa: F401 + return + except ImportError: + pass + if (model.dir_model / "tokenizer.model").is_file(): + original = model.set_vocab + + def set_vocab_needs_sentencepiece() -> None: + try: + original() + except ModuleNotFoundError as exc: + if getattr(exc, "name", None) == "sentencepiece": + raise ModuleNotFoundError( + "This model ships tokenizer.model, which needs the sentencepiece package. " + "pip install sentencepiece" + ) from exc + raise + + model.set_vocab = set_vocab_needs_sentencepiece + return + if not (model.dir_model / "tokenizer.json").is_file(): + return + + def set_vocab() -> None: + config_path = model.dir_model / "tokenizer_config.json" + if config_path.is_file(): + with config_path.open("r", encoding="utf-8") as handle: + prefix = json.load(handle).get("add_prefix_space") + if prefix is not None: + model.gguf_writer.add_add_space_prefix(prefix) + log.info("sentencepiece is not installed; loading tokenizer.json") + model._set_vocab_gpt2() + + model.set_vocab = set_vocab + + +def _collect_bias_names(model) -> set[str]: + names: set[str] = set() + for hf_name in model.model_tensors: + if not hf_name.endswith(".bias"): + continue + try: + names.add(model.map_tensor_name(hf_name)) + except Exception: + continue + return names + + +def _float_file_type(gguf, outtype: str): + return { + "f32": gguf.LlamaFileType.ALL_F32, + "f16": gguf.LlamaFileType.MOSTLY_F16, + "bf16": gguf.LlamaFileType.MOSTLY_BF16, + "auto": gguf.LlamaFileType.GUESSED, + }[outtype] + + +_PACKED_WEIGHT_DTYPES = {"U8", "I8"} +_PACKED_SCALE_DTYPES = {"U8", "I8", "F8_E4M3", "F8_E5M2"} +_FP8_DTYPES = {"F8_E4M3", "F8_E5M2"} + + +def _numel(shape: tuple[int, ...]) -> int: + count = 1 + for dim in shape: + count *= int(dim) + return count + + +def _is_packed_nvfp4_layout( + weight_dtype: str, + weight_shape: tuple[int, ...], + scale_dtype: str, + scale_shape: tuple[int, ...], +) -> bool: + """Match llama.cpp's packed-4-bit check using safetensors header dtypes. + + Packed NVFP4 weights are U8 or I8, two 4-bit values per byte, with one + scale byte per 16 values (8 stored weight bytes per scale). A weight + labeled F8_E4M3 is an 8-bit float, not packed 4-bit data. + """ + if weight_dtype not in _PACKED_WEIGHT_DTYPES or scale_dtype not in _PACKED_SCALE_DTYPES: + return False + if len(weight_shape) < 2 or len(scale_shape) < 2: + return False + rows = int(weight_shape[0]) + n_blocks = int(scale_shape[1]) + if rows <= 0 or n_blocks <= 0 or n_blocks % 4 != 0: + return False + return _numel(weight_shape) == rows * n_blocks * 8 and _numel(scale_shape) == rows * n_blocks + + +def _scale_name_for_weight(name: str) -> str | None: + if name.endswith(".weight_packed"): + return name.removesuffix("_packed") + "_scale" + if name.endswith(".weight"): + return name[: -len(".weight")] + ".weight_scale" + return None + + +def _sample_names(names: list[str], limit: int = 4) -> str: + if len(names) <= limit: + return ", ".join(names) + return ", ".join(names[:limit]) + f", ... ({len(names) - limit} more)" + + +class _TensorLayout: + def __init__(self) -> None: + self.packed: list[str] = [] + self.fp8: list[str] = [] + self.unrecognized: list[str] = [] + + @property + def found(self) -> bool: + return bool(self.packed or self.fp8 or self.unrecognized) + + +def _checkpoint_tensor_layout(model_dir: Path) -> _TensorLayout: + """Classify safetensors by dtype and shape without reading the bytes.""" + layout = _TensorLayout() + files = sorted(model_dir.glob("*.safetensors")) + if not files: + return layout + try: + from safetensors import safe_open + except ImportError: + log.warning("safetensors is not installed, so packed 4-bit tensors were not prechecked.") + return layout + meta: dict[str, tuple[str, tuple[int, ...]]] = {} + for path in files: + with safe_open(path, framework="pt", device="cpu") as handle: + for key in handle.keys(): + header = handle.get_slice(key) + meta[key] = (str(header.get_dtype()), tuple(int(dim) for dim in header.get_shape())) + for name, (dtype, shape) in meta.items(): + scale_name = _scale_name_for_weight(name) + if scale_name is None: + continue + scale = meta.get(scale_name) + if scale is not None and _is_packed_nvfp4_layout(dtype, shape, scale[0], scale[1]): + layout.packed.append(name) + continue + if dtype in _FP8_DTYPES: + layout.fp8.append(name) + elif dtype in {"U8", "I8"} and scale is not None: + layout.unrecognized.append(name) + layout.packed.sort() + layout.fp8.sort() + layout.unrecognized.sort() + return layout + + +def _log_tensor_layout(layout: _TensorLayout) -> None: + if not layout.found: + return + log.info( + "Safetensors layout: %d packed 4-bit, %d FP8 weights that are not 4-bit, %d other byte weights that are not packed 4-bit.", + len(layout.packed), + len(layout.fp8), + len(layout.unrecognized), + ) + if layout.packed: + log.info("Packed 4-bit, repacked as NVFP4: %s", _sample_names(layout.packed)) + if layout.fp8: + log.info("FP8, stored as float: %s", _sample_names(layout.fp8)) + if layout.unrecognized: + log.warning( + "These tensors are U8 or I8 but do not match packed 4-bit NVFP4, so they will not be repacked: %s", + _sample_names(layout.unrecognized), + ) + + +def _checkpoint_is_nvfp4(model_dir: Path, hparams: dict) -> bool: + """Match llama.cpp's NVFP4 detection in ModelBase.prepare_tensors.""" + quantization_config = hparams.get("quantization_config") or {} + quant_algo = quantization_config.get("quant_algo") + quant_method = quantization_config.get("quant_method") + quant_format = quantization_config.get("format") + quant_groups = quantization_config.get("config_groups") or {} + quant_layers = quantization_config.get("quantized_layers") or {} + quant_config_file = model_dir / "hf_quant_config.json" + if (not quant_algo or not quant_layers) and quant_config_file.is_file(): + with quant_config_file.open("r", encoding="utf-8") as handle: + hf_quant_config = json.load(handle) + quant_config = hf_quant_config.get("quantization") or {} + producer = hf_quant_config.get("producer") or {} + if quant_method is None: + quant_method = (producer.get("name") or "").lower() + quant_algo = quant_config.get("quant_algo", quant_algo) + quant_method = quant_config.get("quant_method", quant_method) + quant_format = quant_config.get("format", quant_format) + quant_groups = quant_config.get("config_groups", quant_groups) or {} + quant_layers = quant_config.get("quantized_layers", quant_layers) or {} + if quant_algo in ("NVFP4", "W4A16_NVFP4"): + return True + if quant_method == "compressed-tensors" and ( + quant_format == "nvfp4-pack-quantized" + or ( + quant_format == "mixed-precision" + and any( + isinstance(group, dict) and group.get("format") == "nvfp4-pack-quantized" + for group in quant_groups.values() + ) + ) + ): + return True + return any( + isinstance(entry, dict) and str(entry.get("quant_algo", "")).endswith("NVFP4") + for entry in quant_layers.values() + ) + + +def _token_text(value) -> str | None: + if isinstance(value, str): + return value or None + if isinstance(value, dict): + content = value.get("content") + return content if isinstance(content, str) and content else None + return None + + +def _hf_stops(model_dir: Path) -> list[str]: + """Stop strings Ollama should use for this tokenizer. + + Llama 3's EOS list includes <|eom_id|>, which ends a tool-call turn inside + the chat template. Stopping on it clips the call. The working Llama 3.1 + Modelfile stops on the header tokens and <|eot_id|> instead. + """ + config_path = model_dir / "config.json" + config = json.loads(config_path.read_text(encoding="utf-8")) if config_path.is_file() else {} + tok_cfg: dict = {} + tokenizer_config = model_dir / "tokenizer_config.json" + if tokenizer_config.is_file(): + tok_cfg = json.loads(tokenizer_config.read_text(encoding="utf-8")) + decoder = tok_cfg.get("added_tokens_decoder") or {} + known = set() + by_id = {} + for key, value in decoder.items(): + try: + token = _token_text(value) + except (TypeError, ValueError): + continue + if token: + known.add(token) + try: + by_id[int(key)] = token + except (TypeError, ValueError): + continue + eos_text = _token_text(tok_cfg.get("eos_token")) + if eos_text: + known.add(eos_text) + if "<|eot_id|>" in known or "<|start_header_id|>" in known: + return [token for token in ("<|start_header_id|>", "<|end_header_id|>", "<|eot_id|>") if token in known] + stops: list[str] = [] + + def add(token: str | None) -> None: + if token and token not in stops and token != "<|eom_id|>": + stops.append(token) + + add(eos_text) + eos_ids = config.get("eos_token_id") + if isinstance(eos_ids, int): + eos_ids = [eos_ids] + for token_id in eos_ids or []: + try: + add(by_id.get(int(token_id))) + except (TypeError, ValueError): + continue + if str(config.get("model_type") or "").lower().startswith("gemma"): + if "" in known: + stops = [""] + [token for token in stops if token != ""] + return stops + + +def _hf_sampler(model_dir: Path) -> list[tuple[str, object]]: + path = model_dir / "generation_config.json" + if not path.is_file(): + return [] + config = json.loads(path.read_text(encoding="utf-8")) + chosen = [] + for key in ("temperature", "top_p", "top_k", "min_p", "repeat_penalty"): + value = config.get(key) + if isinstance(value, bool) or value is None: + continue + if isinstance(value, (int, float)): + chosen.append((key, value)) + return chosen + + +def _hf_chat_template(model_dir: Path) -> str: + """The chat template llama.cpp will store, when the checkpoint has one.""" + tok_cfg: dict = {} + tokenizer_config = model_dir / "tokenizer_config.json" + if tokenizer_config.is_file(): + tok_cfg = json.loads(tokenizer_config.read_text(encoding="utf-8")) + if "chat_template" in tok_cfg and tok_cfg.get("chat_template") is not None: + return flatten_chat_template(tok_cfg.get("chat_template")) + jinja = model_dir / "chat_template.jinja" + if jinja.is_file(): + return jinja.read_text(encoding="utf-8") + return "" + + +def _write_hf_modelfile( + model_dir: Path, + gguf_path: Path, + overwrite: bool, + mmprojs: list[Path] | None = None, +) -> Path: + """Write a Modelfile next to the GGUF.""" + modelfile = gguf_path.parent / "Modelfile" + if modelfile.exists() and not overwrite: + raise SystemExit(f"{modelfile} already exists. Pass --overwrite to replace it.") + projectors = _place_mmprojs(gguf_path, mmprojs, overwrite) + stops = _hf_stops(model_dir) + lines = [f"FROM ./{gguf_path.name}"] + for projector in projectors: + lines.append(f"FROM ./{projector.name}") + lines.append("") + lines.append("# The chat template is the one stored in the GGUF.") + template = _hf_chat_template(model_dir) + lines.extend(tool_modelfile_lines(template)) + if chat_template_has_tools(template): + log.info("Chat template supports tools.") + else: + log.info("Chat template has no tool round-trip. Tool requests stay rejected.") + for stop in stops: + lines.append(f"PARAMETER stop {_quote_modelfile_value(stop)}") + for key, value in _hf_sampler(model_dir): + rendered = str(value).lower() if isinstance(value, bool) else str(value) + lines.append(f"PARAMETER {key} {rendered}") + lines.append("# Context length comes from OLLAMA_CONTEXT_LENGTH.") + lines.append("") + modelfile.write_text("\n".join(lines), encoding="utf-8") + log.info("Wrote %s", modelfile) + return modelfile + + +def _mtp_depth(model) -> tuple[int, int]: + n_blocks = int(getattr(model, "block_count", 0) or 0) + hparams = getattr(model, "hparams", {}) or {} + n_nextn = int(hparams.get("num_nextn_predict_layers") or 0) + hidden = hparams.get("num_hidden_layers") + if n_nextn <= 0 and hidden is not None and n_blocks > int(hidden): + n_nextn = n_blocks - int(hidden) + return n_blocks, n_nextn + + +def _fold_unclaimed_scale(name: str, architecture: str, n_blocks: int, n_nextn: int) -> bool: + """True when this sidecar is a per-tensor NVFP4 scale the graph never multiplies.""" + if not name.endswith(".scale"): + return False + weight = name[: -len(".scale")] + ".weight" + if preserve_scale_tensor(architecture, weight): + return False + if mtp_layer_tensor(weight, n_blocks, n_nextn): + return True + return must_bake_nvfp4_scale(architecture, weight) + + +def _fold_mtp_scales(model) -> None: + """Bake NVFP4 scales the graph never multiplies, and do not write the sidecars.""" + writer = model.gguf_writer + architecture = str(getattr(writer, "arch", "") or "") + n_blocks, n_nextn = _mtp_depth(model) + original = writer.add_tensor + pending: dict[str, tuple] = {} + + def add_tensor(name, tensor, raw_shape=None, raw_dtype=None, tensor_endianess=None): + text = str(name) + if _fold_unclaimed_scale(text, architecture, n_blocks, n_nextn): + weight_name = text[: -len(".scale")] + ".weight" + held = pending.pop(weight_name, None) + if held is None: + return + scale = float(np.asarray(tensor).reshape(-1)[0]) + original(weight_name, bake_packed_nvfp4(held[0], scale), raw_dtype=held[1]) + return + scale_name = text[: -len(".weight")] + ".scale" if text.endswith(".weight") else "" + if getattr(raw_dtype, "name", "") == "NVFP4" and _fold_unclaimed_scale(scale_name, architecture, n_blocks, n_nextn): + pending[text] = (np.array(tensor, copy=True), raw_dtype) + return + original(name, tensor, raw_shape=raw_shape, raw_dtype=raw_dtype, tensor_endianess=tensor_endianess) + + def write_header_to_file(path=None): + for held_name, (raw, dtype) in pending.items(): + original(held_name, raw, raw_dtype=dtype) + pending.clear() + return original_header(path) + + writer.add_tensor = add_tensor + original_header = writer.write_header_to_file + writer.write_header_to_file = write_header_to_file + + +def _normalize_chat_templates(model) -> None: + writer = model.gguf_writer + original = writer.add_chat_template + + def add_chat_template(template, *args, **kwargs): + return original(normalize_chat_template(template), *args, **kwargs) + + writer.add_chat_template = add_chat_template + + +def _warn_when_header_is_written(model) -> None: + writer = model.gguf_writer + original = writer.write_header_to_file + + def write_header_to_file(path=None): + warn_missing_gguf_metadata(writer) + return original(path) + + writer.write_header_to_file = write_header_to_file + + +def _assign_output(args) -> None: + """Point args.output at the GGUF file inside the bundle directory.""" + model_dir = Path(args.model) + if args.output is None: + if args.no_quant and args.outtype == "auto": + directory = Path.cwd() / model_dir.name + gguf_name = f"{model_dir.name}-{{FTYPE}}.gguf" + elif args.no_quant: + label = args.outtype.upper() + directory = Path.cwd() / f"{model_dir.name}-{label}" + gguf_name = f"{directory.name}.gguf" + else: + directory = Path.cwd() / f"{model_dir.name}-NVFP4" + gguf_name = f"{directory.name}.gguf" + else: + output = Path(args.output) + if output.suffix == ".gguf": + gguf_path = output.resolve() + gguf_path.parent.mkdir(parents=True, exist_ok=True) + args.output = str(gguf_path) + 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_existing_output(args) -> None: + refuse_existing_bundle( + Path(args.output), + args.overwrite, + getattr(args, "mmproj", None), + skip_gguf=bool(args.modelfile_only), + ) + + +def export_float(args) -> None: + """Convert the Hugging Face model to GGUF without learned rounding. + + Packed 4-bit tensors are repacked as NVFP4. U8 is the packed weight and + F8_E4M3 is usually its block scale; a weight whose own dtype is F8_E4M3 + is an 8-bit float and is stored as float. The shape has to be 8 weight + bytes per scale before a U8 tensor is treated as 4-bit. ``--outtype nvfp4`` + asks for that repack. MOSTLY_NVFP4 is selected only after at least one + tensor is actually repacked. + """ + gguf, _model_base, model_type_enum, get_model_architecture, get_model_class = load_gguf_modules(args.llama_cpp) + model_dir = Path(args.model).resolve() + if not model_dir.is_dir(): + raise FileNotFoundError(f"model directory does not exist: {model_dir}") + passthrough = args.outtype == "nvfp4" + + logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO, format="%(message)s") + log.info("Loading %s", model_dir) + hparams = _model_base.load_hparams(model_dir, False) + layout = _checkpoint_tensor_layout(model_dir) + _log_tensor_layout(layout) + config_nvfp4 = _checkpoint_is_nvfp4(model_dir, hparams) + if passthrough and not config_nvfp4 and not layout.packed: + if layout.fp8: + log.info( + "No packed 4-bit tensors were found. %d FP8 weights will be stored as float.", + len(layout.fp8), + ) + else: + raise SystemExit( + f"{model_dir} has no packed 4-bit NVFP4 tensors. " + "Use --outtype f16, bf16, f32, or auto to write float weights, " + "or omit --outtype nvfp4 to fit NVFP4 from float weights." + ) + architecture = get_model_architecture(hparams, model_type_enum.TEXT) + log.info("Architecture: %s", architecture) + model_cls = get_model_class(architecture) + file_type = gguf.LlamaFileType.MOSTLY_BF16 if args.outtype == "nvfp4" else _float_file_type(gguf, args.outtype) + model = model_cls( + model_dir, + file_type, + Path(args.output).resolve(), + use_temp_file=not args.no_temp_file, + eager=args.eager, + model_name=args.model_name, + ) + quant_config = model.hparams.get("quantization_config") + if layout.fp8 and not layout.packed and not config_nvfp4 and not (isinstance(quant_config, dict) and quant_config.get("quant_method")): + merged = dict(quant_config) if isinstance(quant_config, dict) else {} + merged["quant_method"] = "modelopt" + model.hparams["quantization_config"] = merged + log.info("FP8 weights have no quant method in the config. Their scales will be applied and the weights stored as float.") + _install_tokenizer_fallback(model) + apply_hf_context_length(model.hparams, args.context_length) + _warn_when_header_is_written(model) + _normalize_chat_templates(model) + _fold_mtp_scales(model) + if layout.packed or config_nvfp4: + log.info("Repacking tensors that are packed 4-bit. Other weights are stored as float. Learned rounding is skipped.") + else: + stored = "bf16" if args.outtype == "nvfp4" else args.outtype + log.info("Writing float GGUF (%s). NVFP4 quantization is skipped.", stored) + model.write() + _write_hf_modelfile(model_dir, Path(model.fname_out), args.overwrite, args.mmproj) + log.info("Wrote %s", model.fname_out) + + +def export_nvfp4(args) -> None: + if args.modelfile_only: + logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO, format="%(message)s") + model_dir = Path(args.model).resolve() + if not model_dir.is_dir(): + raise FileNotFoundError(f"model directory does not exist: {model_dir}") + log.info("Skipping quantization and GGUF creation; writing Modelfile only") + _write_hf_modelfile(model_dir, Path(args.output), args.overwrite, args.mmproj) + return + _refuse_existing_output(args) + if args.no_quant or args.outtype == "nvfp4": + export_float(args) + return + logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO, format="%(message)s") + ensure_cuda_interpreter(args.device) + gguf, _model_base, model_type_enum, get_model_architecture, get_model_class = load_gguf_modules(args.llama_cpp) + model_dir = Path(args.model).resolve() + if not model_dir.is_dir(): + raise FileNotFoundError(f"model directory does not exist: {model_dir}") + + args.optimizer = resolve_optimizer(args.optimizer) + args.output = str(Path(args.output).resolve()) + log.info("Loading %s", model_dir) + hparams = _model_base.load_hparams(model_dir, False) + architecture = get_model_architecture(hparams, model_type_enum.TEXT) + log.info("Architecture: %s", architecture) + bind_exclude(args, architecture) + settings_path_parent = Path(args.checkpoint_dir) if args.checkpoint_dir else Path(args.output + ".nvfp4-work") + if args.fresh and settings_path_parent.exists(): + shutil.rmtree(settings_path_parent) + settings_path_parent.mkdir(parents=True, exist_ok=True) + require_quant_settings(settings_path_parent, args) + model_cls = get_model_class(architecture) + output = Path(args.output).resolve() + model = model_cls( + model_dir, + gguf.LlamaFileType.MOSTLY_BF16, + output, + use_temp_file=not args.no_temp_file, + eager=args.eager, + model_name=args.model_name, + ) + quantizer = TensorQuantizer(args, gguf, load_learned_converter(args.convert_to_quant)[1]) + _install_tokenizer_fallback(model) + apply_hf_context_length(model.hparams, args.context_length) + _warn_when_header_is_written(model) + _normalize_chat_templates(model) + writer = Nvfp4Writer(model, quantizer, args, _collect_bias_names(model)) + writer.install() + try: + log.info( + "llama.cpp prints BF16 for matrices before they are repacked. " + "Selected matrices are stored as NVFP4." + ) + model.write() + except KeyboardInterrupt: + log.warning("Interrupted. Checkpoints are in %s; rerun the same command to resume.", writer.ckpt_dir) + raise + finally: + writer.restore() + + if not args.keep_checkpoints and writer.quantized: + shutil.rmtree(writer.ckpt_dir, ignore_errors=True) + _write_hf_modelfile(model_dir, Path(model.fname_out), args.overwrite, args.mmproj) + log.info( + "Wrote %s (%d NVFP4 tensors, %d with scale folded into the blocks, %d biases adjusted, %d other tensors)", + model.fname_out, + writer.quantized, + writer.baked, + writer.corrected, + writer.passthrough, + ) + + +def self_test(args) -> None: + """Check that packed blocks dequantize to the same values llama.cpp will load.""" + logging.basicConfig(level=logging.INFO, format="%(message)s") + from nvfp4gguf import pack_nvfp4, ue4m3_bits + from nvfp4gguf.quant import check_scale_policy + check_scale_policy() + log.info("scale policy ok") + gguf, *_rest = load_gguf_modules(args.llama_cpp) + _converter_cls, unpack_uint4 = load_learned_converter(args.convert_to_quant) + + codes = np.zeros((1, 64), dtype=np.uint8) + codes[0, :16] = 7 # +6 + codes[0, 16:32] = 15 # -6 + scale_float = np.array([[1.0, 0.5, 256.0, 448.0]], dtype=np.float32) + bits = ue4m3_bits(scale_float) + raw = pack_nvfp4(codes, bits) + decoded = gguf.quants.dequantize(raw, gguf.GGMLQuantizationType.NVFP4) + expected = np.zeros((1, 64), dtype=np.float32) + expected[0, :16] = 6.0 * scale_float[0, 0] + expected[0, 16:32] = -6.0 * scale_float[0, 1] + if not np.allclose(decoded, expected, rtol=1e-5, atol=1e-5): + raise AssertionError(f"manual NVFP4 roundtrip failed\n{decoded}\n{expected}") + log.info("manual block roundtrip ok") + + def check(quantizer, label: str, shapes: tuple[tuple[int, int], ...]) -> None: + generator = np.random.default_rng(0) + for shape in shapes: + weight = generator.normal(size=shape).astype(np.float32) * 0.05 + codes, block_scale, per_tensor, dequant = quantizer.quantize_matrix(weight) + runtime = quantizer.runtime_dequant(pack_nvfp4(codes, ue4m3_bits(block_scale)), per_tensor) + delta = float(np.max(np.abs(runtime - dequant))) + if delta > 1e-4: + raise AssertionError(f"{label} {shape} ggml dequant disagrees with the quantizer by {delta}") + log.info("%s %s roundtrip ok (max abs %.3e)", label, shape, delta) + baked_codes, baked_bits = quantizer.bake_global_scale(codes, block_scale, per_tensor) + baked = quantizer.runtime_dequant(pack_nvfp4(baked_codes, baked_bits), None) + rel = float(np.max(np.abs(baked - dequant))) / (float(np.max(np.abs(dequant))) + 1e-8) + if rel > 0.15: + raise AssertionError(f"{label} {shape} baked scale moved the reconstruction by {rel}") + log.info("%s %s baked-scale relative error %.3e", label, shape, rel) + + def configured(**overrides): + values = vars(args).copy() + values.update(overrides) + return argparse.Namespace(**values) + + check(TensorQuantizer(configured(simple=True, num_iter=1, device="cpu", optimizer="adamw"), gguf, unpack_uint4), + "simple", ((64, 128), (40, 64))) + check(TensorQuantizer(configured( + simple=False, num_iter=4, device="cpu", optimizer="original", scale_optimization="fixed", + ), gguf, unpack_uint4), "learned", ((64, 128),)) + log.info("self-test passed") + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Quantize a Hugging Face model to NVFP4 GGUF with convert_to_quant's learned E2M1 rounding.", + ) + parser.add_argument("model", nargs="?", help="Hugging Face model directory (config.json + safetensors)") + 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 the checkpoint value, or sets one when the checkpoint has no single context length.") + parser.add_argument("--no-quant", action="store_true", + help="Skip learned rounding. U8 tensors are repacked as NVFP4 only when their shape is packed 4-bit. FP8 weights are stored as float.") + parser.add_argument("--outtype", default="auto", type=_outtype, choices=("auto", "f32", "f16", "bf16", "nvfp4"), + help="Float type for tensors that are not packed 4-bit. nvfp4 repacks a checkpoint that already has packed 4-bit weights. auto matches the source float weights (default: auto)") + parser.add_argument("--llama-cpp", type=Path, default=DEFAULT_LLAMA_CPP, + help="llama.cpp checkout used for architecture conversion and the GGUF 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 BF16") + 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="Load every tensor up front (uses more RAM)") + 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.model: + parser.error("model directory is required") + _assign_output(args) + _refuse_existing_output(args) + 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: + self_test(args) + return + export_nvfp4(args) + + +if __name__ == "__main__": + main() diff --git a/hf_to_nvfp4_safetensors.py b/hf_to_nvfp4_safetensors.py new file mode 100644 index 0000000..3e72042 --- /dev/null +++ b/hf_to_nvfp4_safetensors.py @@ -0,0 +1,335 @@ +#!/usr/bin/env python3 +"""Quantize a Hugging Face directory to one ComfyUI NVFP4 safetensors file. + +convert_to_quant reads one safetensors file and writes one file with packed +NVFP4 weights, ``weight_scale`` / ``weight_scale_2``, ``.comfy_quant`` +tensors, and a ``_quantization_metadata`` header. A checkpoint is usually +sharded. This script runs that same conversion on every shard, then merges +the results into a single ``.safetensors`` file. + +A bias is corrected only when it lives in the same shard as its weight, +which is how convert_to_quant sees it. Norms, embeddings, ``lm_head``, and +the other names on convert_to_quant's exclusion list stay in their original +dtype. +""" + +from __future__ import annotations + +import argparse +import json +import shutil +import struct +from pathlib import Path + +from nvfp4gguf import ( + CTQ_NUM_ITER, + DEFAULT_CONVERT_TO_QUANT, + DEFAULT_EARLY_STOP_STALL, + DEFAULT_SCALE_OPTIMIZATION, + DEFAULT_SCALE_REFINEMENT_ROUNDS, + load_learned_converter, + quant_profile, + resolve_optimizer, +) + +INDEX_NAME = "model.safetensors.index.json" +# safetensors aligns each tensor to its dtype width, not to 8. +_DTYPE_ALIGN = { + "BOOL": 1, "U8": 1, "I8": 1, + "F8_E4M3": 1, "F8_E5M2": 1, "F8_E8M0": 1, + "F16": 2, "BF16": 2, "I16": 2, "U16": 2, + "F32": 4, "I32": 4, "U32": 4, + "F64": 8, "I64": 8, "U64": 8, +} + + +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(): + payload = json.loads(index_path.read_text(encoding="utf-8")) + weight_map = payload.get("weight_map") + if not isinstance(weight_map, dict) or not weight_map: + raise SystemExit(f"{index_path} has no weight_map") + names = sorted(set(weight_map.values())) + missing = [name for name in names if not (model_dir / name).is_file()] + if missing: + raise SystemExit(f"Index entries are missing from {model_dir}: {', '.join(missing)}") + return names + + 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 in {model_dir}") + return shards + + +def output_file(args, source: Path) -> Path: + if not args.output: + return (Path.cwd() / f"{source.name}-NVFP4.safetensors").resolve() + output = Path(args.output) + if output.exists() and output.is_dir(): + return (output / f"{source.name}-NVFP4.safetensors").resolve() + if output.suffix != ".safetensors": + output = output.with_name(output.name + ".safetensors") + return output.resolve() + + +def read_header(path: Path) -> tuple[dict, int]: + with path.open("rb") as handle: + raw_size = handle.read(8) + if len(raw_size) != 8: + raise SystemExit(f"{path} is not a safetensors file") + header_size = struct.unpack(" None: + """Concatenate quantized shards into one safetensors file. + + Tensor bytes are copied, not loaded. ``_quantization_metadata`` layers + from each shard are combined so ComfyUI still sees every quantized weight. + """ + metadata: dict[str, str] = {} + layers: dict = {} + pieces: list[tuple[str, dict, Path, int, int]] = [] + owners: dict[str, str] = {} + for path in paths: + header, header_size = read_header(path) + file_meta = dict(header.pop("__metadata__", {}) or {}) + raw_quant = file_meta.pop("_quantization_metadata", None) + if raw_quant: + parsed = json.loads(raw_quant) + file_layers = parsed.get("layers", {}) + overlap = sorted(set(file_layers) & set(layers)) + if overlap: + raise SystemExit(f"Quant metadata for {overlap[0]!r} appears in more than one shard") + layers.update(file_layers) + for key, value in file_meta.items(): + metadata.setdefault(key, value) + data_base = 8 + header_size + for key, entry in header.items(): + if key in owners: + raise SystemExit(f"Tensor {key!r} appears in both {owners[key]} and {path.name}") + owners[key] = path.name + start, end = entry["data_offsets"] + pieces.append((key, entry, path, data_base + int(start), int(end) - int(start))) + + if layers: + metadata["_quantization_metadata"] = json.dumps( + {"format_version": "1.0", "layers": layers}, + separators=(",", ":"), + ) + + # safetensors rejects gaps. Grouping by descending dtype width keeps every + # tensor aligned without padding, which is how save_file lays a file out. + ordered = sorted( + enumerate(pieces), + key=lambda item: (-_DTYPE_ALIGN.get(item[1][1].get("dtype", ""), 8), item[0]), + ) + cursor = 0 + new_header: dict = {} + if metadata: + new_header["__metadata__"] = metadata + layout: list[tuple[Path, int, int]] = [] + for _index, (key, entry, path, abs_start, length) in ordered: + align = _DTYPE_ALIGN.get(entry.get("dtype", ""), 8) + if cursor % align != 0: + raise SystemExit(f"Cannot align {key} ({entry.get('dtype')}) at offset {cursor}") + info = {name: value for name, value in entry.items() if name != "data_offsets"} + info["data_offsets"] = [cursor, cursor + length] + new_header[key] = info + layout.append((path, abs_start, length)) + cursor += length + + header_bytes = json.dumps(new_header, separators=(",", ":")).encode("utf-8") + header_bytes += b" " * ((8 - (len(header_bytes) % 8)) % 8) + dest.parent.mkdir(parents=True, exist_ok=True) + temporary = dest.with_name(dest.name + ".tmp") + with temporary.open("wb") as out: + out.write(struct.pack(" None: + if destination.exists(): + destination.unlink() + convert_to_nvfp4( + str(source), + str(destination), + exclude_layers=args.exclude_layers, + output_dtype=args.output_dtype, + simple=args.simple, + num_iter=args.num_iter, + heur=args.heur, + calib_samples=args.calib_samples, + seed=args.seed, + optimizer=optimizer, + lr=args.lr, + lr_schedule=args.lr_schedule, + top_p=args.top_p, + min_k=args.min_k, + max_k=args.max_k, + full_matrix=args.full_matrix, + early_stop_loss=args.early_stop_loss, + early_stop_stall=args.early_stop_stall, + scale_refinement_rounds=args.scale_refinement_rounds, + scale_optimization=args.scale_optimization, + low_memory=args.low_memory, + use_speed=args.use_speed, + verbose_output=args.verbose, + ) + if not destination.is_file() or destination.stat().st_size == 0: + raise SystemExit(f"convert_to_quant did not write {destination}") + + +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_layers: + args.exclude_layers = profile.skip + print(f"{profile.arch} profile: leaving {profile.label} unquantized.") + else: + print(f"{profile.arch} profile: --exclude-layers replaces the default skip of {profile.label}.") + return + + +def convert_directory(args) -> None: + source = Path(args.model).resolve() + _apply_directory_profile(args, source) + if not source.is_dir(): + raise SystemExit(f"Not a directory: {source}") + output = output_file(args, source) + if output.is_relative_to(source): + raise SystemExit("Write the safetensors file outside the input directory.") + if output.exists() and not args.overwrite: + raise SystemExit(f"{output} already exists. Pass --overwrite to replace it.") + shards = discover_shards(source) + + load_learned_converter(args.convert_to_quant) + from convert_to_quant.formats.nvfp4_conversion import convert_to_nvfp4 + from convert_to_quant.utils.logging import setup_logging + + # Without this, convert_to_quant drops the "(13/256) Processing tensor" lines + # and only the inner 4000-step bar is visible. + setup_logging("VERBOSE" if args.verbose else "NORMAL") + + optimizer = resolve_optimizer(args.optimizer) + print(f"Quantizing {len(shards)} shard(s) from {source}") + print(f"Output file: {output}") + if len(shards) == 1: + quantize_shard(convert_to_nvfp4, args, optimizer, source / shards[0], output) + print(f"Wrote {output}") + return + + work = output.with_name(output.name + ".shards") + if work.exists(): + shutil.rmtree(work) + work.mkdir(parents=True) + try: + written: list[Path] = [] + for index, name in enumerate(shards, start=1): + destination = work / name + print(f"\n[{index}/{len(shards)}] {name}") + quantize_shard(convert_to_nvfp4, args, optimizer, source / name, destination) + written.append(destination) + print(f"\nMerging {len(written)} shards into {output.name}") + merge_safetensors(written, output) + finally: + shutil.rmtree(work, ignore_errors=True) + print(f"Wrote {output}") + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Quantize a Hugging Face directory to one ComfyUI NVFP4 safetensors file.", + ) + parser.add_argument("model", nargs="?", help="Hugging Face model directory (config.json + safetensors)") + parser.add_argument("-o", "--output", help="Output .safetensors file. Default: ./-NVFP4.safetensors") + parser.add_argument("--overwrite", action="store_true", help="Replace an existing output file") + parser.add_argument("--convert-to-quant", type=Path, default=None, + help="convert_to_quant checkout, if it is not installed") + parser.add_argument("--simple", action="store_true", help="Round onto the E2M1 grid without learned rounding") + parser.add_argument("--optimizer", default="auto", choices=("auto", "prodigy", "adamw", "radam", "original"), + help="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 progress. At the learning-rate floor, progress means a 1% loss drop (default: 2000)", + ) + parser.add_argument("--calib-samples", type=int, default=3072) + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--use-speed", action="store_true", help="Enable Prodigy's use_speed") + parser.add_argument("--low-memory", action="store_true", help="Stream each shard instead of loading it all at once") + parser.add_argument("--heur", action="store_true", help="Skip layers convert_to_quant's size heuristic rejects") + parser.add_argument("--exclude-layers", default=None, help="Regex of tensor names to leave unquantized") + parser.add_argument("--output-dtype", default="bfloat16", choices=("bfloat16", "float16"), + help="Dtype recorded for quantized layers and used for unquantized 2D weights") + parser.add_argument("--verbose", action="store_true") + args = parser.parse_args(argv) + 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: + convert_directory(parse_args(argv)) + + +if __name__ == "__main__": + main() diff --git a/llama.cpp b/llama.cpp new file mode 160000 index 0000000..63b4b30 --- /dev/null +++ b/llama.cpp @@ -0,0 +1 @@ +Subproject commit 63b4b303bd87456adc2f09d56c7e1f74e9e26673 diff --git a/nvfp4gguf/__init__.py b/nvfp4gguf/__init__.py new file mode 100644 index 0000000..a6cae77 --- /dev/null +++ b/nvfp4gguf/__init__.py @@ -0,0 +1,75 @@ +"""Shared NVFP4 quantization used by the GGUF scripts in this project.""" + +from .quant 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, + ShapeView, + TensorQuantizer, + add_quant_arguments, + bind_exclude, + ctq_skips_key, + eligible_weight, + ensure_cuda_interpreter, + import_gguf, + load_gguf_modules, + load_learned_converter, + looks_like_spark, + check_scale_policy, + must_bake_nvfp4_scale, + mtp_layer_tensor, + bake_packed_nvfp4, + pack_nvfp4, + preserve_scale_tensor, + quant_profile, + quant_settings, + quantize_and_pack, + report_reconstruction, + require_quant_settings, + resolve_optimizer, + scale_bake_notice, + ue4m3_bits, +) + +__all__ = [ + "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", + "ShapeView", + "TensorQuantizer", + "add_quant_arguments", + "bind_exclude", + "ctq_skips_key", + "eligible_weight", + "ensure_cuda_interpreter", + "import_gguf", + "load_gguf_modules", + "load_learned_converter", + "looks_like_spark", + "check_scale_policy", + "must_bake_nvfp4_scale", + "mtp_layer_tensor", + "bake_packed_nvfp4", + "pack_nvfp4", + "preserve_scale_tensor", + "quant_profile", + "quant_settings", + "quantize_and_pack", + "report_reconstruction", + "require_quant_settings", + "resolve_optimizer", + "scale_bake_notice", + "ue4m3_bits", +] diff --git a/nvfp4gguf/__pycache__/__init__.cpython-313.pyc b/nvfp4gguf/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0eaec0ebac6c3d488121cd61bf8bae3fd40b6df8 GIT binary patch literal 1301 zcma)*y>sG56u>3G#>Qa482oL^=2CcW<|xjSD#;APfrkwSjD%+{&5n@Pu!oT3C&?Gw zrOAJgJ}L7jW!n^)beXugHv1$q`KY4eH}AcD`@Zz{xlqU{^85a$-#(~WMfnw#)t7n^ zo`00W4~njUqN{r9Yn7xRMbeNa8OV?|SR?DOPO^|C8?Zq(VUy$_N48*#Q;qU)AVKLsin_MV7Au3>kO~7hsbn8{$z&SbG0at_Q_HU zXH2`k9(T0B0`7-_#$9RSf#!qvfmz|3-&NV`<%P(%j9c0t-_Wkn9*O;T?M`#tGiYnj zziW>SY7FS@xY;*Ed3Dn3HE)`eN7rqG z_6EBCcZPplO56`u?s}`)YtzBd=-za{X&T)@U!48>Y}D>_`|X={-=L$xxc^QUg}6#8 zYu3?!Q%=Zdcbt6`MT5D42e-+T7>J5#+cX{;HDMYpm>UKnKjE%T*|(Mx*-R9y@PP(% z?gwC$=`^|*Wydq^SfmTj zjvN+$!K4&!WO_+-vYsRcO4J=cp^*x7MOHo}!K}aX2 zbaEf>{j20ulop{+9dpTmhHQMXt(f0|t*WR~-K3Rv{UR+>m?j zFtMGe0SiO!P6JVnb^gStDMy9jL={-vE-=|j5JF_(5{eqIhltDh{5OJ3>p3#5=ZAbD zBY`L<6(l`*A-9;wMeJ(vmQHvW^e;aXSxRlsqExKK(I74N7BYx6#5zJgXazP9n+SaA zu!YDY3W#k)5m7>v5j%(qVi!?G)DU}!eZ&Fc5OIV!Mw}o{5oZVuagMk^)Df>FuyWIJ zDE(P^mZ~B?Z_K;}Ydo=g&Kfp*Y$QJscO{urVnDq?`8d+Xgxq8!HB3$9572e_;c5Y-4+9WRnn&Mn(-N7827fY@;MD za=G2bo1HVcOfIA@Rbgkcv(DAshRkm5u&#$A`+M$ZZ3DTH&^r5Ib&)j zliEM-_q_cf34|xvrg-)C`#$~l`|*4~Pv0*qE9G$g;J^Oz*}DB4_e;7lE~{4I@AvZ@ z_Zla1GAHt)>DU^vGUumsrN`D#UiNoYgAD z-J+M(szkq7!D>FSQmhK_;=`hk?w_^D)#4tpn)TI)9byfu)ry^BEvwaub#lE}FE@w{ za--PT$jRHpfVhqIZ5O-5?X1=$Hp$K6Ua^_owTSz~7FH9QIq{KZZsVW0UlgeSSzdl< z-0DA&I(%U|IW?UWu1?1zF-e$^VpEc=2+BlQmLkH{86i0#38zj4`-Ra&{5ra#iMXJQ z%F(H$68JT3ou60T;dnfe3@6d2oX5TGVmLXGs)%17n>sK)K0OxDM)y0^y2c;Pi^M)qRF8vHBUQOt6myS;ag+&+?ScsT3cv*PYua(!sa?Cnk4dhes3E53&( z&_jDjz2o>5`6r&>xUie!Mrgw95-rAFO8kgUt{h;ER@%w)vIVfIuFg(ZBayBSTY)+m~I)uBDBZJA6YuGY%Hk*F+% zj1AC0WLkeC{*`tVv)rn)?8RHFULStvD_?qUt-LoH*Ibd;eVH}0PwcyysxFgmUZjAR^v}5o))zYXEjVsA8 zs7ke6O(bHfEf!Uhsx1;7P0Buuqm~TKOi6vRoRHPhOX1kGR>z6huhX4e4IqWqEC|{e z)F`9jSXkb81kC_xCD^A?%yMha%K4pdRAHEy>o1vc`SUR`T+LLMR!O`5a zSnbtomDTgXR~zRX_Z*z7oQ{eP&0RS=@a1|5S62J|RNCEopX2S-Kf@DN{C=fWV)(yy z_c*zaoE1Hd){h!^lm*A=1O(#@zeNSdh*G-IP^MnHi zMjx;!76BZ>jSUugJbe*QgKrEH(1t5$Qb*i6LNj1j&^qEW>Kjpjy?+mm(*WS`1|SpW z=i73A`!>%nH^+fNj?H!40-ko}tTZFUV&TbfV03B<{A(;4kAx`--{CPc^3C`^RG_2OV;R@F`HkdAyTIxa%g9IKB2@#`I z9omgTM7&9@CxSt>00sJdBP|R}w*V{a2U3Hg#@93(pcDcUu?)W>gY<+jy z*Zqm7`wKf)(Q@Cx)t~0)+^d!KuWx&0+rmh;(m&@|^ENE(db@4K+n({Z|IAbUKYgup zCs(~y-+N)Tu5o$$zNKC7?a6LGnyx##TygZiovYsQMG1{_ublJfoJ8jfmGwC2PXp$myE;&3@0Giah3n)OSx0vYw$2wc$8 z#szJRvGc=wN#ungD29&*tzTm#ThNv_lCA9zz1hAI-tS1=AC!{Qa-69eI@`|SrG|+& z$WRh*|r#6cI{pD)~8+dtIn#7 zvyqAIimbC`wQ2W!NygP^!uJ2Yd)YMvQQ@hB_+GbiB_3^%$KT(x?CM{2*BVkC49FIq zP9{|A*#1XQ?0Xc_k4ih!vkIgI5eg&KR#djqBM+jFt~Tx+o>X9aEOA4U-{IvZH0CQP z&ct*w34tm0n+D4c7!`ox9N4#;;=&P${9rxFFG`cNdA+MSM($Xz%P|;Y*cn{)$W66 z^E=P0h1wIn0#+S=|2Qf;z^iB>5NS{#5|zc2b7LXbx!3FhH&Ck!FYYnx7%xADL9o$I z5b0ESRDwQY3G7vEqp(~Gw2_j-<5FC@IVB%ecYk*}k(4wIc`O=}LJXe+hhquIHsxr* zgd~Zp;C0-uaWd)pLqG1l+xyn`H@3gkywLpP-rqBk^DnkH^D5t(vbMIiruqlS5}`2# zHYPIx!qBY1Mv^d=(9Yi7A>5dVj!vL8_rNH#o`i`+ETZzIScf$npOHydC!2u8YCitd z6kUUS3gDzyDOXXDe(Ro-UrK#n%+w!xuQywNXr<;*rsmN5;)lKeX#}V?-}~~BwW{su zCMjDrmiCOTy1nxUUOv3Af3fY~cCY&C7Y@8~cyVXO*PiyYL&(h!-A*l>%y?VUt`>mK zp4Bi^7a@2f$C2`h1Lp7l6as`%32QDa;+8HB+7BTDyNm;l+k*T?LDog6@i)+R>?6c* z*cCJl_P9N0FDBZI5lx&2b>&oOkq_F-x&5XlCgFb2RGW>rkj+x8l~EjQf-Ro0F}i9g zc%Oyd2bwGT7WCMH7L@k=PJ>o4BimqEZYD;p_z1>R5aQNBGV?ZE!-K`2>jcHaS8!53sM+}IwdEbl}3}0hh(HOAv6W!o|HePB2I*1s6{nt3eHBZ zgb2(pdSWJ!+CDi+13supe}rekn7NT8dkC>#&aD^t4+ zVi`@uVi55Vt~7-(EN%!_ZA`pbVVoR#bFsLP#}q05@QI_d*v$J$+V>)tVl$4 zKwQD-hBKmsS<|!})u|+=kKPdD{utNPNOKG;4aCKmi}1>Oi`-MjDhzkBwb zv+p0sb{qpVMqJpnP@jJ2;L_MnuDy3{=}P*}pzddGD0pE?;omee~V#ce*peqv^WGK65?(g^%+I_o_LMZ}z-K_a^;f zbwrzt?*AL3dq&?nUO|cE4doED{C6D3l-IsyqM-aSFa%u&?dCW^_#whhK${#ugUp8p zy*!}ziPPX9j-bQf2Vt1BpcT`o&W)GjEuaU;)(6kg9IpnD7UM2L+y=CzU5xfE`P?RF zm=||Q4)+=RC2%9;ePGuhnc*8Q;wd@Uud$+!aug0%0bwz_;2yy43D76vju!6xYo?39 zmO67XF*TFN0LTjn0){+-XNGYC!?M#nV;xYGr72RFL7(6sjOdw5E71SdULhPKwR}ck z)UO1Di(y3(Iy*^uX4*XsDoY@`Q81KbAk~D|6Dzn54+Yfq93ukUPQ_o!h)i{8!>LYL zQWCN2lIpx5GGB#!9_wSY57M2OPDW#@9n59s5g8UWCwHLmJLL;hhtUo%hdB||p)nzO zh}vyL*x&)=nj=$6qkWzD%h+^78VgY5F#50KUwIz|$i89QZ2wyQp1V(eR)6fa2Tlx& zwWMLySND4NE8VXiS@E4(9Qw;={`{FAetYTqPj0<;EBn}~%+6DDy}yM5Te0e?x-)Ql zV4-c<(*ko7QkUqS9Ctrz0HrV57Otmv2GdQKvQ?MUp37^l@;jbC^~_&dcD0)N@ZP1i zW!Gb?=qkHiHXr%Swe5=ruHiU;uaWaqkqf1UtEhwbgeF!0r@io>lr(7mlkY!%(r5YD z=Q-JG|M(#uWj}P066IQSin$<14eI5wP?H02&<7y-ttK@*4B9B_bu}p6fD98Anf_Ns zMaCO20wg4#PemA$IQ)i7w2)MTCSiM!D*3hEXxsS4oWEw+G@QZF6rH180!MQ^=&hSL znxhy8GIj-=rwD&CdKpJ^KKOmcDwziE}v2p$6-x{~& zyBJNrVU0V%H%^;;!=4%zVOB$yk0oFMhhhGLBgoTnXuj~~f;F&kLPt&k?$E4ljp*{| z9M-hKGf3b|0r@hHTrDv*=l=74hYU`wk-2<^YFI2MKSc#;!+w`W;a5;sb#+6LNkvvm zn4g>}2t?;{h>Gu^P+TZc#4Jqj#f47{_nQh$DQt?kL6zNOk(?@Z%=~ zmX8CTlSk|yALdbx=Ii|?_4fmo>HWkLHeA6xr&iFyTuu4-W`SOAIjG|wG*Gyw$!zvp zPKd&HW(9e;B^x zJllnPkiBB);&7mu`-ia!7ul6_?iUFakk(<^47o6YXnEpal68(ipY41<3%m?L=8=09 zdU8^h>#1f(EjlG*uBa!cVXDG4B*o=JXvjA_OG0tDdMBrnGqQ}XMd~J!BV*I^;tQ+Z zYR#kXTX8mJoK0(9-+bTeXJ0w{>Oj`pnub(j{{FVN?F&sS&i0J6eGPWy!Ix7DJ-2VA zp?$i&o420?F3y<0UuEOCZ#%X!D^2DG69~cq4{f|e=C;4U@8u>PfXGK+9W=+H4L@p} zhcas!a`?DQRt1KuVgbv?=D~m^v|v}zY8;#~2gY)1Fc0fR%K$c&SZCZ8w+3w{1RM)N zOEF}CQpbb!+V-2H3fzS~IrBB>IKYc`#BW_eo6l62H_jl80LT{OI}BKX+psP_tt)61 zoy^FEFCKLls{=`(UYbg+v2r+nN{rFWm6331CwQzKY8G3&9s89-PFev)(Op6N)+>l? zy#lxKPAJHx!8-zY?HY239z!iQAkEorJ!+W&yXC0m|BO++xlzT6AqVrUZ#{>~9Q25( z@aM0L;YJk{Asu@S_))^P;`mg}o~g;lrw5E(`;Eq~V`JCnkDW7w!eF{1@at*UAhDbc zm)IaS4ieMZaOJ{e#yX*T_{Mz{#8NDA>ljL-=HC`}fLmS;BY04tT?mtq7?E%?Ea=WP zDDlWyfPcYs;KNUt)a(|74o+&(eBG1*M}Y3PUglzXUr+ zim`A*I9UoG0ODq_RU*@)^fG1xU4>Hwqs7OOUy-;$t`n$3uz*g$?|4m%Z?hPH*oYJJx-$~M;Iqx#H&kI znX(hbm&~o}x$?=yXa3%{T-N&bk)^7oq3n()vSr=xCqFGai4M)l`%+swpE?s3C=LV{ zR^gsLVE+t5md;1hN;JQ4s?Nlp03le&K(JK(79Gl#J%QrKLw0Y$@icyuN4oiF zNbIwtk(rTe#jPOZ9fy1aE%5Siihx4f0gg;fC>lq)Pj0KnqpW0=%YV@2xKn~2GsxjdLteM>^sSdMQxQ;l$ z)oG+I%xoG%1NNu3nb6uGQbGx_rce~2JNOkl6Jwo5o|48aHjS14j6dZGTqQn$m^JcT z&Zo-Qoz4|@r^@ns{i(`;$s~+iqAIh+h|TX#Rc!+Er+h4n3r3X@*4(KCQkHJPU#2>* zT@MkrL`+N`onZMw%zXlfX)LCKsfFbvOHz{iaIj4M)9`>|_ROE6I*AfNB&zLM#3hkf z;$Ti4Sxx~2rk55<)pU1{PDT`2RMc{V1XxsG(MX+`4HSD^$2H}?acfGKUjp#I!@u(1 zAq>8p8|gj>%yP9uCA4MmOWQ87oW*` zuB?GmVpnxiZ3V;T1`v;Ww#>-d_{e>7WjY{h*%<34Wo_fa_1*>?9t))_Dx z&n>$K)|}hlymI%ErM9KorRURKz1gO|tg}DkJg-A7G%Y3<$Lz-J4b>~5+A3L4y zdNSK|ChI(#af-&E4SVkP-hDK^?_|2JciGkVD`z=*r`)&Q>1zMo1MePs=g`vS?4Bc^ zx{hMuF7NMuvyZDj#sB^{9&CU@YQx`EpE_pysLp}%zwFs{+GhK>#({F0n9+03wIBX} z|M|@xgy|`TDT(?~YDglF>3{#57ZKEpDfqoAAuX>+Uw&7+um{wdh>f&UaJKylZrRxS zP>M7~1GG<{#cT6B=p7gbgwOh4c%f87+g!Q1<++`mf)rCEVdo3eWMzG`@$#N75CG)T z1xx1d{{mD{kWuo0+%%TJm$PQf#HI~qD2>f7Lz){pb@(S72P=tZaoqm^L1W430YS^w zIdPO>66A}yx$Xx%L!$1!VlJ1l*9G8}`J z8-mFnDo|9Iqd;@6L$TnPhsJMZ{u>lAl$yeXb48>R(iHHI4VG;4zykM=*W%+-A=Op#!7FpQ3_n zbx;Vi|DX-i2~vWAcIhb?+o-)*P%HTrr;dYof%I^|>8F$|mf1p)@7-M4m++FQPW2_->7x3gSm# zwy@5?p_!S(FcPUz$VEkzNJxyqh(dmo6bT?uTOaD}>$@oS^_>k3?hA=SgFR1$P7L;( z?4z&`3&9ix>3?##uXjVw(1i=TW%owevAOOSMQ^?kH5!H?JV2-#G zh0w)vBt)CRwsS;NbgYT3=O^0o**|7^OJmrgeb97G<*mmQJMhi&$hSCo20AmCIeB6c zEMduqS{mY*KmpVUxepL^)twOv$OH@>tQk#+#=>H_8jug;1z*{xlVhEa%YTMCLut7e zHGhfb^R-`{Ng~C;5rJfgNQ~DPxwuNi}11euN-qyrA0`zQP> z$S48He6G2MD>e3Av*K*YAaBoGedqbx&o7)>d@AehO1ruW9LMOL^AxiCZB29bV(h-= zb53ANO$Dyk1&D^1~t>8%44><9Kjb5&84zrYuQxN4$u z$U>P~x~BvDW`n2W9YTI`J*ke#uzU^q#Y{NRpat#@_2B|dCWK^oHXo&k>uDD@n6OP(`H~`&K9JR_vwVIc>ug@^UYf}6ILcal3lL&E(z}m*IGo)*fF^wYvRvNx z>E7OKd0*Dum$vn7mQA!3_~-BMMWxWxFnFnfe_`MzeWuriBy-W)1qIe`OZ}=>8ikjI zoT)hG9J=V9vI1tOp$;V?cT${+6pKlCYVFqp@lDkM+ssX($nV#AW2WloFO2Y2{-` z`IZBvsfv?57kf@TdG5)fr?jV2kM0nLux5t+3UbLQMI3I$eC}@e3-zQr^4bJa$MD<) zd^d%V^!Y_xSEXcgi#h~dy}~ye$*{IiKhJO?he3jdLGY-7fXq;MC>BMEx%?M6RzIN+ zge{mj61QC~3pZDsyED$+pL?2DJ-zqr){0ViDN0K3xwx|PeA<0JZPT;Eh#MIfiA9Df zccDXU6}}UBGjoG5Yr}O=zc(?8!X1UVBJ>t^1u1jGf-nXqPZz#JBTo{$hRGSNj~}-q z)W!|EjK172Wrubl7mC7Dryqj$LYWptbP_!8-0FGb?a%{u=!RSIF-*-vp!|_yZ^>82 z9mP!8To5c5ip;$~7e31Mn)`?_9e0FpUtu~c*)vIu`@uAWVwq?D!Jx=bIO2;p3oipC7sM;BlbNp#^` zj;JyL<%~I2=`$(hZRh2$u!I&RFS-Q!rLRRPw!-pRgdTx?`_wnoHz>rX@x@f~rk)9F zeo}Jb+_|2Co{;ge)VaR%r-n`=*b~8Fv3!`6vw!fyfDq_n3SjCHViX%W3SLZXh7?Af z#TGD^rjv#IXL@QX0i{o4S%K7v!tUZsC!p~)opb7(N8_ll;b>YX3u0_=a6|LazHBMJ zMy9&TuV9@>J;P_R&{?4zr(Bn1bPsj0hpi9)hOPnQv^HN!LV7`2s}Nr?DhnXyW>B-cdT#Blp*+_hGF zixNc;G4Hbj`L6)^IPuq76!2G9bCr$XJSV8Kd0}ShP`2{O?3q<>?flH^w_drGuHBU` zL(=~4*}k9Gw9J*Py2|c&ZhPi$EX=%h>y2CK=0Lip>oeEhFRWax@VTwzPtLt~ZoX>S zR=uj#)0KV8wtoF)xpL>?WVZ6avhCoSvw7i#6=!G0*=ZWDdp~vU`$b8uc^_PMT`D$= zK7QGXtv+L`U#Nbo;f;pHgV_fE(kXoFlCd3LvpN6d^oytGo?d8Jw(VGTSEp^&ni6i) zj+1$%95VY#j3uq$LniV`d{s)HKs_&<2WHFgk#X`83g}R|x=s7EiMgt%HPysYTt;AO z=?`|0HKo_DAfaq0jlP+U@hCG%vz^#mXl|HJf00&)mV8F?kmZ?NxGD=J>5;(JugQSrxAyhg6Yu&-~4eKr^U$I{8 z<$ddI6@1mbU0i}s^1tKC_+frsu<^Us%Nal$f9&2c&)H{q#B2Bl*w5Rq@oBDpb=zf> zzj0LXmFv4)eD!*51z&#e;nI3bnyX%Kbn*TCdaZ}Qz+>cxcHS%5&eza4-d6m6j$b=| bk+E3ba?eRM7gycPU@*R;^tW!bu}%5^Z&o0@ literal 0 HcmV?d00001 diff --git a/nvfp4gguf/__pycache__/quant.cpython-313.pyc b/nvfp4gguf/__pycache__/quant.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e65e60f1b41048d08d4b7c2ae22d266e4a6e0aac GIT binary patch literal 59016 zcmcG%349#ac_&zXH~PkX5-1!zKmrZo;04|y4jv##p#X{!C^efv1B3`Rpy~z>$g!z7 ziAYHaK}TLfu_l6SO#~&Lh)SFllXzmMBj&JQm*`@@Zqbcj z>oLz!ujoye=@b1dE+7WZ*!ybK=IFmQoM78#Qr^Wp!7kE`oUm@2IsI)7!U>_0#Wo5jg(~I?3Xcjr^EC;5LN)Vk6$GJ%`I?1( zp_chtgaM(B`C8#y&wOpHjt!_|qgKbZGuvypW=`02!K8iT4hj?Mh0V=oEo`T7O4!0m z*(E$CG%#Pga9U_&zTK?kAWCl1O5Ss34@%i8Gz%@;FoWn3_X>{-tt`hrl-I_5`-LZj zZOnH-7!tSE^2)^u)_GQO~^FjxU z?G?s^PUbr?YN>xZ{L7e@y{EcPw(;YUuo#UD^Wwx*ba*T}%7>%F{OIV#aegv9bO8ZD zm#hDLgrAsZF;K#?q7sJ7! z$w^ekYH^(#pBTEpkB6^LOvM{myDvl{lj)Mf7bE=ntCJCNGAzO$kBF&3hEWH^34l@q-*GTAmdIyLeEt+fvhkW&X|6n}U=cVKMJ&v7Z+(-Y{U4|oLqJ%!UV z`fS1PcK^reU)nz=Iq>%3`TuZ@N#d_fEFPJ|}KiKo?g?=zXN7cyTj8DBrmeI|4vlo^zxjrDYS)5U1KX}D=P za;a(MtQ$(hANIMl3t;t{KKgI)e3={I`qGe>#iktWs5}P;?)>+9?$cs z)~53l7b8v6k&&?o8zeSkLo{U-(9on!;U>1igOgWN`60}H0D;ih#mNbP5voosMTtl7 z7aN3gjazj4Z|=FV=f!>3I>@{G#_pSYZ|q(2Rn2Xi+bGrVzh}LBN#eT_zHZ6cz2x!F zSYJGHt#i@gdHK-o!?zA692HAW_f6*w=S}|&|Ey_t+xMN-%NEYL?jsb>4H+kgHg$Xm z{duO2V4^Me=bAvij0uFPBbZyY%_?*92o`-IIsEy}N=bdgFu(jFhxO!LbbfLQL&W{klr=i6lP9N4M z+xWx;;P4orVps>${LuNx&;>vle$&OVSd6xotOeDJG|4}8P`QmLH&P}AO2!rgo8y9C`|^cldWb zu;e-Sk;UwHEpz4^*T;6wRx+D2w|l|bv}7%Y?}%K|C^bE@U_G>CwLgFKxuZ#IscbEs zZJKXInjc#Ie`7tJu%7;j+dq5c?iVC?uVn2N%g~OD#bZSPfPv{FPkQmFurrKFJE$!) z9X6h@4Qg;ZY8o&pi-{m+p9X1BX8KVfwp!{iEhr7*5-bEXHZG_fH7B5Cy7s7Lz%r=8 z;69Id?wqapeu&|s_>wpzY2fbB+)YG-ACNzIJtU&1zg1$VE zA7w)4h}4^@+>Xw!g9APNp`MTO~@ zeo|I(Vj`Z(p_!_H)?zh9jC$BGc?#GoIUuv7ld1?PbBf;Q6 z!OT@Tz+=|A3zF_~*_7ODBd9im*XuRO8#tZ{w zzjkc(oYDZY$mSIAVLH!oM@>;v`s`&sYKfW&P_>!sxM~AHbZ}wJMxLwUPFP2{y_PC& z#MH!L25{#vbq6ecn3XG^L2Z}SXQ?`O_y^^|JX9V%rNCk2E&1HxlN=Yd4cPh!o~(FQ z&1bBI<7n9+jVF5sY=TvvQEPvD41KWb`|&kV`+z-at-@J2YCmW{eiYjW^NpC)K5?Uu z2DP=KpHl~{5p%u0SDBBdRAE-M9{8zeDoW>r&=l~_wp2ic?CODF`ufTcIyMkuqRhDP zx$#JlKN1C^4Gen>==+2S3?>%i8ynL(8{-p=3}fIY3AtrQlQ=`5wT&fy9!m#^=cTb> zpgmce8mxDv>Q}ZUm=3_1AecV?gczD4wqOWSHLKE3#mB~D!4bgcc!>GMR!ZnT!tfUb zeX@rqCnM2eWeJz4OZd=<-cy}@{h|I7p+^S}_V$awD3v`1SXsf!ir5;bHYnxWV>na| zolm*2Tg5{WhNa~U;ea+a9F9j)mdKT%7{NleqSalbi0vZkXK7z5?nlCl_>281oNL^& z*=FXUor)+(jR8CY$#MPS3Ihzn?DKIbYyf*P^3bc5Ga9`fhq} zcwZit%9|2JTNj+oi&oe3$DcdCkhgikx@9r9@Y>NI7M5Sn!3LD{SIPdWq`zABSO36M zu;eVCJtA$~wcu<=#X0MK7K_`C26>8>9TrO95Uezl!T^QI}x7b-J%#oQm+5&qtyfZltatoO07R z2XOhJ@hQSJfMdnt=|d<6_!6B$v8c`z=ve3Dq27bXJB5r* z8$G~b2Ae)=fM=hn7HucI7oZCuVIsHRyc^+{xG&py?l=G@0gF*?hh@a{mZ|qGoHtVD z;MSA_XgiM8(Uj#vL_H8(Q!tsFJ)@Cm1pR*Y0e6j)e0zU8G`DfK_SZiD+oAs+qb~kpZh@)ZmMUAhSZEakenjIj@gS0k zJIJ9~p0b3aS8;6Vrhs;GD4rcz&Z?`#o6#(>2MJ!FMD*c5!|JeFZTo-Z-1o7Q^Y|Bh zd*2Ere0vk_y^?kB&pvi=p5l)J?U%9XVht(g3Sz!nm92$HhOjXX4=y@iOJO%15hO*P2-3diDpB$S6bwq*PYm@?_&Bbth==@r9TO%t4Q?@Wlzg%xktxuOp z4GX6IA$kd^t)xb>@lILBCq`4&vFOM|y*ZU5MxL39#Nv_Rl-p27jCxWX_}uhKIHc7q zo0jr0g~+q%ycCjWq@Lmvt-%qv=#r**@ia-Jlbr<>60bEl6TnjvBi?(`q0S* zikIDpQ>MTRRzG*Ms@ZbK)NC1|)uuigYD0+hmkkK=X%i6EbjmtTJcMX$vkn|tu*yU% zIcr!P#nC(p>QTy(1`X;lTv>Hi`X=!Va()$mF~U}_aZB#JnYP)>3HL_Hx^WFui`I#; zJv2E5twn^W(^MX{3~FDa!#F~Ar#nuLV^a!G@z^G@nKMGz*|Uv}<6`5*vFO>eL0)+~ zdzPjb(E=4}SUfDU&0R$9V9Gu&hLF7X@7rJ6`k#L7YUjRsvv?X!OWD}&i?_$|CO!ox zV_5y^VI2-B1&Cp!_$^9R1Lqp|x88!8{@I;#{R!`8+RwbteSXoOzvK?g6upR5U83|1 zl4?(S`I$bVqsN!IGn{AwYr!m-z)CQKjexxYOazBuWn@*mU=tmq_IhV3&<$kTh>Lvi zyC`6~#~5@-C6Mwotyw?BLH!$72%-v-gY#>QzZ?;D(ko61qhVTi!Hg}9rWAm*>7zE* zP(}wffeviO0Hp_uPC%e>Teflo<_^<#(6GjlR(U`_vi1(CZ}gi6Gp3KOa-=s(KvHWgGCsmqOv-%Waw;bjVuX)Sh;6Ju8#7Xq6X`MNh9uRCU}FMAj3)>BaRV$BA;`WJb;zU*BpuDEsiOJA6`O2w^r z%yMz76ll#3n}8UYKEiow6>Q2rTIz&HtTr;poPGpzBSxrwGl$KVMt~Nz8;QMXz-V<{12`O+w=`3wxA`@W*tg{G>F#>V}zy?Uy zaR{}m_L?->3d#ezs?$wCT@lRfzrRWEICL(~%Vwp6m9r_j zIpqe3C(_&aL@cJDrxx)nTB!i`eD$c*f3UB+6GV>_f`GB0u1{GhOUka~&)n>CS#hEB zVPFO6`M>|Ojsgld7Vs@p8QTX3yJl(StC#2E^G`19IgzM3nXDR+`UeuFr)KPn#p~uw zx2BWDJLkglb;%7o6B~Bk*&uJ&DFt>at}-?nm_T_17TDOl3J(DuBV83olEx4QCNN0EM)veu0g!Sz&`wk;Lmm7ylC+KYiW5lH zr3{lz86gk0R1oMRvGWt-!y*t?PMn03$_ufRI8pI&29I0>X8CbY zgH~)T9qGXm<0wHRN2S5gFH)%oKI15^YQvvW$sla{iSaa{0D9cZav4HF!S7r8UvFF+qA%`i<3x+ zwAl}sPPp{b<>`a!&|%NfTJ^v(Y?vlM5LP||wsTan?F3q@9vx7gHcY9-2l%lKwnwOB9R!_a-!PeVjsN<_BZXpD1r%1Wy$y|x%8U>gUAKCY}RC$iHA;7Kp8m#Dzc z3L=%orMK=cP)O|e;m~f+Z^ZtdUoi8z@8`F?5t;A&+W24Rw=7}3war)Di6_eU%ycYn zZ+~a}_3`gn-tR~p4$XADd}67hcCKT-;jT4Nv3Dj1m_m}@F7w;(9g#a8f9I+cx{%<< z8H!L+{%ZGJhqU>Tdj}I0UCD}L?^h>EdS+~(02P$n-g|rRQb93s`lNM-zB}-KTk_bT zd~7h$d1j&D$;I+2hGEFXTkljzho49k4@!Z-)o|H^4`FcmpVEsM6AeKtA9d0W(^AGn zSDt=1CK$*iMmP#s&Ku)f?7#{}r)9tf z*E(Q_Ya4LDwYTP|b@0aY4nruNi`mmxq5AaSKA`U_A8?Ss#@uC|!Xlr%5p@lKxhZfh zpri~qTFh-`+8_s9%9|3C4s*0w01#XXK$tG1rP9dvGr0^tc0N2AY3B#0%?A7Ipw3wx(MplyH#9K}`_3r=rS%1~lKa2Yxu93P8@$47&)OT+x- zF)-*!q9g>Wh&VPBiS6wIDJ;^3bG|ntygra}>j763P*EJBs-}Q-qA=!Hs!dr@+<74X z)MSyczm)A__)6$P%7P#G$D*MN;)^Vtogq>-0(Fxq+oTAvL~lf6wks`#0Klc;dXKV; z=R8IbF&uAgNjV_zb8Z6i0Jo_iaF{NG7BMt29!oi?GU}R?=>ib8$Vfb83Z-l!iIpg@ zh?W`kO!`T~qyLQx>in#mefBOuQL%r31E{p@EoWicfR>Sjzo&M#XkD3vOj@6_EXx+C7LyIXYk7v4Yp{!!`F zSt)!@Iy)o{O-N;v3kA1U=+%fRhg4Gx~2z&$zmxdS0L=epjr)` z7oSE0>T{I2WLE@@QUOfTkV3Iml8bL3mH2Prq%0$oJ83@R5KOZ=c0rjx#HLy?dHx+G zKlHDhJjS;ER}>U$g@dWHRJ-Z*9dhk~dqvVTj)XdAHp++)y2?9Pl4@N0C;GzW`_O-jex z6-0v3%VG#k*@lRwB>oB^@Z662B-jk+}oo4T@Pn>q|)=vUn?-|o4m40vAha8-Ltg(1Eed4ls>ptS( zuwf9tg95}3IB%I!IcaKAL`0X12updj5Kxn_*e_fr$~4QQl9hO+b?7pMK9?$e;G}v7 zMaF92;IL*sXv(RZUH36ZzaMs)xw1OBv~g|(&UNdr`V<5%jhr9{W2Pt!m7RDYhDtW{ zEt{N{e&SKrlUe*O%3uSAfu|1@--cr(GAm@Vl2xCQ?vGC>c`+QI#{T@9pTF_>q<_8a zUq3gJ@Ha}%Mx}566bX7!0~P+MM?n%4qyw1$HxVfQdvgAS93n`h9HG$g#1Q7vdld8x zIrqq+@llE_A>WYd5CDkf!ub{M2i8+RwRV_n!B2Y3aFx`QFoS+fM_CJz*{;9^Z33^# zlZ_Gq@MTP4RnzLff@zmIYD{Asuyis4f`)n?P|DgVK+o1FRgG8@U{P>=+TqpE9*bbL zqP_>mF&^A{V6X;xi0ow%4x7CsNFizbx5$H9Vx=9M$%eYr_BEo#f`hdHSi7MGU^}C> z99<%5MGe`%ht(js2&)Gj?lMRWIBgh8b*q^61KY@Y3nv(}UUMTx5QK=V2l^VW2{frk zO#{}GCc%S0*Gbb3`*tS(B6vZ{@M$=>4evg|qP{e-`R4!8RrV(|Bx{LKxfMF4zAJ&zr8KUcLGu4qhseFkVAaAaFkUK0moqo=$t*vDh!<) zi^o+i9=Po`9M_hiTtJTIC#n*5R3|bK&N_< z7B3oT4MWDdJ||_4PNs6hG3)~IvC*lCsaRMP!&g%_5{=p^&LVNjb&3j6;9 z9fchB^Upr_>`d#-Ba)+dwmn(CMK0eme`vl>D&LxLY+dq|%dh^-WpPjoXwH|%H zU7_DdN6$#5PbPd%NzSJhy?HkWZw$`n-3T#`?aNNtQ8Ifl;egCxiB#S&e`@}))OPfH zJKx_dl@26)rzGd8MMrMZQ7ofMv+O9JJtsT(Iq>o~Q;yQz3Ev*cx#uV8a`R_vB=0&D z?JIcIDf{?q-HX2bn-^cWDEZoEPr;dc49{Mxz8 z^RYVz-+5&Ivb=G}H(S27^N)7kJNU=D<&9l(es?nOSCs_%Q-u5~TBy-9b~eRtK8({OU2@q3zXy)SG^bz3{K$5`1Zg1zf z;R+%vr~O6nXVsX{_gIzIGmN=0N_+}q9qGyd2Q@Ku!OnI&aGuvX-ei`llej^&MLqPK z&TGViYyUdqPdLIR1T(3r5eOM5XG+r7nGmsdp|L|+MZJ+L@!qE6{U;Tm37|Ftda1%? zhaS!a07^|H7h+b?3GpIW?<6+IGeIS!1f@gGd@h2T02|{-F>xWrQ@x7*5daliR)CDq z0g4QTMZmP!1OQC>l{9_`o&eOMUC?7fWf2fkcyM9IE~3E%-Vx)Xx)B^c2W<#2{fSU? z_UvWIo{#d;sf*_jMMW^u6yR{sr2skWx$z4@h{Fd(LVG@-{sPa}g@Xi2p+Z0uf<9B6 z1l%F=1JW>^ae&4@fuEE!8e%{fg6`1}gtg*PQt`M7nMj&21SAvy5x5wi1REL=>0`S{ zXS^cR7JCdHP+WYR(?3)F#m~)LnQQ*_UzqQB`@~x(?sg>`4#^FWEi72{1a2O^adakz zwd<*zeMa_FLp0bOAbpOT`)}+=k|nqA`qYck-= z)a+;;n4UPm50|k;K_GL*BUor^8xSNy;uLg?N4Qr_4&o=5jSvjP&Zg%kq_S006opQR zU(G*AXgquL^NQf0<)AJO!AVP56(B=xd1?;*m!Umb+acr#7AyBCjG*4;b~NWOd11XXeL3_Gzv;s8$lvM834V~ zxF>00#)9HsqNxlni86fCB}yzMB@7RIdWERYPnR}8;&lvIXBr=42n#EB+OA-Rf3I}V z^8V3Z{lETT?vwv;XJ2nnOdznI(5|70$*c8wVv_1xBy6OnsoDAIPZbp%jQiE~ZSJ41a_4wVdUW4Ike zw=H7$a>}LCGE;U1n2LW#bwECkaMfYbAyPcHb5I%`UM;PiOX1Mcpbkw_@T>r>>j}J` zgacU+mnZ3}yzi=R$!d)>N zlif85N9|lZ;n=e1&%5qg^agGYzAz|x56srSR{u)<+@ZO?*&tr$fioRg2=2e{Jpe=n z=%E)lOZD7_`TY5F^UvU~`1PiQw*_&&{G_k)zOQojQo^?(>DwavwtT%a*|1-3*q`tn zNcy^DAGG%lCw;xLuQ%a4d99m}!s0ht-faJB`~1_1+Ff$#u7s~W>3c->J#ugRdwagU zM@i=K-8}rl;h9r&b+Tt8wvxh%>$TU#8Pj#~`o`yaHSB55_NMoB@BGl)V{eTm_?-#& zF3Gw}*;ml9+N13&Uqa}^_Z7oxqUz%xDGWI^xUII|0P~9VFx98Q)SqLoF!Vr{T1YriZ6gtLR6pR8HC%y! zPPUGq3KAQOk3sM~ecA(5QDBoIpUlWB8%b=y84M-hGG<;L3NO%zH+Rk(>u}; z$YlI4oNz}Wpg4siFGK3mEl`q+4X&{$B>S1xJ!Qcel(F<0o3|0Kag@aS!*L^+)jk^v zjZmDqN($`220`QD6!e^~xRV*FG=v+Y;{Wl6AYXz|jotAzTe8 zqv@kPY=J`??4et#IS&ZsC}iNQ#APSAHdng#uOq`PLv#CN{n`;JTadp7nP`=OTZ7G8V-k)1Q4N3YemJxVx&Dt z>51tDT6{?vCL0LyS6{gL;Z*lQN`@(*PeXk z$@zVWb?tv$wEKG3q9^xe&yAkhqQCa=A6mHLJ%8)TyV>(X&unSJQ+uuR#}3b;(|gl% z!}D_g?D}6Hgi0e$HP?F9*kb+;v1`R6*xC=X)4092Yuem6H2sL8)CbT9(1;`O)!1%wg0M9Z z-?Wt$f$_+udOiXP2)?-<;Km+{4?|s?{X*9b>`GD~XDB83 zdbMZ-&bCwMAZrNGi5M`jsi;WDos^@a^HlFZPfyCFa0m$&gTq2cR1$KSfSeQ|&gy|S zSDiBunu@VWBvvEoahfuZAswcSJNM?k8~YSBS2~`0if;D4(7Tl5xqj-!@|j_&;;iHh zFZoLq{Wi@5`q76uHn06!=dy?M^V+FZ57WV%V5Jjv z7+pMS(@(X7+G%wy`M$ojF%RtoskWKGU0=E03e_h-2SIS`G69&8lI~l?)TrdZeP5$| zFj1)8m_DSWTge&Ej_O#bJxwx@#{udFtF;=PYkwhQ@aSVxqvq3pk-|m7RIPUCT6lJu zA!BJ>dmID{J2Ps_wy#bvP4xk6U?h}hKx12F3u`hNzF>ozNB@1)q>6)KWBMIsV`B4I zA8q~n)Q-}#XJyzK=0AEAU+l%{d5fCK(4H)KB>Kx|nC;nn)XxOd0+kqUa zJW#g-%0NnV-KOJ(9H%MLcSsu?yixb%6ZQhg03ZR; z;;eL9b@J~`2@&1MiE-|V@ZSra^BN!+P<3@U~W>*?H?d7wCJ1=sV6-93WKf? zI-q_wYC$;nz!cND;qbmxecFx-+ldX|t?T2cNzdY&Vmu1E#?9}&+wraAe|Y?lPb9V+ zdp#^Y+PBdEm~{F~qW?*G{ZkA~qS9`poz7$CqYMS}o2H!&=^mf1tylXOXfPX#Am0a> z*t9|!!?^qF!ATl{lGXW7okNpTjih(>shg1_*r)TKI`~a&7^ferXY|q)W>-L`Coz zLXEI=q9ay&sRA2)U3``8lHv!<1d)*L$7%4NhjWelNpA7%b~(2?nS1CBVZP{VgUR)~ z<@LMob|uyyx_0!(?!s3KUn_g1ELpl$F5Q|eZIMe`zH#o|r{8({-RL_}sq<7~`(x7J zQ;F?QK^c9?m-lk(?R~fQC44nW-)7mjIpJ#n86kI5B6l-*P*&gbC!RYYmppatglrA{ zu(0ZS&a&B>Q^?fQdD+h={55l<5YG5Ppk&#?1%lA_4OGuzSKlK0w=k`B@1l=?Bmd3H z*DED{>mBDe3zNH!%DawAJC7}MrXEw(V%4VE3s6`0Zuv=G(N7)L9M`got8aM8eA9Kq zB|+1`xt^Ff&KI07=g&O;>y>ZR&W(L7C{^$IM(evf-`NSzUpx1HxQ;90mn%7^Z>A{W zD5ft1=6U?a@ua6x_EaW44Rf1;!n%q;CMUc^a+S?)p`+3cdBYBA{m#3Nd$zlm(V%WL z2|qbKX)Y736o*02>91S>n!3ZBl91}B%`2ic5M4AU{|aTlQ^ z6Jz^;(QsT)ByiB%m2#Yi$SlAYBqnKmba;_+o+J8TbU4P6rtC@sQZA4HCN78KBaqLv z0R%^*sS2pqs4}}GW{h^i*ji)=0Bs`rmPy=!s*IqV40nuxYYkgmN@}qaDXvngKC}VU zM{s5?y|_E+u9n>(^CjK&vb!ELbg`lNmpY&CeXciYEtajtNo%EStz59yFK*bPhL_0J zk_Bt+k~Jr3-EiN!Veabpt<4G+Z0v5DXn0gDKclXx06kM^nYskB!$4Z5z=0G!Q)rFa zAx=VCrcB3_a69-&yOi`wtuWu|6kVe^kfio2(p8U=okAiJkNqOPZpf>i?0^?dFw0e+Qc}{QDH9_?QzV0K&s;;?1s{AVTFGEX*h&AlVE~KJv(^2c`1LH ze4If-jM&r1r#*%r;-DG6K0DUuFr=W}cEywJ1hNVfbrQyI#2Zha9M=HXAjUv>^d956 z!Fn~+FjF8VrOys^#F;eksE+CC^xmj*kVq)(8L+oOk63UISVIuF!W;^!G5RlJyolMV zhp0Zp_z=^gzU#k;@gt^94^e%H2_R;>`mX;XCKoX~^$^vEm^{R^tMB?RV)7BQM-Neb zh$$G{uf9Pvp+G29R}kWgGUAE`52z^(X^Jz_6bmJWxY7Y9N-4`oQ6`idQdDHbRSZ6o zrG|AGY1RpqhWu6dt`0Si{)<}qj9mO6&g9Bt$X%V0rh4#jmNYe};iwv}|DuLk#PsMP zst+-B8Rgaq>kV~o5H=#kCZQhg<^fwsF%6*2lVsWokFg<{j%Ifk*LAca=HUKe0%dG?XY=#4D1`{(b4z9;nhRNuDrP6FD;n%_DM z;_^d!t63cvAVy?-w52y?XQF1Yl#TIx*#TFrujG!pS6;US#UMn~B5-}W=rf0k+pY6islQ4Yt?QH^<2IEm~* zt{sO;DntA!Mfi0UNk!$^YYY%m0mG|e!l77Uu8{Ozl&MZDpAbvsDG}+OU>1Q>IXK{k zVv~``a92t(dmLiy?)y}?YZ6u?VK%CH`;~r%;E6X7%#=*0BMKLujSnF|?fiPOy#lv^ zeAK^+&0$b&$?61|YL2kjak9szbQ60g{aCV8NLt*`gl*tQCR0AGW}`rc z+eXE);px2e;OMrcH>FJV)5i~jBL`dz%>Z%sX^36IOEO%rxvVso#G=BGxf%igE<7Ys zjS72?)b6In!y*q`h)TX-eY0W_hJY5cnx^QiE0&Wdt$YrIY=M(t+#NmwZbF>*t2wjJ_UC z6l{Y*y2_0U&T<&2yIp>({JI_V{h3nIzGwR@ z@1J{?Mp=)7CoguwUigT{$o+qvJJmb ziY_X<{p6RPTz1eK=okezFTZekIfvd{Tz>KG&0pHQ?4~!5`sSrKpZeyfw*XhVE?Kha ze#xfgTzb#rs@K2i{;GR9pWX`e*h0L2T!cDj>*ieZ9Seoc%f%E_f+Vy3b9)zBIu}a1 zmP;wDj4LjmZJR5fA6_VGT`ot^hZS5w+44GytW0OPQ@v2qwp>L)JXcmZJ3QCB&~|vC z^vH5Gh1GC;9VvtFyJuOb>R7Ht*m51Wt~yz<^?t?H<@J@QE6H2t4O*DivlhiM^ZNF|^aF!VCi*5PIhRttYc=LknsgZUa zd;iG$yQNc)Njpw2dh!-AHsx=YU+=hi{DtFps!@5;Q!9IFmpp-)f|*}f02|%YPQsVR zZX8Q`sv&ln^fbzz#*cGzK&C37De~;iXJM@M=HVNMrNZIwdm=;yD_4%_>0>z-E633h zhaHfYt&CA+o&tepxWt7+Ah62@E{@TbLp zeHp!#GvLp(iGBLV%z(T$FO+$&&5nO%>(`tE0VtNQHJAT?sB`takLC`z1_FKBp-}Hp zLw{#mr7pp}%RJy8$fdPf@99-!s|eX<*#7he4W9-gI?R-cfR0XxBt)xdK`~?+G6+O( z75|Dd%H#mta7*euPx@#fmH|!%mM;wj`XC4LJNnOp(%Ja$Kzx z^}7^BHtEv}NFu3{iO0zKl;R`g`!4yOCWj%mddrq5>@1v=b1a6_2o41)I}8p(`wC*V zfXEzER@F6uhoN=E1XiIF%|16WEdC4ShuACYnUp;QeF8D8tM)SmrnGDVre~FMvjWp5@LcKgLeWXavQV)`{a4j9X4MVFvmlY_m_I`o#7yXaAdx!{ z%Fb=q?X#QbFD(|9-#&Be%nfYTOa1~95t=PZ`iVERR8VyL@RttH4$q$XM&7$6@07>| z?bkb(3X5-_{?h5$_~aIj?rE>&@e@AOG5kWZgcwZeODAKqBwpb;n|$c-A&sfWN6Z>+IC5M-Hq{ z1~$ooO<$jS``Ndil>(cj!0u#VzZ}?~2pmiXy5&H3B5>ro4TjloyKlLZf%PoQZaJ_U zf$jkA%E*It+U`W&VaaoN(N~i6Rmr}pgs(d3tCM|o^PO+^yw!7$$=6A~I?30Q^!3ZW zere#bgzvQU_!F{k@VW&yD-;3XuWWg(>6NBL*_K2>!}Xjcf9}m^UwC$Qm!jc3UwUUK zQNN2StcJCXyIZ8Hu0&q91PjkF^amRjbN#Smv1h*jPTyNk%? z1IeNexu_#i)Ripik&Ai~MZMSEkmO`3*u1`N!lET{(|6e>7}tO}Qn&6Q08IBJCv zcbl#qqF->JZ3-`9Q?O=}|3zpXzH-HbP~z>cc-kyc8=3GiP_{J*83R`!Yl-T2>FEYF zNgSYx6)UR3D_J|UYNBguez4y#7WAMUbYf%=%m`V>HFU+m6R1{G=)X|ZHe8cK=I6S}wJ z2YBtDE+EKDmWdk4;18qEtdG?f=+eZx4SwBk5_>Jn=WE zLQG(e*)URezdm{u;G`g}LM+wLs2Cnrj35w`vfiQZPl{HX9i>Ayg6(!te}j$mVQ~T} zS8HDQ)lFP6$8;rCwUPj_G65A6gagYaxkbs`>ifCXZ`34fw%@PWe&^D6``&xv+fT?f zClk4kg2Dh3WyfwEOBU421@)jm1PZR(mLTffp)gYExL$QTbSpGB_U6Rv6L+pAcl5|R zdZdEm*E<(0tFIq<>BLfHH3lQ2c*N(k%ELqVcS2W$R zCn|Q#_r6FTggqc=QBznwgxJ{d1*nRNbjxXz5-I6upx3 zi5JOH)ISrgp>U0apT_Diet#M)?5yR!YpLkWvwXLz1pYtEb9M5LKdW$%zqX=tza@hw zOE4B|!t_xuJdllixQ2m6g&m}+1#<-^gVjRq0KtTxOw-4i#uLfhuR_0=&QlhZd!k9w z^>U1VuM##HDiegYQbNv@8UN4lrLABpNY7==m^69>a_8{WP+T*6nbxpWbbv@KS~LDz zy>c!xH6de?WtxoyjUZE#x9$z|o35|A=Gzl6rkA%J_=LaU<)`P06Gioszg}|Iuio^u z4*po_;H;)o*f}f>hykiK*$!qs0{K=x*<_Tn*{VMspq0Jy$#&k)wmGW6ldM#n1DhJR z`)F8vc3F6Rt7MY{0;0g{&Arp>A;v|bP*C7tiyi8@P|{*UOa#sJ#f1=NXVhheHy=U2 zr&PogB=uB5clYt0M-}M;MoXtVsKlc*>e{SGo6ZscCq7x33wFh7`)}^Lu}gMWFvL$qz(DgXy;TZm#e}XJXR)e<(8Al zmQ!-esYLE$L=-KAhzAhjSI@on^lv;pUzVtBlMCB`*!l~vdsj~&2Wn+2bO|n2=z|GC zZP-iGJ0kLEPqxVdiH58)q1j}Vb;(t_?&cgQ^*D5DrRyHSL`U`k8#9Lz#Zg(qAhKau zWL8G7>@tD0<;Y5;$^I=_q%#Jrbn;Z85^tA>aKZb^-V43#b2xM#0McD_ZF}{VKk$PAy;mZ3OVl zN+vRqeEFK>pe=eSs!t^qHG0wFoP(a@Aw`b*pmVT6O_Pn6tmCUZclygj;^}Kv)9b%# z>3u{gSn)h$oS9io0Lrw9OogD(0f<9aZO{cT;2i?hV&n}%7e63TLCD)$hQ}_ZsTgGn z9``t6VKTP7+9_m8OwE^E8jw(CsCn(H- zXEIs`qh?${VK6*NnsX^rKP*{JU1W3(*6@^hbcjsjkB@_KsE|dX)LbvaAXCl>DD07~ z&Xku(Ik;p+(5@eQi&-%|S>>6WMLj1x92{u-+f9BN#i=?)JKO+ng@dIU1EbnOYy zPZC8=IoMn1N(|%65dy6}3iZN&ZVVFFARnb!nyV4f6$$WvZl3!q=Pj?T$MzgWyG zx?O*(KABf9=hXuP$SqWX{(3nV%&~z8GJo>r%kTevEbRcR9JR<@JoYp#dqt!x9zX?{`p?H z@NsMp>$fC56|$#dwjWH2zYP>71N{8}|3+T2dfWZ#ZFkPg+j|#Iolc%QE8~B4I1xBU zRExsf&Rfpeg4fDlDNh8}13NA%zkT6L7v}1|9(nuXTNmY`J)i;v@)1#3HrqTq`qzbx zz>SM4=C-}QS1M{w7PZMmZFl!Wf-W3s$OE^oQ>j8xtt zmG4TH@0ZK>C&~{d%RA-r&O~{)?CHLKEEOpJ(7_c2fxvq6NJ!W9g6k#EVxXMpDEZ581y7G-^sio74xY}Ps&a`3e%d(6@LqL zrtJlZZ&CdW{bW8O(JAYJQcka1s|8P@Du%3RIj23WP5kHhUMQ(5h7u_f3BN-1u!OH_ zKGql(@6Kk`PBCUjb69+ioL?sAMRMLC=N>h+gPbyQXwwqADS{l5-(;BYuW&y#A28WQ zOdofc^KGY1|FpTlR`YR##kTolm)%zMNua>iYf?&}eER^k0Ut&qkOxWG*-uOQxAjxp z4T|8#2#l=^lR_>Qw0=o)%C20NoLxX#IiVP|XS6+EJ)?(F`zUVH8Q^xK3CdeG)eOjT z9#+iohl`P=r|WnNQ%QiQRW;mol@Dv?WfYMO$Wc5BD_;%#DMi+pjy2jTn@?Z!tX$$f zjKeX+Dgaj-Va5h_8*$IJdI~xpf&Cjj-zc=A_3~i)UyQSx`CK@}Xfhilqyeao%STiN zh$u-(vvx7VU|Ke<%`o}^qsJ32#SjiNe1k^BCiu%DG`nFbf(+)+4ciJDP8YuNBTyHF zG6n7+k3^w>J?##%_5e&@3PK~528x=CZOIp+yIe=2X=!Gd=TuAtk(|&7 zOG4u#5HlKu4It2@nH8+GRoXwm0Mr0$jS>35RGOVdGs8)`2(KLG9Ty!1vsPR(%q|gv z{osUq-5f5(Y>=!CY{q3w6`FVqRsJn_NYRgCM@_~lqx0j`(~UNHfGh%?A{=(bLx`%H z8amB{kh8}ip|6q7)HoodX>YbG0n|9AAtcVDm7m(C;vsGYxOpl8z0sV*~DGNEUCFi#N~jxN{^?yzjb)1U5bgOBP9Y4VkiV*CpMXW%uU9 z!v^Ff)B=hxDpejs*I~d`K9KL$Xo^{tA8<32HXzW!%sObRO=QtED-MDT=yvDyFKX7U zXl2H5ef@=&&^Dee1nNoEXcRA`qq-)Cx@8CG8MX^*YPY0+M zLL_p5?@+>+z!lxkOqxn`k04eyU4tBSrA&>Az|KFQQLxGk)P(4NjD59_PIAD7SmoPN zPQ?@21|kTJop_g=|B5sS_!jwT$w3bs&?Uog0G1|}s+wDT4@q~@xF11sEH@ViQ&Hmy z_m-r)Rd&OW$=!Thzn64(%I?m4L+`gH+$XNFi^1D(w9iClA5FNc=1drR7AWP@;7iuc z%2Gs$+C#0!WBRBDZsyHa>{c(krfXmo?V~nWdhxKc{Yj;7#vVZd(J&R(Dq|-Eh@rSB!N{} z_Ny+|w-{trG8M{-?*%@wZ+iWv{q0YE?o6;AzuMODxlyRWOgvqGCaCNO zb)N>bxO7kk#HPj1#$YQv*#U$GpTUXOp?4ibVwJHlvpJ0&NjQ11?^x($--)gxJ)Pp8 zpp8Z#=w*Z_Y8|_RTt;+1eI#`l2+fZCnVzJhT6R<~`PWI#b)fRB-e0uVo_t_ybybFN zAximGVvGi z&YUOcd&*H2zQ^sKvAzi79=m4iUTb`%abeTWL|JybJXU02LqJDMQhddI^(HcqfwTP} zPRgX^jT%-yfWYZcZGcd{V1Z72_VgNUSWQnd&<{wfX~L*!nZ^vRqSp28nHG2-O3*IH zU?FHR8L?c2_c##o#lUm;nGE<{v$FiotI(G?R&+1-nEd zeD+U!mFbLw42cSZmpdT33pzQ!w{^+W#XD(byImtu#l7bCUKl6B$!)SmRISh@D z!73F4&f*VH%2PCgAWL8bf8xuBAxwY4wZlIQ6ynqgsw3?9Nu>=*ccbiXTr4WXSxgC> zty0v*=KJ4%@~tPO-N%!Sy>cUNRVu%J_;1{K*LU2$GIQkz`Nbc4x!fX1B6@O_sg26C zhtM823%-tKWG+Ou8>c6+HXcYd3KPbV{)bO_4155MfE_g(_k00bR4V0&<`uXH=5w7i z3)FIl_tA)Eqq{LZE$Tz|7^qfs+F$VyPcj>U8lhR}hiZ#(KU5Iy0p;i+r91w0r4Lxu zuMC4fYtWL)LU#jYPFpYNW6+$G2Yn7sw(%X9%%g{JIRp?2$a0U8F%YtK6bXqqR-~;p z(Ln_m4FpbL&j?3}G!RsD=`%J-*l`2bFbHU>7IO}_APsl*_QS-QQtLtvyqY|dHJ{E+5B~n%qe~2k262MmWxh3*p<)<%s z()PuaS;UR4bWsNSg7mr7egK<@5fBJXV)ydW4v?~9(Tidi%2~-L^1#r}u-XCQe?l%& zNQ}LOq)^m!_^!vL!u4}gNk^mXXe1rYvdU{63)bQ#U-@iL!dDN~Xm%NF>0C#`T@Osj zmxrb7@X3y{-)dXTE4|%ts{w?g;>wpiX$e#*_t3q^-aGT{Gm`K`QaCFMXTR?ZFXjZ~ zoQiacMNh|~C!fsZ&<}K$KPDyUe{ylbZ7-j@{q&a-V1vWEM=ML7W^{J^18eN8Sy29o7I}t{6cBa z-f6;Fu57xyYdkzka0CZ{q4Q~zZfW!91ed}Hg^i=h32>c>UUG3P1~qJ)@o?lC!a<^u zuH%lK4=Q>mXR+eDm?n#;tD7*DlE(@3FpZBdqcw_T#aGDp3ONH5`xrUT(d#rhxA2B7wDD@lnct3J&mlMxuL}0{)I12KV&%qLla1frGvMp;MiG0^x;! zO!YB<*+a=6*z^@DUaZw;C@+b)i08;TORvw8L+uw?8;G1H67mj=hrp>3x%Mf@HQhOh z%^)WJJ(4{~Fmnzby5+sZV_$UUzPxwY0^d)ZftTxEz6>3L)ALh_;%$k%?aMZVLs}yT zw?`A9ec3_pPA(^ZrsMYUTgR7k=-tKTc&`uN9KA8R?51}Qms2zo#|>0+@y2B@h4{Ff zb+g0C%58Gxwq-ws1h^brl$WgCFW2s0&ZUq%`xRQSk9-I0xkz4 z{kO|*l`R+2dy)QLOz$Q7dnvt_aXDqPZOQT`xx8t)oI)zNobp#Y<_e^ZyB2l}iHiQ^ zbrf95HTH3gxLGQ?K^p-c0Ypj-!cn#}dUo%heQB!{wCB79?S% zxpd2NEkZu7*B=!^`{omkj*G*No&r%hB3SE5=n$7pE}QMlMvu@l2b zbTq|2Uj{O#qj-=G$acn2Xn7D@-)N< z8xq~)VQbaIQzE8ODvuEN6;C#HvtY+R2N`}sp4=4~@M>r|ovv3r#7I?c$75|pDNxvS z^-g=$6%l+oHW3xyWvhn{cMxd7LYXc;q=6-(Pq4*9@M`)A*tlTw&I*`fWFv?qXjXC< zxFS+5G11szIf^#ngm_iK#|ZDGrd*|xQ%=~=xHN`q9af@aMIwYopod5o zK;foawV%YFp*Me%fXr=_Lvk4fGhdM18(_H72Ob%0)ezFQG zK7Z`FV@YeFY%QD}p6{2)*FwQCfb(+4?W12hI(vEk8M&bOZuhkl_pKeP(J<|xMcV_d zt*nP@ZFOm@2eTdP(0IkImS!_&<#4&|11)D7>DL0gxdxl2t@l%hdXD`EG)192%J6~K zL?_ym1y^6-GB9S^9wPP%eeqm4mSN>j{Cy;4&cCCf*sTl&bg3@Be(}zcZ739e-GMh*NQ~dMb#tM5j@B8t4#ART#;f`Fxu64txGM-NS6ZRK7Ri-X~f2DV<~d zP_1)*38i8#X{3-aAeiCct<98-jxgycoKB91QF)C6Vla$h1$RnXN6R3BqLqml<;p^W2x6snS!1CjG$!g%SI5; zZa&Q{#USLZ4ZoJ6crO3QxlO5UEE3&8sCJiIFf7gW@-gzJ-HB zlaoATa3{~lbcW>Fvy9=uxPE8P8gAQAw7C_-LP1wIaaj3QqH7r#kV zGUIPy7a_i?{kS2p;K5jhUFa(yi$MxRVPS%dpnY_BI4u~+^08a~s0pt0ogJ`odTxSD zz%epLgi$M8{htOHTe+B2(4`; zXSxKPG2OCHDS^n@P}qr1GGLG@AXD_pEghu7Pgb8Mo)-TFgT9Z76W@kUe23-$qyyNB zzegbqJp2KCsr*oP=kenQTYE#op@Ti0p#h<@12?>*=h6X3x_eLbb#^Fbsfb+z@`cXe z@eomwAP{wr!U3=lIH5&L)WtM;vGC#)wsj`VYGk$+An6}ca$*Qvl0gVZwn|P$ao`iGHJ?ne^Z~LW_PbCk8nxb*NIFVoN9n7dpC3+EwaQhkcjD6iQ;8j?s1$cVfrixQ>Z3tJ+NmC}RS*l%L~tjJ zzfl9pLx(e2nTZ;7pv__W&ySISGMmPjZAAVuTv4M4#w*Jpee(h>A7zayT#+>6-j!ae zZR!#q!%_6?S#^J*jYqrqhKWm4IWYZzr2}&fFwf4!U|s2pb0{(m0{H%{6%rSWU5@}1 z6Np=Qm`s|Z2fNt(RT+!=PNpyxQ|>ftpMVdWahd$ZLf~znq+J3X8zKsX^D)JMVif!X znrg(^0izLydKE?^+v=qJ=7gh}O#Wh;!R|Qt$iNJm?)Vb!{gQS68hF6`g9jIQfX*Od zU>U1oz=UIgsTIaJVd+S-GFwSkD}joYk;;}%Wmj#{B9#NF6cDeLl|!krZIar?fv-kR z7mfh#-s!1KD4YhdDt13L9v_>8eR<3!Wr8RJp1y-9h=HUVl2!`OgFgZArw%1f0W|go zg-F~I;zA>)Ura+f;GW_u+u1&qjdDpvtc%8odJ z3xFeHDKh;X{$jt!Ak>mu-TJhh3wzP^k!8+Q*iGj_FIMVESH((QtXvt$735JWjmOWjW$Fr+aBC6=iRexK_-Z2=sV%E)$Co*flEcj{6Z2!Xyz3$P-8d-I-B3C=(%5>$t}H#H^=@J9ulKEv z&uG1Gb@?me{VVHV5x;it`h>N6H?X??jPDDqZvTq-+%?-55b_=ppZ}2ff;Hm{g|*|| ztK-%7>jz;WAUyCZ45_lLq^xDFJ!6Vi_h`n57Oft!jQHZ!Beo)b?e$#zko7`#9oJe< z#X{*r;@4gg#W44=qI|U%89XX`Nd4sx`Tlb7qE?ij(Ld!6`TlaD;vxCh?w|69^iR34 z?jhw@u8hy2^7-f7z;e(-qp^jCAC3PGP zX|yZ1&l#b1?dtl~yljruK4gy6K4gy6K4gy6K4cBo3UzCaLG44Yc)sf7({8oE{G9OQIBr*$eb%jgQVbUIVN z!62!Vh0z1Hj47M+fL0U80oj;6!WK2fDm)^4`i9S#UNdu~maq2N1KML*S^h)P*L}wH zjSot17@t+^%Me$u=2zFAA#U@kIE{gpO}YYjXpInrUZ`ad1X$$(vA0&8#XJbc*(5Bo zj$@Nrx7yFzym?p~wz4)@4Q;Syw87d}rnXN16}B4ZCaivBkp%hq+NqAOZkbzX78;sX z$y{pR12VYs2z5fs>U3IpRa&}Lb5JWwPqS(sWl7Wez&_T?(QMhS8V5a%Chw-E$AqHm^n#Wx zPe~i1yU^1`rSh2=GIUS6bOjS-R<&l%D)GBC-&`Q=M;aMX23N|k-|1rd(<?j9svS zF)4xy)e*Ba78xHQ(=gFsh9vWJ@PX;HUNv?;rWHveDLvo3y61h$XR2BN)0-by{4=2o zp-aZ1l`^-kF7v^kGGWZmw5jR?BC$F|B0@7dk0L(GL`8o@%jL)9e2*NW0OKkqaTEd! zF<7eZB_VE@U5hZmPw{Uk4U@bg`D`X;4@m|q(UIuZcPI_vvOM|;k>F~|dLc3yPdU?) zt-~wXGI>l4Elnt&oERS)x+-o!8{Ht=#HPSVrHerLYaKs;_`XvrYf8Ge%I>X_b*tpw znhtADx?5y-i)3w)+$~wd{P{D7rOMW%zfJbHN!B*W-Sz_|Y-iHHOZJl*faKoA!e%Fu ze#Wn{1|@fpigQS%L4_DES(_wxQ`RbwE=MZgnshhI?qPY%K zWq+si|2B8+F>zh#{a(29;xY_i-Ud88Y%?}PFkoY2j2{?m2nL%m#7g4ev3VFj$pvD2 z5^cz?)ODP6gPo|xSyv`2OQDjb(ymwQZBy0%t@GcRjJq?%bQ|qfky2?ZdqWZ_soMU& zbLQSVgNI4ls!~VdaPN73=bZ1Hdmi8S`;<1O!ocR!etIF>!fI1c)f<7AJlhji8-l9N z2B@8t6+A^X{GMX975uhBr6kBLJQE9v{Nl?+OSiM)O<`AE(517f@Y@o0H3nUcOlf3l zBc(-W!s1PHP;F*PGgF(R`TcL`dx|GfkME{g)FZyvSFT5*x+U_9uP@~{(<3zAp}oq~ z>P1)t?+>e$LA8=8l}xP!}(%m-6j^xlnYDN1@ItgeH+6I0ePbsc#; z7b4@nUZ!}N>ZP_!qkdR6UBIt?IZv$M*AP}W2i46?+04|?*TwB72w(HY8G z4@+ihZdffwTvoCrtTqPKD6hu&w}sXApxVxqcBTTc2R6(I#FCY554)O!t|%YJ_{UAT z-^2e<*wq_!^)jWGslAIT47G>IB$S6K9;SN8qc}v}9-VW@qX1@|g&HPPH!P~F!fMGU z(2^busvB{QuMkLe;Gt3;6(>0r-}v*L-cIvG?a znKH@L$wk*1R<@Nj^)k0F>^dBDL4NKqQx8-8;4Q^Kx_psRu@6-=#Qt_pG6tmr!<{I}ze-=vW82L!7UVlKgo zEKVZ)WjLuwsN{FVIM1F$-eYZJBj$<75#>lfz_OyER+SQrW!v%RF!#+XoPMNG5`BjQ z4Lt~Mb0BCEepvQ9Qbck4j5E;hqGA#Yk+F|}#A686ni=*PyMOGz9SLKkpFnz_(&K!= z5WoURb{xGr-LPT$Qq;J1hF(S@T@IYz;LeA@qR%;xd}8biQCD9oAXftHoex-Eqx)!71_hoW}NqPPfj(ao<={bVqM1fGV;Zv2ooA#TB;HBb#P3f#Su zt$E#c{3Tepui>J!T!*`x13PN}0Gc??qeop(U_SdS$KrruHlh1QQ4zI2qvhoc#oS8H zF>=CiIJ(V=(+f8LIL9yH*gT$$1&1NmD;}l0qWLe33vgfyiTPgS z&?6{-zUx7Ms2;WzYA;)6Iu~85XFBFi-=7G%x|qEyqBiv2qxn;AS`DY&-4(0X&k|!0iL}UVBx)qrI(8qem z`KEKG@5a%qC+=G2hwknEtCK%>{l>oQi9^b+!i_30bx&S7IXy9326$e`2&>tDzIS>r zQ#+Z`dHeW>r+#*7KJO##?*qRI1lM&6Z!tqeM=Po-Nyc641H52{Fm?OHe1P6u{yUP5 zu^1hve601GH%dqP_46$sfbzlF#(0jyS2WIl^w@-+76YAsttv#*AV>qBV`Q>_s-E*#``vB$}RM%1m%ju20N;HZK(PUA3k}B%1St$$CQ;|G6(3M@d;0)oR^FigC>g?l>^wF zad4Z^6r(jE&HuCZvFl|o|KH~zsJVnC{ol0Cij?S-{ePmRFO6CUqYH;7xPppK&2H%3 z^jUp&mlW&caddx9Ovb;-!a%?PUPrS2)%(Vc>o4yJG|wy6V=^qhBk-t4)#hwr)4*U!OFP9tnC zr)Rxaetq4wbvLtb<-MObYY!EALfIQa=^N(;gXwi-l-Y&(-FD4(Gv$`;eH&nS9Y1r- zRfXL3p@N1`=H^h^mV3Fuw3eyXMaA~x{cr4#*jpCu&bN=hGkj(Ez258n*ZODL-aQ)1 zs0i6BXD5U9Y7sheWd!QeBiBX%#ya}`=$s`~S`*59CX`VdvTynxQRZ8N_C{DFxVq!A z?Mu~aAsxjH4fCz6YAb&g-ySr*@k3M7PyV75?lNE;z`=8)x{5DVvy3RffO7cA3SR%{ z(1p>TqJ!vLV*H4u@h5*l61MU9HReb)<(dH-V5HWuQ&Po>^IPH|Vqu8SX~>NG#){7g z5pgKwgz+grDw-U4 zLlI^YKKI{Kz4bS7!G=-0MOuFcl#g`Y{*o{8To>R&DcdEmdlr^$D$SM@qEy1OQfh{DkAB*iSkFX zJlTQ$U51v3xWvIdou4l21f~{Az8cz&1V_PT+-c)RN-iX&kK4OF?VGmLXy2gg+QE*r z{L;1%JCU=XV23vjJAQnhCq5LfA*T3l5Hc6GZ@9QwNaJcR0{}4`)K*h!uAfa;1>J{% zyA+}{d`!JX`P1G=`#bVGLQizzfCV()s?l%|x{xO+{8M_p4F}391nLG@RJwY2dhrZ9 zm}dZ^NDK6tA!-@o$uM9=P}cM`v~wq z=d(h^b@PMw4*mQjk^$!8ol|d~dg~17T{@@pDPGsyJ}l8G(57B~J!C7rePM2bZEk;H z39s)CuJ5MOGIFN}5wS9q@r(|WF>~S84{jKWACx>1923Tr7}uE??JJLKQhx-ZbEMn8Bju|}n&ug^9A7QuLy~b?9r~_5 zY2P)N6!>^BRf31 z1I+>zWc(x+*iqnAk^sON9+~mwr&&;v#K%E9hj5|L5rrWduL;<_-a#m&<07~l^Gu+~ zKRI!}+6_a?a3%-+{BV#SnrL0~0!`WgUpzBL>4X(ptkeNg4TUIeawxE=mvEGDxER&e zX{$T12`ZT|Om<-u%9lWg)aig`2hIr3jX+J`N?Kl6pLbz$52#+RVOAF^fkc$nL(V}e z8*to%{<9n+IM7I>0{aQtWRT7n938nNke(pGf-fb<6eTEG)PS^lE{tNQvm1$@gEj@P zhwa44^QiZ@C^yaPg^6e{nJ^CQ>k^IS)rpL;Y)tAvx`t5I@^*%P=&&EkPEGEH+S*zV z0VonsBQH-BqjcZuw!n`hu`rgph#E z3rp2w-m#JQZ_w}Ko#>N8=nmd76nb{_ocHwSQ`F@2=D+AYJxYuAKo+pV{MwmIUJRr$ zFImJMX?nZ%cR#tbxh3F=57S0|vHf2@xIEw163C7V!KwC{GXbBEo7EmB)k(fy1oZtS zoa_&cjT-~d533{>+{cH;u`R)}k|M-0U~FR%6H|)-cz$GPf|C|F#>?OhPa0a3k+9+o zi8081P={mqaeE}+S-_?t?`CvJE$kkSlRYM4teQYUT+M)YdLH9}*YDj9!j>HpXR3PQ&geOHa!nyNa(xnFIwr&^z(oNkoU4Z@cD-W*_ zXbP;}O^WThudXB+6)AI|dj#lQcVI*B(8$n54*ARVVL4VXSl~QWA2Y_}IXx5DZwH;? zyF@14xqo2OQ!^+R9ReA+b{eKG0WY09kADOaP1Ct24G7SKgTQ(onLLYWklXlLNN+uS zXrR0Az(B|0wqAZhwNRwnCl?CnJqHhW9O!r+w$XdK7uLjw^|rNlbhjM<@aRBqPha<5 zpTLNE6-}oN(~gC<*@POyg{QP}cuCQtrd31>uZuy^xb_m7^&ag~et-w;Qk1l?k{?v^ zXVT`{*|t7LFa41i-)7eGGWXR?U+3H_Z0A^{r2lcMWGQ$e$$+Fcb4tHc|519RWY`Ei z853x|-G{T8mZu7G%@?x0r+7i9je;Wf(uY-hSl8*tDRRaE`LTtUrtmW!iz*0@*emW- zJcLfVb1WL3ig3H6htIg?(^>0Tp7S{)=NZQx%ltU^+#c(hI`8A2+u}WOO^$YHGCWzq z#=C3p=WyTl7+=p_%e@QScc0-Ku@~R2e^|Re;yj*^z=%En=Fmg$u88ye3gP8T7 zc(Q_;`PaDL-Wb1)^IdG;Aom_Jyk}Hk@@!r1%L=ORHr&sNlpN$t@kek>udr8ulP9`K z;B3qRn@tN33kum^z^BS%L}(>u7arfJ2mL^y5Jno{*F=$yfvJ(>K6<7zG1FI=arj$G zuV{yj@W0WYkOMytpj3F~{>SLqD(=|Gz=QVB6q`TsaC*|u5qN2Tpm2WT@NdQwkIBnj zZf8QAR+~8~nu7{imcO7Skah+*2QN^SkD>y36p==LT(ug>mlMJ>9iYZg%xcBOB^}*w zvVmWZ7UanGapo>~D=0#dmjt|@N~#&YfmMg--X60*VS4Jh{0g+8Y%0Z!s&R@$g%*^- z(GwGh@@0Hg5);kokqOYl%<*}uKCdm8ucjuBA=4TYGmmOuwo8-=%$%sT0@x~0TSQsT zXh7{l&<89oe%rc_Xyw-eyp#BaKQGs$h38e7%-~405IS=;Gv&9p$EXTMrYjUmce#Q` z=vE?-Wg1A)+uooZC76WqgRa))eR-7f$k%0WYj#J!Tn9-KR};<2d*mR8Eni3*7#N-; z0Na594IdFnTSFP%pl10496B@`fo-7?t&#${oNqP-7L$X6OAaN$MjhgFoQe`AEuoB&Ge6RPcK#`;nCXsnr>_=6_-(zz}QYRO+XhSyN6DAhAbs z@|ctr0b;faaG0rde1J>WGN~w1Ucsa_c*`$j(yBTb6QEIe)2GmaCTR7P;{2%uZRZc_NX!^n6NzY+c%tE>|y=I_0J% zw?l4RDsaeI_rF{2n z$-JGpw}tYWK=_hXmAy;F7C4zUdE?hPR(bUkiJUwsyELL?M3m}?lJ|v8$xfNdSjtER z(djaXu&XG)I9uMiw4n&)H>JyM@=|GvT(Oj<$UDEvC_(Tp*&*+emkKJ7s7pRB%axD2 zQl$f^{o_=Z{Hz>tl|~<`>Y@)#EzyVe4)O4*#q*_wA}>{1F@v6A8{z PdXAj=WlgOd{Vx9(<;l?* literal 0 HcmV?d00001 diff --git a/nvfp4gguf/bundle.py b/nvfp4gguf/bundle.py new file mode 100644 index 0000000..7d53e46 --- /dev/null +++ b/nvfp4gguf/bundle.py @@ -0,0 +1,340 @@ +"""Output bundle helpers shared by the GGUF conversion scripts.""" + +from __future__ import annotations + +import logging +import shutil +from pathlib import Path + +log = logging.getLogger("nvfp4gguf.bundle") + +CONTEXT_HPARAMS = ( + "max_position_embeddings", + "n_ctx", + "n_positions", + "max_length", + "max_sequence_length", + "model_max_length", +) + + +def is_model_context_key(name: str) -> bool: + """The model's max context, not RoPE's original context length.""" + return name.endswith(".context_length") and ".rope." not in name + + +def require_context_length(length: int | None) -> int | None: + if length is None: + return None + if length <= 0: + raise SystemExit("--context-length must be a positive token count") + return int(length) + + +def scalar_context_length(value) -> int | None: + if isinstance(value, bool) or isinstance(value, (list, dict)): + return None + try: + length = int(value) + except (TypeError, ValueError): + return None + if length <= 0: + return None + return length + + +def _hf_context_key(hparams: dict) -> tuple[str | None, int | None]: + """Key llama.cpp's find_hparam will see, and its scalar length if it has one.""" + for key in CONTEXT_HPARAMS: + if key in hparams: + return key, scalar_context_length(hparams[key]) + for nested_name in ("text_config", "llm_config", "language_config"): + nested = hparams.get(nested_name) + if not isinstance(nested, dict): + continue + for key in CONTEXT_HPARAMS: + if key in nested: + return key, scalar_context_length(nested[key]) + return None, None + + +def apply_hf_context_length(hparams: dict, length: int | None) -> int | None: + """Return the context llama.cpp will write, after an optional override. + + llama.cpp reads the first usual context key on the top-level config. A + list or a missing key is not a usable length. The override is written + where that lookup will find it. + """ + requested = require_context_length(length) + found_key, found_value = _hf_context_key(hparams) + if requested is not None: + hparams[found_key or "max_position_embeddings"] = requested + log.info("context length %d", requested) + return requested + if found_value is None: + return None + if found_key not in hparams: + hparams[found_key] = found_value + log.info("context length %d from the nested checkpoint config", found_value) + else: + log.info("context length %d from the checkpoint", found_value) + return found_value + +OUTTYPES = ("auto", "f32", "f16", "bf16", "nvfp4") + + +def outtype(value: str) -> str: + return value.lower() + + +def quote_modelfile_value(text: str) -> str: + if text == "" or any(char.isspace() for char in text) or '"' in text: + if '"""' in text: + raise ValueError("Modelfile value contains a triple quote, which a Modelfile cannot hold") + if "\n" in text or '"' in text: + return '"""' + text + '"""' + return '"' + text + '"' + return text + + +def mmproj_destinations(gguf_path: Path, sources: list[Path] | None) -> list[tuple[Path, Path]]: + """Pair each projector with the path it will have in the output directory.""" + dest_dir = gguf_path.parent + planned: list[tuple[Path, Path]] = [] + seen: set[str] = set() + for source in sources or []: + source = Path(source) + if not source.is_file(): + raise SystemExit(f"mmproj file does not exist: {source}") + if source.name == gguf_path.name or source.name in seen: + raise SystemExit( + f"mmproj {source} would be copied as {source.name}, which collides with another file in {dest_dir}." + ) + seen.add(source.name) + planned.append((source, dest_dir / source.name)) + return planned + + +def place_mmprojs(gguf_path: Path, sources: list[Path] | None, overwrite: bool) -> list[Path]: + """Copy projector GGUFs next to the language model, keeping their file names.""" + copied: list[Path] = [] + for source, dest in mmproj_destinations(gguf_path, sources): + try: + same = dest.exists() and dest.resolve() == source.resolve() + except OSError: + same = False + if same: + copied.append(dest) + continue + if dest.exists() and not overwrite: + raise SystemExit(f"{dest} already exists. Pass --overwrite to replace it.") + shutil.copy2(source, dest) + log.info("Copied vision projector to %s", dest) + copied.append(dest) + return copied + + +def refuse_existing_bundle(gguf_path: Path, overwrite: bool, mmprojs: list[Path] | None, *, skip_gguf: bool = False) -> None: + """Stop before a long run when the GGUF, Modelfile, or a copied projector is already there.""" + if not skip_gguf and gguf_path.exists() and not overwrite: + raise SystemExit(f"{gguf_path} already exists. Pass --overwrite to replace it.") + modelfile = gguf_path.parent / "Modelfile" + if modelfile.exists() and not overwrite: + raise SystemExit(f"{modelfile} already exists. Pass --overwrite to replace it.") + for source, dest in mmproj_destinations(gguf_path, mmprojs): + try: + same = dest.exists() and dest.resolve() == source.resolve() + except OSError: + same = False + if dest.exists() and not same and not overwrite: + raise SystemExit(f"{dest} already exists. Pass --overwrite to replace it.") + + +def _stored_value(kv: dict, name: str): + item = kv.get(name) + if item is None: + return None + return getattr(item, "value", item) + + +def _nonempty(value) -> bool: + if value is None: + return False + if isinstance(value, str): + return bool(value.strip()) + if isinstance(value, (list, tuple)): + return len(value) > 0 + return True + + +def warn_missing_gguf_metadata(writer) -> None: + """Warn when the GGUF metadata cannot describe a usable chat model. + + Context length, the chat template, and the tokenizer are the fields that + still produce a file but change how it runs. Block count and embedding + length are what llama.cpp needs to build the graph. + """ + kv = writer.kv_data[0] if writer.kv_data else {} + arch = writer.arch or "unknown" + missing: list[str] = [] + + context_keys = [name for name in kv if is_model_context_key(name)] + if not context_keys: + missing.append(f"context length ({arch}.context_length); pass --context-length to set it") + elif all(scalar_context_length(_stored_value(kv, name)) is None for name in context_keys): + missing.append("a single context length; pass --context-length to replace the stored value") + + template_names = [ + name for name in kv + if name == "tokenizer.chat_template" or name == "tokenizer.chat_templates" or name.startswith("tokenizer.chat_template.") + ] + if not any(_nonempty(_stored_value(kv, name)) for name in template_names): + missing.append("chat template (tokenizer.chat_template)") + + if not _nonempty(_stored_value(kv, "tokenizer.ggml.model")): + missing.append("tokenizer model (tokenizer.ggml.model)") + if not _nonempty(_stored_value(kv, "tokenizer.ggml.tokens")): + missing.append("tokenizer vocabulary (tokenizer.ggml.tokens)") + if "tokenizer.ggml.eos_token_id" not in kv: + missing.append("end-of-sequence token (tokenizer.ggml.eos_token_id)") + if scalar_context_length(_stored_value(kv, f"{arch}.block_count")) is None: + missing.append(f"block count ({arch}.block_count)") + if scalar_context_length(_stored_value(kv, f"{arch}.embedding_length")) is None: + missing.append(f"embedding length ({arch}.embedding_length)") + + if missing: + log.warning("GGUF is missing %s.", "; ".join(missing)) + + +# llama.cpp enables its DeepSeek-R1 tool parser only when the template source +# contains this exact expression. The sglang DeepSeek-R1 template renders the +# same calls but does not contain the expression, so the calls stay in the +# visible reply. +_DEEPSEEK_R1_TOOL_MARKER = ( + "{{'<|Assistant|><|tool▁calls▁begin|><|tool▁call▁begin|>' + tool['type'] + '<|tool▁sep|>'" +) +# A brace was left between the two branches of that template. Jinja prints it +# after every tool call that has no text. +_DEEPSEEK_R1_STRAY_BRACE = "'<|tool▁call▁end|>'}}\n }\n {%- else %}" +_DEEPSEEK_R1_STRAY_FIXED = "'<|tool▁call▁end|>'}}\n {%- else %}" + + +def normalize_chat_template(value): + """Make a DeepSeek-R1 tool template recognizable to llama.cpp's parser.""" + if isinstance(value, str): + return _normalize_one_chat_template(value) + if isinstance(value, list): + changed = [] + for item in value: + if isinstance(item, str): + changed.append(_normalize_one_chat_template(item)) + elif isinstance(item, dict) and isinstance(item.get("template"), str): + item = dict(item) + item["template"] = _normalize_one_chat_template(item["template"]) + changed.append(item) + else: + changed.append(item) + return changed + return value + + +def _normalize_one_chat_template(template: str) -> str: + if "tool▁calls▁begin" not in template: + return template + if _DEEPSEEK_R1_STRAY_BRACE in template: + template = template.replace(_DEEPSEEK_R1_STRAY_BRACE, _DEEPSEEK_R1_STRAY_FIXED, 1) + if _DEEPSEEK_R1_TOOL_MARKER not in template: + template = "{# " + _DEEPSEEK_R1_TOOL_MARKER + " #}\n" + template + log.info("Adjusted the DeepSeek-R1 tool template so tool calls are parsed instead of printed.") + return template + + +def flatten_chat_template(value) -> str: + """Join a string or a list of named chat templates into one text blob.""" + if value is None: + return "" + if isinstance(value, bytes): + return value.decode("utf-8", "replace") + if isinstance(value, str): + return value + if isinstance(value, list): + parts: list[str] = [] + for item in value: + if isinstance(item, str): + parts.append(item) + elif isinstance(item, dict) and isinstance(item.get("template"), str): + parts.append(item["template"]) + return "\n".join(parts) + return "" + + +# Ollama accepts a tools request only when the chat template both mentions tools +# and can render a tool result. A bare "tools" string does not. +_TOOL_RESULT_MARKERS = ( + "tool_response", + "tool_results", + "role'] == 'tool'", + "role'] == \"tool\"", + "role\"] == 'tool'", + "role\"] == \"tool\"", + "message.role == 'tool'", + "message.role == \"tool\"", + "ipython", +) + + +def chat_template_has_tools(template: str | None) -> bool: + """True when the template can render a tool call and the tool's result.""" + if not template: + return False + if "tools" not in template and "tool_call" not in template: + return False + if "tool_calls" not in template and "assistant_tool_call" not in template: + return False + return any(marker in template for marker in _TOOL_RESULT_MARKERS) + + +def tool_modelfile_lines(template: str | None) -> list[str]: + """Declare tools when the stored chat template can round-trip them. + + A template that cannot render a tool result leaves the capability off. + Ollama then rejects a request that includes tools instead of dropping the + call on the floor. + """ + if chat_template_has_tools(template): + return [ + "CAPABILITY tools", + "# Tool calls use the chat template stored in the GGUF.", + ] + return ["# This chat template has no tool round-trip, so tools stay off."] + + +def write_simple_modelfile( + gguf_path: Path, + overwrite: bool, + mmprojs: list[Path] | None, + chat_template: str | None = None, +) -> Path: + """Write a Modelfile that points at a finished GGUF and any projector copies. + + Context is not set here. A PARAMETER num_ctx would override OLLAMA_CONTEXT_LENGTH. + """ + modelfile = gguf_path.parent / "Modelfile" + if modelfile.exists() and not overwrite: + raise SystemExit(f"{modelfile} already exists. Pass --overwrite to replace it.") + projectors = place_mmprojs(gguf_path, mmprojs, overwrite) + lines = [f"FROM ./{gguf_path.name}"] + for projector in projectors: + lines.append(f"FROM ./{projector.name}") + lines.append("") + lines.append("# The chat template is the one stored in the GGUF.") + lines.extend(tool_modelfile_lines(chat_template)) + if chat_template_has_tools(chat_template): + log.info("Chat template supports tools.") + elif chat_template: + log.info("Chat template has no tool round-trip. Tool requests stay rejected.") + lines.append("# Context length comes from OLLAMA_CONTEXT_LENGTH.") + lines.append("") + modelfile.write_text("\n".join(lines), encoding="utf-8") + log.info("Wrote %s", modelfile) + return modelfile diff --git a/nvfp4gguf/quant.py b/nvfp4gguf/quant.py new file mode 100644 index 0000000..1e048f2 --- /dev/null +++ b/nvfp4gguf/quant.py @@ -0,0 +1,1000 @@ +"""NVFP4 learned rounding and ggml packing. + +The optimizer is convert_to_quant's LearnedNVFP4Converter. This module only +captures the scales that quantizer used, packs them into llama.cpp's NVFP4 +block layout, and keeps the same hyperparameters convert_to_quant uses. +Matrix multiplies stay in full FP32. TF32 would make the SVD residual drift +from a convert_to_quant run on the same tensor. +""" + +from __future__ import annotations + +import argparse +import gc +import json +import logging +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + +import numpy as np +import torch + +log = logging.getLogger("nvfp4gguf") + +QUANT_VERSION = 2 +QK_NVFP4 = 64 +NVFP4_BLOCK_BYTES = 36 +F8_E4M3_MAX = 448.0 +CTQ_NUM_ITER = 4000 +# Joint trains the block scales inside the same loop. Iterative refreshes them +# only on the original optimizer, so joint is the default that Prodigy, AdamW, +# and RAdam all honor. +DEFAULT_SCALE_OPTIMIZATION = "joint" +DEFAULT_SCALE_REFINEMENT_ROUNDS = 2 +# Patience 1 decays the step on every miss and the lr-floor stop then ends +# the search while the loss is still the best. Prodigy adapts its own step, +# so a slower plateau and no floor abort let the budget actually run. +DEFAULT_LR_PATIENCE = 32 +DEFAULT_EARLY_STOP_LR = 0.0 +# A wobble of 2.000e-7 to 2.002e-7 is not progress, but only once the step +# size has floored. Before that, any new best has to count or the search +# stops while the loss is still around 1e-2. +DEFAULT_LR_THRESHOLD = 0.01 +DEFAULT_EARLY_STOP_STALL = 2000 +# Once the step size is at --lr-min, this many steps without a new best ends +# the matrix. 64 is about a second at the speed these fits run. +DEFAULT_EARLY_STOP_FLOOR_STALL = 64 +# ffn_down at 9e-4 already matched a run that reached 3e-7. 1e-5 is below +# that, so hitting it does not change the packed weights. +DEFAULT_TARGET_LOSS = 1e-5 +# These projections write the residual or build Q, K, and V. The low-rank +# objective ignores most of their error, which is where a wrong token comes from. +_TIGHT_ATTENTION = ( + "attn_output.weight", + "attn_q.weight", + "attn_k.weight", + "attn_v.weight", + "attn_qkv.weight", + "o_proj.weight", + "q_proj.weight", + "k_proj.weight", + "v_proj.weight", + "q_k_v_proj.weight", +) + +DEFAULT_LLAMA_CPP = Path.home() / "llama.cpp" +DEFAULT_CONVERT_TO_QUANT = Path("/mnt/d/dev/convert_to_quant") + + +def _ensure_import_path(path: Path, label: str) -> None: + path = path.resolve() + if not path.exists(): + raise FileNotFoundError(f"{label} path does not exist: {path}") + entry = str(path) + if entry not in sys.path: + sys.path.insert(0, entry) + + +def load_gguf_modules(llama_cpp: Path): + llama_cpp = llama_cpp.resolve() + if not (llama_cpp / "convert_hf_to_gguf.py").is_file(): + raise FileNotFoundError( + f"{llama_cpp} does not look like a llama.cpp checkout (missing convert_hf_to_gguf.py)" + ) + _ensure_import_path(llama_cpp, "llama.cpp") + _ensure_import_path(llama_cpp / "gguf-py", "llama.cpp gguf-py") + os.environ.setdefault("NO_LOCAL_GGUF", "1") + import gguf + from conversion import ModelBase, ModelType, get_model_architecture, get_model_class + + return gguf, ModelBase, ModelType, get_model_architecture, get_model_class + + +def import_gguf(llama_cpp: Path | None = None): + root = Path(llama_cpp) if llama_cpp else DEFAULT_LLAMA_CPP + gguf_path = root / "gguf-py" + if not gguf_path.is_dir(): + raise FileNotFoundError(f"llama.cpp gguf-py was not found at {gguf_path}") + entry = str(gguf_path) + if entry not in sys.path: + sys.path.insert(0, entry) + import gguf + return gguf + + +def load_learned_converter(convert_to_quant_dir: Path | None): + try: + from convert_to_quant.converters.learned_nvfp4 import LearnedNVFP4Converter + from convert_to_quant.utils.float_utils import unpack_uint4 + return LearnedNVFP4Converter, unpack_uint4 + except ImportError: + pass + + search = [] + if convert_to_quant_dir is not None: + search.append(Path(convert_to_quant_dir)) + search.append(DEFAULT_CONVERT_TO_QUANT) + last_error = None + for candidate in search: + if not candidate.exists(): + continue + _ensure_import_path(candidate, "convert_to_quant") + try: + from convert_to_quant.converters.learned_nvfp4 import LearnedNVFP4Converter + from convert_to_quant.utils.float_utils import unpack_uint4 + return LearnedNVFP4Converter, unpack_uint4 + except ImportError as exc: + last_error = exc + raise ImportError( + "convert_to_quant is not importable. Install it, or pass --convert-to-quant " + "pointing at the checkout that provides LearnedNVFP4Converter." + ) from last_error + + +def ctq_skips_key(key: str, convert_to_quant_dir: Path | None = None) -> bool: + """True when convert_to_quant's default exclusion list would leave this tensor alone.""" + try: + from convert_to_quant.constants import AVOID_KEY_NAMES + except ImportError: + load_learned_converter(convert_to_quant_dir) + from convert_to_quant.constants import AVOID_KEY_NAMES + return any(pattern in key for pattern in AVOID_KEY_NAMES) + + +def resolve_optimizer(requested: str) -> str: + if requested != "auto": + return requested + try: + import prodigyplus.prodigy_plus_schedulefree # noqa: F401 + return "prodigy" + except ImportError: + log.info( + "prodigy-plus-schedule-free is not installed; using AdamW. " + "pip install prodigy-plus-schedule-free to match convert_to_quant's default optimizer." + ) + return "adamw" + + +def _opt(args, name: str, default): + return getattr(args, name, default) + + +def resolved_lr_min(args) -> float: + """Plateau floor from ``--lr-min``. ``--lr`` is only the starting rate.""" + lr_min = getattr(args, "lr_min", None) + if lr_min is None: + return 1e-8 + return float(lr_min) + + +class GgufLearnedNVFP4: + """LearnedNVFP4Converter plus the total scale the codes were optimized against.""" + + def __init__(self, converter_cls, **kwargs): + class _Capturing(converter_cls): + def convert(self, *args, **kw): + self._captured_total_scale = None + self._last_lr = self.lr + self._floor_stall = 0 + self._best_seen = None + self._settled = False + return super().convert(*args, **kw) + + def _learning_rate_floored(self) -> bool: + # lr_min is --lr-min, the plateau floor. --lr is only where the search starts. + return getattr(self, "_last_lr", self.lr) <= self.lr_min * 1.01 + + def _loss_has_settled(self, loss: float) -> bool: + target = float(getattr(self, "target_loss", DEFAULT_TARGET_LOSS) or 0) + return target > 0 and 0 < loss <= target + + def _check_improvement(self, current_loss: float, best_loss: float) -> bool: + # Sub-1% wiggles stop counting once the fit has settled or the + # step has reached --lr-min. Before that they are the descent. + settled = getattr(self, "_settled", False) + if self.lr_threshold > 0 and (settled or self._learning_rate_floored()): + return super()._check_improvement(current_loss, best_loss) + return current_loss < best_loss + + def _check_early_stop(self, current_loss: float, curr_lr: float, worse_loss_counter: int) -> bool: + self._last_lr = curr_lr + if 0 < current_loss < float("inf"): + best = getattr(self, "_best_seen", None) + self._best_seen = current_loss if best is None else min(best, current_loss) + best_seen = getattr(self, "_best_seen", None) + self._settled = self._loss_has_settled(best_seen if best_seen is not None else current_loss) + limit = int(getattr(self, "early_stop_floor_stall", 0) or 0) + # The packed codes are already chosen once the best loss is at + # the target. The learning rate does not change that. + if self._settled: + log.info(" Loss reached %g. Stopping early.", float(self.target_loss)) + return True + return super()._check_early_stop(current_loss, curr_lr, worse_loss_counter) + + def _compute_svd_components(self, W_float32, verbose: bool = True): + # full_matrices=True builds the square factors and then throws + # them away. The loss only uses k directions, and a thin SVD + # returns those directly. + if not self.full_matrix: + return super()._compute_svd_components(W_float32, verbose) + import math + rows, cols = W_float32.shape + max_rank = min(rows, cols) + k = min(self.max_k, max(self.min_k, int(math.floor(self.top_p * max_rank)))) + k = min(k, max_rank) + if verbose: + print(f" - Tensor shape: [{rows}, {cols}], Max rank: {max_rank}. Using k={k} components.") + print(" - Using torch.linalg.svd with full_matrices=False") + left, _, right = torch.linalg.svd(W_float32, full_matrices=False) + return left[:, :k], right[:k, :], k + + def _compute_block_scales(self, weight, per_tensor_scale, rows, cols): + fp8, total, mask = super()._compute_block_scales(weight, per_tensor_scale, rows, cols) + self._captured_total_scale = total.detach() + return fp8, total, mask + + def _optimize_nvfp4(self, *args, **kw): + codes, total = super()._optimize_nvfp4(*args, **kw) + self._captured_total_scale = total.detach() + return codes, total + + self.converter = _Capturing(**kwargs) + + def convert(self, weight: torch.Tensor): + return self.converter.convert(weight) + + +def pack_nvfp4(codes: np.ndarray, scale_bits: np.ndarray) -> np.ndarray: + """Pack E2M1 codes and UE4M3 block scales into ggml NVFP4 bytes. + + ``codes`` is ``(..., K)`` with K a multiple of 64. Each nibble is an E2M1 + code. ``scale_bits`` is ``(..., K / 16)``. A super-block is 4 scale bytes + followed by 32 code bytes, matching ``block_nvfp4``. + """ + codes = np.ascontiguousarray(codes, dtype=np.uint8) + scale_bits = np.ascontiguousarray(scale_bits, dtype=np.uint8) & np.uint8(0x7F) + if codes.shape[:-1] != scale_bits.shape[:-1]: + raise ValueError(f"code shape {codes.shape} does not match scales {scale_bits.shape}") + length = codes.shape[-1] + if length % QK_NVFP4 != 0: + raise ValueError(f"NVFP4 row length {length} is not a multiple of {QK_NVFP4}") + if scale_bits.shape[-1] * 16 != length: + raise ValueError("expected one scale per 16 values") + + prefix = codes.shape[:-1] + n_super = length // QK_NVFP4 + grouped = codes.reshape(*prefix, n_super, 4, 16) + quant_bytes = (grouped[..., :8] | (grouped[..., 8:] << np.uint8(4))).reshape(*prefix, n_super, 32) + scales = scale_bits.reshape(*prefix, n_super, 4) + return np.ascontiguousarray( + np.concatenate([scales, quant_bytes], axis=-1).reshape(*prefix, n_super * NVFP4_BLOCK_BYTES) + ) + + +def mtp_layer_tensor(name: str, n_blocks: int, n_nextn: int) -> bool: + """True for weights stored in the NextN/MTP blocks after the trunk. + + llama.cpp's normal load skips those blocks before it looks for NVFP4 + ``.scale`` sidecars, so a sidecar on one of them is never claimed and the + loader aborts with ``wrong number of tensors``. + """ + if n_nextn <= 0 or n_blocks <= 0 or not name.startswith("blk."): + return False + layer = name.split(".", 2)[1] + if not layer.isdigit(): + return False + return int(layer) >= n_blocks - n_nextn + + +def bake_packed_nvfp4(raw: np.ndarray, per_tensor: float) -> np.ndarray: + """Fold a per-tensor scale into the UE4M3 bytes of packed NVFP4 blocks.""" + raw = np.ascontiguousarray(raw, dtype=np.uint8) + if raw.size == 0 or raw.shape[-1] % NVFP4_BLOCK_BYTES != 0: + raise ValueError(f"NVFP4 packing has shape {raw.shape}, which is not groups of {NVFP4_BLOCK_BYTES} bytes") + if abs(float(per_tensor) - 1.0) <= 1e-6: + return raw + blocks = raw.reshape(*raw.shape[:-1], -1, NVFP4_BLOCK_BYTES) + scales = np.ascontiguousarray(blocks[..., :4]) + decoded = torch.from_numpy(scales).view(torch.float8_e4m3fn).float().numpy() + encoded = ue4m3_bits(decoded * np.float32(per_tensor)).reshape(scales.shape) + baked = np.array(blocks, copy=True) + baked[..., :4] = encoded + return np.ascontiguousarray(baked.reshape(raw.shape)) + + +def ue4m3_bits(scale_float: np.ndarray) -> np.ndarray: + """Encode positive scales as UE4M3 using torch's E4M3FN bit patterns. + + ggml's own encoder saturates every value from 256 to 448 down to 448. + Torch's float8 bits decode back to the right magnitude with ggml's decoder, + including the exponent-15 range that NVFP4 block scales actually use. + """ + array = np.ascontiguousarray(np.clip(scale_float, 0.0, F8_E4M3_MAX), dtype=np.float32) + bits = torch.from_numpy(array).to(torch.float8_e4m3fn).view(torch.uint8).numpy() + return np.ascontiguousarray(bits & np.uint8(0x7F)) + + +def _cuda_python_candidates() -> list[Path]: + found: list[Path] = [] + env_root = Path.home() / "anaconda3" / "envs" + if env_root.is_dir(): + for python in sorted(env_root.glob("*/bin/python")): + found.append(python) + which = shutil.which("python3") + if which: + found.append(Path(which)) + return found + + +def _python_sees_cuda(python: Path) -> bool: + if not python.is_file(): + return False + probe = subprocess.run( + [str(python), "-c", "import torch, sys; sys.exit(0 if torch.cuda.is_available() else 1)"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + return probe.returncode == 0 + + +def ensure_cuda_interpreter(device: str) -> None: + """Re-run under a CUDA torch when this interpreter cannot see the GPU.""" + wants_cuda = device == "auto" or str(device).startswith("cuda") + if not wants_cuda or torch.cuda.is_available() or os.environ.get("HF_NVFP4_REEXEC") == "1": + return + current = Path(sys.executable).resolve() + candidates = _cuda_python_candidates() + candidates.sort(key=lambda path: (0 if "envs/torch/" in path.as_posix() else 1, str(path))) + for candidate in candidates: + try: + if candidate.resolve() == current: + continue + except OSError: + continue + if not _python_sees_cuda(candidate): + continue + print( + f"This interpreter ({sys.executable}, torch {torch.__version__}) cannot use CUDA. " + f"Restarting with {candidate}", + flush=True, + ) + os.environ["HF_NVFP4_REEXEC"] = "1" + os.execv(str(candidate), [str(candidate), *sys.argv]) + if device == "auto": + return + built = torch.version.cuda or "cpu-only" + raise RuntimeError( + f"{sys.executable} has torch {torch.__version__} (CUDA {built}) and no visible GPU. " + "Pass --device cpu, or run this script with a CUDA build of torch." + ) + + +def configure_matmul(device: str) -> None: + """Full FP32 matmul. convert_to_quant does not enable TF32, and TF32 moves the SVD residual.""" + if not str(device).startswith("cuda"): + return + torch.backends.cuda.matmul.allow_tf32 = False + torch.backends.cudnn.allow_tf32 = False + torch.set_float32_matmul_precision("highest") + + +class TensorQuantizer: + def __init__(self, args, gguf_module, unpack_uint4): + self.args = args + self.gguf = gguf_module + self.unpack_uint4 = unpack_uint4 + self.nvfp4_cls = getattr(getattr(gguf_module, "quants", None), "NVFP4", None) + optimizer = resolve_optimizer(args.optimizer) + self.optimizer_name = optimizer + device = args.device + if device == "auto": + device = "cuda" if torch.cuda.is_available() else "cpu" + if str(device).startswith("cuda") and not torch.cuda.is_available(): + raise RuntimeError( + f"{sys.executable} has torch {torch.__version__} (CUDA {torch.version.cuda or 'cpu-only'}) " + "and no visible GPU." + ) + self.device = device + configure_matmul(device) + + converter_cls, _ = load_learned_converter(getattr(args, "convert_to_quant", None)) + lr_min = resolved_lr_min(args) + self.engine = GgufLearnedNVFP4( + converter_cls, + optimizer=optimizer, + num_iter=args.num_iter, + lr=args.lr, + top_p=args.top_p, + min_k=args.min_k, + max_k=args.max_k, + full_matrix=args.full_matrix, + no_learned_rounding=args.simple, + lr_schedule=args.lr_schedule, + lr_gamma=_opt(args, "lr_gamma", 0.99), + lr_patience=_opt(args, "lr_patience", DEFAULT_LR_PATIENCE), + lr_factor=_opt(args, "lr_factor", 0.95), + lr_min=lr_min, + lr_cooldown=_opt(args, "lr_cooldown", 0), + lr_threshold=_opt(args, "lr_threshold", DEFAULT_LR_THRESHOLD), + lr_shape_influence=_opt(args, "lr_shape_influence", 1.0), + early_stop_loss=args.early_stop_loss, + early_stop_lr=_opt(args, "early_stop_lr", DEFAULT_EARLY_STOP_LR), + early_stop_stall=_opt(args, "early_stop_stall", DEFAULT_EARLY_STOP_STALL), + scale_refinement_rounds=args.scale_refinement_rounds, + scale_optimization=args.scale_optimization, + use_speed=_opt(args, "use_speed", False), + block_size=16, + pad_to_16x=True, + device=device, + ) + self.engine.converter.early_stop_floor_stall = _opt( + args, "early_stop_floor_stall", DEFAULT_EARLY_STOP_FLOOR_STALL, + ) + self.engine.converter.target_loss = float(_opt(args, "target_loss", DEFAULT_TARGET_LOSS)) + self.calib_cache: dict[int, torch.Tensor] = {} + log.info( + "NVFP4 quantizer: %s on %s, %s iterations, scale mode %s, lr patience %s, " + "stop at loss %g, fp32 matmul", + "simple round-to-grid" if args.simple else f"learned rounding ({optimizer})", + device, + args.num_iter if not args.simple else 0, + args.scale_optimization, + _opt(args, "lr_patience", DEFAULT_LR_PATIENCE), + self.engine.converter.target_loss, + ) + if not args.simple: + log.info("Attention projections stay NVFP4 and are fit across every singular direction.") + + def _extract(self, weight: torch.Tensor, converted) -> tuple[np.ndarray, np.ndarray, float, np.ndarray]: + packed, _blocked, per_tensor, dequant, _extra = converted + rows, cols = weight.shape + if not torch.any(packed): + codes = np.zeros((rows, cols), dtype=np.uint8) + scales = np.zeros((rows, cols // 16), dtype=np.float32) + return codes, scales, 1.0, np.zeros((rows, cols), dtype=np.float32) + + codes_t = self.unpack_uint4(packed.detach().contiguous()) + codes = codes_t[:rows, :cols].to(dtype=torch.uint8).cpu().numpy() + total = self.engine.converter._captured_total_scale + if total is None: + raise RuntimeError("quantizer did not record a block scale") + pts = per_tensor.detach().to(device=total.device, dtype=total.dtype).reshape(-1)[0] + pts_value = float(pts.float().cpu()) + if not np.isfinite(pts_value) or pts_value <= 0.0: + raise RuntimeError(f"invalid per-tensor scale {pts_value}") + block_scale = (total / pts)[:rows, : cols // 16].float().cpu().numpy() + dequant_np = dequant.detach().float().cpu().numpy()[:rows, :cols] + return codes, block_scale.astype(np.float32, copy=False), pts_value, dequant_np + + def quantize_matrix(self, weight: np.ndarray, name: str = "") -> tuple[np.ndarray, np.ndarray, float, np.ndarray]: + tensor = torch.from_numpy(np.ascontiguousarray(weight, dtype=np.float32)) + converter = self.engine.converter + saved = None + if _tight_attention(name): + # The stock objective keeps the top fifth of the singular spectrum. + # A complete basis makes the loss see every direction this matrix has. + saved = (converter.top_p, converter.max_k, converter.full_matrix) + converter.top_p = 1.0 + converter.max_k = max(int(converter.max_k), int(min(weight.shape[-2:])), 1) + converter.full_matrix = True + log.info(" full-rank fit %s", name) + converted = None + try: + with torch.enable_grad(): + converted = self.engine.convert(tensor) + return self._extract(tensor, converted) + finally: + if saved is not None: + converter.top_p, converter.max_k, converter.full_matrix = saved + del tensor, converted + + def bake_global_scale(self, codes: np.ndarray, block_scale: np.ndarray, per_tensor: float) -> tuple[np.ndarray, np.ndarray]: + """Fold the per-tensor scale into UE4M3 block scales and keep the learned codes.""" + product = block_scale * np.float32(per_tensor) + return codes, ue4m3_bits(product) + + def runtime_dequant(self, raw: np.ndarray, per_tensor: float | None) -> np.ndarray: + if self.gguf is None: + raise RuntimeError("runtime dequant needs the gguf module") + values = self.gguf.quants.dequantize(raw, self.gguf.GGMLQuantizationType.NVFP4) + if per_tensor is not None: + values = values * np.float32(per_tensor) + return values + + def bias_correction(self, original: np.ndarray, reconstructed: np.ndarray) -> np.ndarray: + rows, cols = original.shape + samples = self.args.calib_samples + cached = self.calib_cache.get(cols) + if cached is None or cached.shape[0] != samples: + generator = torch.Generator(device="cpu") + generator.manual_seed(self.args.seed + cols) + cached = torch.randn(samples, cols, generator=generator, dtype=torch.float32) + self.calib_cache[cols] = cached + + error = torch.from_numpy(np.ascontiguousarray(original - reconstructed, dtype=np.float32)) + error = error.to(self.device) + accumulator = torch.zeros(rows, dtype=torch.float32) + batch = 256 + for start in range(0, samples, batch): + batch_x = cached[start:start + batch].to(self.device) + accumulator += (batch_x @ error.T).sum(dim=0).float().cpu() + del batch_x + del error + gc.collect() + return (accumulator / samples).numpy().astype(np.float32, copy=False) + + +# Feed-forward matmuls in these graphs pass a null scale, so a sidecar +# `.scale` tensor is loaded and then ignored. Gemma 4 and Gemma embedding 2 +# do apply it. See llama.cpp src/models/gemma3.cpp build_ffn. +_FFN_SCALE_IGNORED = frozenset({ + "gemma", + "gemma2", + "gemma3", + "gemma-embedding", +}) + +# gemma3n multiplies the NVFP4 scale only on the attention output and the LM head. +_GEMMA3N_SCALE_USED = frozenset({"attn_output", "output"}) + + +def _lfm_ignores_nvfp4_scale(name: str) -> bool: + """LFM2 and LFM3 multiply a sidecar scale on Q, K, V, and the attention output. + + The dense feed-forward, the per-head attention gate, and the short-conv + projections are called with a null scale. A short-conv sidecar is also + never loaded, so writing one makes the GGUF fail to open. + """ + if _is_ffn_matmul(name): + return True + if ".shortconv." in name: + return True + return _weight_leaf(name) == "attn_gate" + + +def _architecture_name(architecture: str | bytes | None) -> str: + if architecture is None: + return "" + if isinstance(architecture, bytes): + architecture = architecture.decode("utf-8", "replace") + return str(architecture).strip().lower() + + +def _is_spark2_5(architecture: str | bytes | None) -> bool: + """True for the spark2_5 GGUF arch and the Spark2_5ForCausalLM class name. + + Muse Spark and DeepSeek DSpark stay out of this profile. + """ + name = _architecture_name(architecture).replace("-", "_").replace(".", "_") + head = name.split("for", 1)[0] + return head in {"spark2_5", "spark25"} + + +class QuantProfile: + """Tensors an architecture leaves in the source float type unless --exclude is set.""" + + def __init__(self, arch: str, skip: str, label: str): + self.arch = arch + self.skip = skip + self.label = label + + +# GGUF names are blk.N.attn_gate.weight. The Hugging Face checkpoint calls the +# same matrix self_attn.g_proj.weight. +_SPARK_PROFILE = QuantProfile( + "spark2_5", + r"(?:^|\.)(?:attn_gate|g_proj)\.weight$", + "attn_gate.weight", +) + + +def quant_profile(architecture: str | bytes | None) -> QuantProfile | None: + if _is_spark2_5(architecture): + return _SPARK_PROFILE + return None + + +def _tight_attention(name: str) -> bool: + return bool(name) and name.endswith(_TIGHT_ATTENTION) + + +def looks_like_spark(names) -> bool: + """True when a weight list has Spark's fused QKV and its attention gate.""" + has_qkv = False + has_gate = False + for name in names: + if name.endswith("q_k_v_proj.weight"): + has_qkv = True + if name.endswith("g_proj.weight") or name.endswith("attn_gate.weight"): + has_gate = True + if has_qkv and has_gate: + return True + return False + + +def bind_exclude(args, architecture: str | bytes | None) -> None: + """Compile args.exclude_re, using the architecture profile when --exclude is absent.""" + if getattr(args, "_exclude_bound", False): + return + args._exclude_bound = True + profile = quant_profile(architecture) + user = getattr(args, "exclude", None) + pattern = user + if profile is not None and not user: + pattern = profile.skip + args.exclude = pattern + log.info("%s profile: leaving %s in the source float type.", profile.arch, profile.label) + elif profile is not None: + log.info( + "%s profile: --exclude replaces the default skip of %s.", + profile.arch, + profile.label, + ) + args.exclude_re = re.compile(pattern) if pattern else None + + +def quant_settings(args) -> dict: + """Flags that change the bytes in a checkpoint. A mismatch forces --fresh.""" + return { + "version": QUANT_VERSION, + "simple": bool(getattr(args, "simple", False)), + "optimizer": args.optimizer, + "num_iter": args.num_iter, + "lr": args.lr, + "lr_schedule": args.lr_schedule, + "scale_optimization": args.scale_optimization, + "scale_refinement_rounds": args.scale_refinement_rounds, + "full_matrix": bool(getattr(args, "full_matrix", False)), + "top_p": args.top_p, + "min_k": args.min_k, + "max_k": args.max_k, + "early_stop_loss": args.early_stop_loss, + "early_stop_stall": _opt(args, "early_stop_stall", DEFAULT_EARLY_STOP_STALL), + "lr_threshold": _opt(args, "lr_threshold", DEFAULT_LR_THRESHOLD), + "target_loss": float(_opt(args, "target_loss", DEFAULT_TARGET_LOSS)), + "calib_samples": args.calib_samples, + "bias_correction": not getattr(args, "no_bias_correction", False), + "seed": args.seed, + "quantize_output": bool(getattr(args, "quantize_output", False)), + "exclude": getattr(args, "exclude", None), + "lr_patience": _opt(args, "lr_patience", DEFAULT_LR_PATIENCE), + "early_stop_lr": _opt(args, "early_stop_lr", DEFAULT_EARLY_STOP_LR), + } + + +def require_quant_settings(directory: Path, args) -> None: + path = directory / "settings.json" + payload = quant_settings(args) + if path.is_file(): + previous = json.loads(path.read_text(encoding="utf-8")) + if previous != payload: + raise SystemExit( + f"Checkpoint settings in {path} do not match this run. " + "Pass --fresh to recompute, or rerun with the same quantizer flags." + ) + return + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + + +def _weight_leaf(name: str) -> str: + base = name[: -len(".weight")] if name.endswith(".weight") else name + return base.rsplit(".", 1)[-1] + + +def _is_ffn_matmul(name: str) -> bool: + leaf = _weight_leaf(name) + if leaf.startswith("ffn_gate_inp"): + return False + return leaf.startswith(("ffn_up", "ffn_gate", "ffn_down")) + + +def must_bake_nvfp4_scale(architecture: str | bytes | None, name: str, has_ffn_bias: bool = False) -> bool: + """Fold the per-tensor scale into the UE4M3 block scales. + + Most llama.cpp graphs multiply ``name.scale`` after the NVFP4 matmul. + Gemma 3's feed-forward graph does not, which leaves those layers far too + small and the sampler stuck on one token. A dense FFN that also has a + bias cannot carry an external scale: llama.cpp adds the bias first and + aborts if both are present. + """ + if has_ffn_bias and _is_ffn_matmul(name): + return True + arch = _architecture_name(architecture) + leaf = _weight_leaf(name) + if arch == "gemma4-assistant": + return True + if arch in {"lfm2", "lfm2moe", "lfm3"} and _lfm_ignores_nvfp4_scale(name): + return True + if arch == "gemma3n": + return leaf not in _GEMMA3N_SCALE_USED + if arch in _FFN_SCALE_IGNORED and _is_ffn_matmul(name): + return True + if arch in {"gemma4", "gemma-embedding2"} and ( + leaf in {"inp_gate", "proj", "per_layer_model_proj"} or leaf.startswith("ffn_gate_inp") + ): + return True + # spark2-5.cpp passes null scales for the feed-forward, the attention + # gate, and the LM head. QKV and attn_output do multiply their sidecar. + if _is_spark2_5(arch) and (_is_ffn_matmul(name) or leaf in {"attn_gate", "output"}): + return True + return False + + +def preserve_scale_tensor(architecture: str | bytes | None, weight_name: str) -> bool: + """Keep an existing ``.scale`` tensor that is not the NVFP4 weight scale. + + Gemma 4 multiplies ``ffn_gate_inp.scale`` into the router activation + before the matmul. The NVFP4 weight scale is baked instead. + """ + arch = _architecture_name(architecture) + if arch not in {"gemma4", "gemma-embedding2"}: + return False + return _weight_leaf(weight_name).startswith("ffn_gate_inp") + + +def scale_bake_notice(architecture: str | bytes | None) -> str | None: + arch = _architecture_name(architecture) + if arch in _FFN_SCALE_IGNORED: + return ( + f"{arch} feed-forward layers do not multiply an NVFP4 .scale tensor. " + "Those per-tensor scales are baked into the block scales." + ) + if arch == "gemma3n": + return ( + "gemma3n multiplies an NVFP4 .scale only on attn_output and output. " + "Every other matrix has its per-tensor scale baked into the block scales." + ) + if arch in {"lfm2", "lfm2moe", "lfm3"}: + return ( + f"{arch} does not multiply an NVFP4 .scale on the feed-forward, " + "the attention gate, or the short convolutions. " + "Those per-tensor scales are baked into the block scales." + ) + if arch == "gemma4-assistant": + return ( + "gemma4-assistant does not multiply NVFP4 .scale tensors. " + "Per-tensor scales are baked into the block scales." + ) + if arch in {"gemma4", "gemma-embedding2"}: + return ( + f"{arch} does not multiply an NVFP4 weight scale on the router, " + "the per-layer projections, or per_layer_model_proj. " + "Those scales are baked into the block scales." + ) + if _is_spark2_5(arch): + return ( + "spark2_5 does not multiply an NVFP4 .scale on the feed-forward, " + "the attention gate, or the output. Those per-tensor scales are baked " + "into the block scales." + ) + return None + + +def check_scale_policy() -> None: + """Guard the architecture table against the llama.cpp graphs it was read from.""" + bake = must_bake_nvfp4_scale + assert bake("gemma3", "blk.0.ffn_up.weight") + assert bake("gemma3", "blk.0.ffn_gate.weight") + assert bake("gemma3", "blk.0.ffn_down.weight") + assert not bake("gemma3", "blk.0.attn_q.weight") + assert not bake("gemma3", "blk.0.attn_output.weight") + assert not bake("gemma3", "output.weight") + assert bake("gemma", "blk.0.ffn_up.weight") + assert bake("gemma2", "blk.0.ffn_down.weight") + assert bake(b"gemma3", "blk.0.ffn_up.weight") + assert bake("llama", "blk.0.ffn_up.weight", has_ffn_bias=True) + assert not bake("llama", "blk.0.ffn_up.weight") + assert not bake("llama", "blk.0.attn_q.weight") + assert bake("gemma3n", "blk.0.attn_q.weight") + assert bake("gemma3n", "blk.0.ffn_up.weight") + assert bake("gemma3n", "blk.0.laurel_l.weight") + assert not bake("gemma3n", "blk.0.attn_output.weight") + assert not bake("gemma3n", "output.weight") + assert not bake("gemma4", "blk.0.ffn_up.weight") + assert not bake("gemma4", "blk.0.attn_q.weight") + assert bake("gemma4", "blk.0.ffn_gate_inp.weight") + assert bake("gemma4", "blk.0.inp_gate.weight") + assert bake("gemma4", "blk.0.proj.weight") + assert bake("gemma4", "per_layer_model_proj.weight") + assert bake("gemma-embedding2", "per_layer_model_proj.weight") + assert not bake("llama", "per_layer_model_proj.weight") + assert preserve_scale_tensor("gemma4", "blk.0.ffn_gate_inp.weight") + assert not preserve_scale_tensor("gemma3", "blk.0.ffn_up.weight") + assert not preserve_scale_tensor("gemma4", "blk.0.ffn_up.weight") + assert bake("gemma4-assistant", "blk.0.attn_q.weight") + assert bake("gemma4-assistant", "output.weight") + assert bake("lfm2", "blk.0.ffn_up.weight") + assert bake("lfm2", "blk.0.ffn_gate.weight") + assert bake("lfm2", "blk.0.ffn_down.weight") + assert bake("lfm2", "blk.0.shortconv.in_proj.weight") + assert bake("lfm2", "blk.0.shortconv.out_proj.weight") + assert not bake("lfm2", "blk.0.attn_q.weight") + assert not bake("lfm2", "blk.0.attn_output.weight") + assert bake("lfm2moe", "blk.0.shortconv.in_proj.weight") + assert bake("lfm3", "blk.0.ffn_up.weight") + assert bake("lfm3", "blk.0.ffn_gate.weight") + assert bake("lfm3", "blk.0.ffn_down.weight") + assert bake("lfm3", "blk.4.attn_gate.weight") + assert bake("lfm3", "blk.0.shortconv.in_proj.weight") + assert bake("lfm3", "blk.0.shortconv.out_proj.weight") + assert bake("lfm3", "blk.0.shortconv.conv.weight") + assert not bake("lfm3", "blk.2.attn_q.weight") + assert not bake("lfm3", "blk.2.attn_k.weight") + assert not bake("lfm3", "blk.2.attn_v.weight") + assert not bake("lfm3", "blk.2.attn_output.weight") + assert not bake("lfm3", "output.weight") + assert scale_bake_notice("lfm3") + assert not bake("gemma-embedding2", "blk.0.ffn_up.weight") + assert bake("gemma-embedding2", "blk.0.inp_gate.weight") + assert scale_bake_notice("gemma3") + assert scale_bake_notice("llama") is None + assert bake("spark2_5", "blk.0.ffn_up.weight") + assert bake("spark2_5", "blk.0.ffn_gate.weight") + assert bake("spark2_5", "blk.0.ffn_down.weight") + assert bake("spark2_5", "blk.4.attn_gate.weight") + assert bake("spark2_5", "output.weight") + assert not bake("spark2_5", "blk.0.attn_qkv.weight") + assert not bake("spark2_5", "blk.0.attn_output.weight") + assert not bake("spark2_5", "output_norm.weight") + assert bake("Spark2_5ForCausalLM", "blk.3.ffn_up.weight") + assert bake("spark25", "blk.0.ffn_down.weight") + assert not bake("muse-spark", "blk.0.ffn_up.weight") + assert not bake("dspark", "output.weight") + assert scale_bake_notice("spark2_5") + spark = quant_profile("Spark2_5ForCausalLM") + assert spark is not None and spark.arch == "spark2_5" + spark_skip = re.compile(spark.skip) + assert spark_skip.search("blk.12.attn_gate.weight") + assert spark_skip.search("model.layers.0.self_attn.g_proj.weight") + assert not spark_skip.search("blk.0.attn_output.weight") + assert not spark_skip.search("blk.0.ffn_gate.weight") + assert not spark_skip.search("blk.0.attn_qkv.weight") + assert not spark_skip.search("model.layers.0.self_attn.attn_gate_proj.weight") + assert quant_profile("llama") is None + assert quant_profile("muse-spark") is None + assert quant_profile("dspark") is None + kept = argparse.Namespace(exclude=None) + bind_exclude(kept, "spark2_5") + assert kept.exclude_re.search("blk.1.attn_gate.weight") + replaced = argparse.Namespace(exclude=r"output\.weight") + bind_exclude(replaced, "spark2_5") + assert replaced.exclude == r"output\.weight" + assert not replaced.exclude_re.search("blk.1.attn_gate.weight") + assert looks_like_spark(["model.layers.0.self_attn.q_k_v_proj.weight", "model.layers.0.self_attn.g_proj.weight"]) + assert not looks_like_spark(["blk.0.attn_qkv.weight", "blk.0.attn_gate.weight"]) + assert _tight_attention("blk.0.attn_output.weight") + assert _tight_attention("blk.0.attn_qkv.weight") + assert _tight_attention("model.layers.0.self_attn.o_proj.weight") + assert _tight_attention("model.layers.0.self_attn.q_k_v_proj.weight") + assert not _tight_attention("blk.0.ffn_down.weight") + assert not _tight_attention("blk.0.attn_gate.weight") + assert not _tight_attention("") + + +def quantize_and_pack( + quantizer: TensorQuantizer, weight: np.ndarray, bake: bool, name: str = "", +) -> tuple[np.ndarray, np.ndarray | None]: + """Quantize one matrix, or a stack of expert matrices, into ggml NVFP4 bytes.""" + if weight.ndim == 2: + codes, block_scale, per_tensor, _dequant = quantizer.quantize_matrix(weight, name) + return _pack_matrix(quantizer, codes, block_scale, per_tensor, bake) + raws = [] + scales = [] + for index in range(weight.shape[0]): + codes, block_scale, per_tensor, _dequant = quantizer.quantize_matrix(weight[index], name) + raw, scale = _pack_matrix(quantizer, codes, block_scale, per_tensor, bake) + raws.append(raw) + scales.append(1.0 if scale is None else float(scale[0])) + stacked = np.stack(raws, axis=0) + return stacked, None if bake else np.asarray(scales, dtype=np.float32) + + +def _pack_matrix(quantizer: TensorQuantizer, codes, block_scale, per_tensor: float, bake: bool): + if bake: + baked_codes, bits = quantizer.bake_global_scale(codes, block_scale, per_tensor) + return pack_nvfp4(baked_codes, bits), None + raw = pack_nvfp4(codes, ue4m3_bits(block_scale)) + return raw, np.asarray([per_tensor], dtype=np.float32) + + +def report_reconstruction(quantizer: TensorQuantizer, weight: np.ndarray, raw: np.ndarray, scale: np.ndarray | None) -> None: + sample_w = weight[0] if weight.ndim == 3 else weight + sample_raw = raw[0] if raw.ndim == 3 else raw + per_tensor = None if scale is None else float(np.reshape(scale, -1)[0]) + reconstructed = quantizer.runtime_dequant(sample_raw, per_tensor) + error = sample_w.astype(np.float32) - reconstructed + denom = float(np.max(np.abs(sample_w))) + 1e-8 + rms = float(np.sqrt(np.mean(np.square(sample_w)))) + 1e-12 + log.info( + " rel-max %.3e rmse %.3e rmse/std %.4f scale %s", + float(np.max(np.abs(error))) / denom, + float(np.sqrt(np.mean(np.square(error)))), + float(np.sqrt(np.mean(np.square(error)))) / rms, + "baked" if scale is None else f"{per_tensor:.4e}", + ) + + +class ShapeView: + def __init__(self, shape: tuple[int, ...]): + self.shape = shape + self.ndim = len(shape) + + +def eligible_weight(name: str, shape: tuple[int, ...], args, gguf_module) -> bool: + """Llama.cpp weight that this project quantizes instead of leaving as BF16.""" + if not name.endswith(".weight"): + return False + if len(shape) not in (2, 3): + return False + if len(shape) == 3 and "exps" not in name and "expert" not in name: + return False + length = int(shape[-1]) + rows = int(shape[-2]) + if length < QK_NVFP4 or length % QK_NVFP4 != 0 or rows < 1: + return False + lowered = name.lower() + if "norm" in lowered or "token_embd" in lowered: + return False + if name == "output.weight" and not getattr(args, "quantize_output", False): + return False + exclude = getattr(args, "exclude_re", None) + if exclude is not None and exclude.search(name): + return False + return True + + +def add_quant_arguments(parser: argparse.ArgumentParser) -> None: + """Flags shared with convert_to_quant's learned NVFP4 defaults.""" + parser.add_argument("--device", default="auto", help="auto, cpu, cuda, or cuda:N") + parser.add_argument("--simple", action="store_true", help="Round onto the E2M1 grid without learned rounding") + parser.add_argument("--optimizer", default="auto", choices=("auto", "prodigy", "adamw", "radam", "original"), + help="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-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-lr", type=float, default=1.01e-8) + 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( + "--lr-min", type=float, default=1e-8, + help="Lowest learning rate. The flat-loss check starts here (default: 1e-8).", + ) + parser.add_argument("--use-speed", action="store_true", help="Enable Prodigy's use_speed") + parser.add_argument("--calib-samples", type=int, default=3072) + 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 output.weight") + parser.add_argument("--exclude", default=None, help="Regex of tensor names to leave unquantized") + parser.add_argument("--convert-to-quant", type=Path, default=None, + help="convert_to_quant checkout, if it is not installed") diff --git a/ollama_nvfp4.py b/ollama_nvfp4.py new file mode 100644 index 0000000..b496eb7 --- /dev/null +++ b/ollama_nvfp4.py @@ -0,0 +1,1838 @@ +#!/usr/bin/env python3 +"""List Ollama's sha256 model store in readable form, and quantize a model to NVFP4. + +Ollama keeps weights in blobs/sha256- and names them through JSON manifests. +`list` and `show` resolve those hashes. `list` prints a short capability +code for each model. `list --full-scan` also reads GGUF headers, which is +slower, and uses them to find encoders and capabilities that the manifest +does not name. `list --ctx-scan` reads each parameter blob and names the +models whose Modelfile sets num_ctx. `--info MODEL` runs gguf_info.py on +that model's sha256 weight blob. Arguments after the model name are passed +through, so `--info MODEL --metadata` is gguf_info.py on the blob with +`--metadata`. `export` writes the original GGUF and a Modelfile into a +directory. `quant` does the same after fitting NVFP4, and can +register the result as a new Ollama model. The Modelfile keeps the source +template, stop list, system prompt, renderer, and parser, which is what +Ollama uses for thinking and for stopping generation. + +Most Ollama blobs are already quantized (Q4_K_M, Q8_0, and so on). NVFP4 is then fit +to those dequantized weights, so it cannot recover detail the source quant discarded. +A float16 or BF16 blob is the better source. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import logging +import os +import re +import shutil +import struct +import subprocess +import sys +from dataclasses import dataclass, field +from pathlib import Path + +log = logging.getLogger("ollama_nvfp4") + +MODEL_MEDIA = "application/vnd.ollama.image.model" +CONFIG_MEDIA = "application/vnd.docker.container.image.v1+json" +TEMPLATE_MEDIA = "application/vnd.ollama.image.template" +MESSAGES_MEDIA = "application/vnd.ollama.image.messages" +ADAPTER_MEDIA = "application/vnd.ollama.image.adapter" +PROJECTOR_MEDIA = "application/vnd.ollama.image.projector" +EMBED_MEDIA = "application/vnd.ollama.image.embed" +PARAMS_MEDIA = "application/vnd.ollama.image.params" +LAYER_KIND = { + TEMPLATE_MEDIA: "TEMPLATE", + "application/vnd.ollama.image.prompt": "TEMPLATE", + "application/vnd.ollama.image.system": "SYSTEM", + "application/vnd.ollama.image.license": "LICENSE", + PARAMS_MEDIA: "PARAMETER", + MESSAGES_MEDIA: "MESSAGE", + PROJECTOR_MEDIA: "PROJECTOR", + ADAPTER_MEDIA: "ADAPTER", +} +FLOAT_TYPES = {"F32", "F16", "BF16"} + + +def default_models_dir() -> Path: + env = os.environ.get("OLLAMA_MODELS") + if env: + return Path(env) + system = Path("/models/ollama") + if (system / "blobs").is_dir(): + return system + return Path.home() / ".ollama" / "models" + + +def digest_to_name(digest: str) -> str: + return digest.replace(":", "-") if digest.startswith("sha256:") else f"sha256-{digest}" + + +def blob_path(root: Path, digest: str) -> Path: + return root / "blobs" / digest_to_name(digest) + + +def format_size(num_bytes: int) -> str: + size = float(num_bytes) + for unit in ("B", "KiB", "MiB", "GiB", "TiB"): + if size < 1024 or unit == "TiB": + return f"{int(size)} {unit}" if unit == "B" else f"{size:.1f} {unit}" + size /= 1024 + return f"{size:.1f} TiB" + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def read_json(path: Path) -> dict: + with path.open("r", encoding="utf-8") as handle: + data = json.load(handle) + if not isinstance(data, dict): + raise ValueError(f"{path} is not a JSON object") + return data + + +@dataclass +class OllamaModel: + name: str + manifest_path: Path + store: str + config_digest: str + config: dict + layers: list[dict] + model_digest: str + model_size: int + gguf_path: Path + metadata: dict = field(default_factory=dict) + runner: str = "" + manifest_format: str = "" + + @property + def family(self) -> str: + families = self.config.get("model_families") or [] + if families: + return str(families[0]) + return str(self.config.get("model_family") or "") + + @property + def params(self) -> str: + return str(self.config.get("model_type") or self.metadata.get("general.size_label") or "") + + @property + def quant(self) -> str: + if self.config.get("file_type"): + return str(self.config["file_type"]) + file_type = self.metadata.get("general.file_type") + return "" if file_type is None else str(file_type) + + @property + def context(self) -> str: + found = _context_from_metadata(self.metadata) + if found: + return found + # Ollama's metadata cache is often mode 600 and owned by root. The GGUF + # header is readable and is what /api/show uses. + return _context_from_gguf(self.gguf_path) + + +def _is_model_context_key(name: str) -> bool: + return name.endswith(".context_length") and ".rope." not in name + + +def _scalar_context(value) -> int | None: + if isinstance(value, bool) or isinstance(value, (list, dict)): + return None + try: + length = int(value) + except (TypeError, ValueError): + return None + if length <= 0: + return None + return length + + +def _context_from_metadata(metadata: dict) -> str: + for key, value in metadata.items(): + if not _is_model_context_key(key): + continue + length = _scalar_context(value) + if length is not None: + return str(length) + return "" + + +def _context_from_gguf(path: Path) -> str: + """Read only the GGUF header. GGUFReader maps the whole weight file.""" + if not path.is_file(): + return "" + scalar = {0: 1, 1: 1, 2: 2, 3: 2, 4: 4, 5: 4, 6: 4, 7: 1, 10: 8, 11: 8, 12: 8} + integer = {4: " bytes: + data = handle.read(n) + if len(data) != n: + raise ValueError("truncated GGUF header") + return data + + def read_string() -> bytes: + size = struct.unpack(" 16 * 1024 * 1024: + raise ValueError("GGUF string is too long") + return read(size) + + def skip(value_type: int) -> None: + if value_type in scalar: + read(scalar[value_type]) + return + if value_type == 8: + read_string() + return + if value_type != 9: + raise ValueError("unknown GGUF value type") + subtype = struct.unpack(" tuple[str, str]: + for store in ("manifests", "manifests-v2"): + base = root / store + try: + parts = manifest.relative_to(base).parts + except ValueError: + continue + if len(parts) < 3: + break + tag = parts[-1] + model = parts[-2] + namespace = parts[-3] + name = f"{model}:{tag}" if namespace == "library" else f"{namespace}/{model}:{tag}" + return name, store + return manifest.name, "loose" + + +def iter_manifests(root: Path): + for store in ("manifests", "manifests-v2"): + base = root / store + if not base.is_dir(): + continue + for path in sorted(base.rglob("*")): + if path.is_file(): + yield path + + +def load_models(root: Path) -> list[OllamaModel]: + models: list[OllamaModel] = [] + for manifest_path in iter_manifests(root): + try: + manifest = read_json(manifest_path) + except (OSError, json.JSONDecodeError, ValueError): + continue + layers = manifest.get("layers") or [] + model_layer = next((layer for layer in layers if layer.get("mediaType") == MODEL_MEDIA), None) + config_info = manifest.get("config") or {} + if model_layer is None or "digest" not in config_info: + continue + config_path = blob_path(root, config_info["digest"]) + try: + config = read_json(config_path) if config_path.is_file() else {} + except (OSError, json.JSONDecodeError, ValueError): + config = {} + meta_path = root / "metadata" / digest_to_name(model_layer["digest"]) + meta_file = meta_path.with_suffix(".json") + metadata = {} + if meta_file.is_file(): + try: + metadata = read_json(meta_file).get("kv") or {} + except (OSError, json.JSONDecodeError, ValueError): + metadata = {} + name, store = manifest_name(root, manifest_path) + models.append(OllamaModel( + name=name, + manifest_path=manifest_path, + store=store, + config_digest=config_info["digest"], + config=config, + layers=layers, + model_digest=model_layer["digest"], + model_size=int(model_layer.get("size") or 0), + gguf_path=blob_path(root, model_layer["digest"]), + metadata=metadata, + runner=str(manifest.get("runner") or ""), + manifest_format=str(manifest.get("format") or ""), + )) + models.sort(key=lambda item: item.name.lower()) + return models + + +def referenced_digests(root: Path) -> set[str]: + found: set[str] = set() + for manifest_path in iter_manifests(root): + try: + manifest = read_json(manifest_path) + except (OSError, json.JSONDecodeError, ValueError): + continue + config = manifest.get("config") or {} + if isinstance(config.get("digest"), str): + found.add(config["digest"]) + for layer in manifest.get("layers") or []: + if isinstance(layer.get("digest"), str): + found.add(layer["digest"]) + return found + + +def resolve_model(models: list[OllamaModel], query: str) -> OllamaModel: + exact = [model for model in models if model.name == query] + if len(exact) == 1: + return exact[0] + if len(exact) > 1: + raise SystemExit("More than one model is named " + query + ":\n" + "\n".join(model.manifest_path.as_posix() for model in exact)) + folded = query.lower() + partial = [model for model in models if folded in model.name.lower() or folded in model.model_digest.lower()] + if len(partial) == 1: + return partial[0] + if not partial: + raise SystemExit(f"No Ollama model matches {query!r}. Run list to see names.") + preview = "\n".join(f" {model.name}" for model in partial[:20]) + raise SystemExit(f"{query!r} matches {len(partial)} models:\n{preview}") + + +# Letters match Ollama's capability set. X is text because T is tool calls. +CAPABILITY_LETTERS = ( + ("completion", "X", "text"), + ("tools", "T", "tool calls"), + ("vision", "V", "vision (images and video)"), + ("audio", "A", "audio input"), + ("image", "I", "image generation"), + ("embedding", "E", "embeddings"), + ("thinking", "R", "thinking"), + ("insert", "M", "infill (fill-in-the-middle)"), + ("decision", "D", "decision (classification, not chat)"), + ("speech", "S", "speech output"), +) +# Families and architectures that always need a vision encoder. Gemma 3 is +# not here: its 270M and 1B builds are text, and the larger ones are not. +_VISION_IDS = { + "qwen2vl", "qwen25vl", "qwen2.5vl", "qwen3vl", "qwen3vlmoe", + "llama4", "mllama", "llava", "bakllava", "moondream", + "minicpm-v", "minicpmv", "mistral3", "pixtral", "internvl", + "cogvlm", "deepseek2-ocr", "glmocr", "glm-ocr", "gemma3n", + "hunyuan_vl", "paddleocr", "sensenova_u1", "llama3.2-vision", +} +_EMBED_IDS = { + "nomic-bert", "nomic-bert-moe", "modern-bert", "neo-bert", + "jina-bert-v2", "jina-bert-v3", "eurobert", + "gemma-embedding", "gemma-embedding2", "llama-embed", "pangu-embedded", +} +_SPEECH_ARCH = {"qwen3tts", "pockettts", "talkie", "wavtokenizer-dec"} +_THINKING_PARSERS = { + "qwen3-thinking", "qwen3-vl-thinking", "deepseek3", "gemma4", + "lfm2-thinking", "olmo3-think", "harmony", +} +_TOOL_PARSERS = {"qwen3-coder", "functiongemma", "harmony"} +_AUDIO_PROJECTORS = { + "qwen2a", "qwen3a", "glma", "voxtral", "voxtral_rt", "meralion", + "lfm2a", "musicflamingo", "granite_speech", "mimo_audio", "parakeet", + "dots3note_a", "d1omni_a", "muse-spark-audio", "ultravox", + "qwen2.5o", "nemotron_omni", +} +_SPEECH_PROJECTORS = { + "qwen3tts_spkenc", "qwen3tts_gen", "pockettts_spkenc", "pockettts_gen", "soprano", +} +_VISION_PROJECTORS = { + "gemma3", "gemma3nv", "gemma3na", "gemma4v", "gemma4a", "gemma4uv", "gemma4ua", + "phi4", "idefics3", "pixtral", "llama4", "qwen2vl_merger", "qwen2.5vl_merger", + "exaone4_5", "qwen3vl_merger", "ling3vl", "step3vl", "internvl", "lfm2", + "lfm3vl", "kimivl", "paddleocr", "kimik25", "lightonocr", "cogvlm", + "janus_pro", "dots_ocr", "dots3note_v", "deepseekocr", "deepseekocr2", + "deepseek4v", "deepseek41v", "glm4v", "glm5v", "youtuvl", "nemotron_v2_vl", + "nemotron_omni", "qwen2.5o", "hunyuanvl", "minimax_m3", "minicpmv4_6", + "minicpmv4_7", "minicpm_ocr_1_0", "mimovl", "granite4_vision", "muse-glimmer", + "cohere2v", "d1omni_v", +} +_TEMPLATE_MEDIAS = { + TEMPLATE_MEDIA, + "application/vnd.ollama.image.prompt", +} +_GEMMA3_VISION_SIZE = re.compile(r"(^|[^0-9.])(4(?:\.3)?|12|27)\s*b\b") +# Shared chat templates often contain an image or audio branch even for a text +# checkpoint. A real requirement comes from the name, the architecture, or a +# projector the manifest actually lists. +_NAME_VISION = re.compile(r"(?:^|[^a-z0-9])(?:vl|vision|ocr)(?:[^a-z0-9]|$)") +_NAME_AUDIO = re.compile(r"(?:^|[^a-z0-9])audio(?:[^a-z0-9]|$)") +_GGUF_CACHE: dict[tuple[str, int, int], "GGUFProfile"] = {} + + +@dataclass +class GGUFProfile: + context: str = "" + architecture: str = "" + general_name: str = "" + general_type: str = "" + pooling: bool = False + decision: bool = False + vision_blocks: bool = False + audio_blocks: bool = False + has_vision_encoder: bool | None = None + has_audio_encoder: bool | None = None + projector_type: str = "" + image_tokens: bool = False + video_tokens: bool = False + audio_tokens: bool = False + templates: list[str] = field(default_factory=list) + vision_tensors: bool = False + audio_tensors: bool = False + speech_tensors: bool = False + readable: bool = False + + +@dataclass +class CapabilityReport: + marks: list[tuple[str, str, bool]] = field(default_factory=list) + notes: list[str] = field(default_factory=list) + projector: str = "-" + context: str = "" + + def letters(self) -> str: + return " ".join(letter if ok else f"{letter}!" for letter, _label, ok in self.marks) + + def column(self) -> str: + text = self.letters() + if self.notes: + return f"{text} {'; '.join(self.notes)}" if text else "; ".join(self.notes) + return text + + def labels(self) -> str: + rendered = [] + for _letter, label, ok in self.marks: + rendered.append(label if ok else f"{label}, weights missing") + return ", ".join(rendered) + + +def _gguf_cache_key(path: Path) -> tuple[str, int, int] | None: + try: + stat = path.stat() + except OSError: + return None + return (str(path), stat.st_mtime_ns, stat.st_size) + + +def _note_gguf_key(profile: GGUFProfile, key: str, value) -> None: + low = key.lower() + if low == "general.architecture" and isinstance(value, str): + profile.architecture = value + elif low == "general.name" and isinstance(value, str): + profile.general_name = value + elif low == "general.type" and isinstance(value, str): + profile.general_type = value + elif low.endswith("pooling_type"): + profile.pooling = True + elif low == "decision.type" or low.endswith(".decision.type"): + profile.decision = isinstance(value, str) and bool(value.strip()) + elif low.endswith("vision.block_count"): + profile.vision_blocks = True + elif low.endswith("audio.block_count"): + profile.audio_blocks = True + elif low.endswith("has_vision_encoder") and isinstance(value, int): + profile.has_vision_encoder = value != 0 + elif low.endswith("has_audio_encoder") and isinstance(value, int): + profile.has_audio_encoder = value != 0 + elif low.endswith("projector_type") and isinstance(value, str): + profile.projector_type = value + elif low.endswith("image_token_id"): + profile.image_tokens = True + elif low.endswith("video_token_id"): + profile.video_tokens = True + elif low.endswith("audio_token_id"): + profile.audio_tokens = True + elif "chat_template" in low and isinstance(value, str) and value: + profile.templates.append(value[:500_000]) + elif _is_model_context_key(key): + length = _scalar_context(value) + if length is not None and not profile.context: + profile.context = str(length) + + +def _note_tensor_name(profile: GGUFProfile, name: str) -> None: + low = name.lower() + head, _, rest = low.partition(".") + if head in {"v", "vis", "vision", "mm", "mmproj"}: + profile.vision_tensors = True + elif head in {"audio", "gen_audio"} or low.startswith("a.blk"): + profile.audio_tensors = True + elif head in {"tts", "spk", "speaker", "codec"} or rest.startswith("spk"): + profile.speech_tensors = True + + +def _scan_gguf(path: Path) -> GGUFProfile: + """Read a GGUF header for capability metadata. Does not map the weights.""" + key = _gguf_cache_key(path) + if key is None: + return GGUFProfile() + cached = _GGUF_CACHE.get(key) + if cached is not None: + return cached + profile = _scan_gguf_uncached(path) + _GGUF_CACHE[key] = profile + return profile + + +def _scan_gguf_uncached(path: Path) -> GGUFProfile: + profile = GGUFProfile() + scalar = {0: 1, 1: 1, 2: 2, 3: 2, 4: 4, 5: 4, 6: 4, 7: 1, 10: 8, 11: 8, 12: 8} + integer = {0: " bytes: + data = handle.read(n) + if len(data) != n: + raise ValueError("truncated GGUF header") + return data + + def read_string() -> bytes: + size = struct.unpack(" 16 * 1024 * 1024: + raise ValueError("GGUF string is too long") + return read(size) + + def read_scalar(value_type: int): + if value_type == 8: + return read_string().decode("utf-8", "replace") + if value_type not in scalar: + raise ValueError("unknown GGUF value type") + raw = read(scalar[value_type]) + if value_type in integer: + return struct.unpack(integer[value_type], raw)[0] + return None + + def skip_array(subtype: int, count: int, keep_strings: bool) -> None: + if subtype in scalar: + handle.seek(scalar[subtype] * count, os.SEEK_CUR) + return + if subtype != 8: + raise ValueError("unsupported GGUF array") + kept = 0 + for _ in range(count): + text = read_string() + if keep_strings and kept < 4: + profile.templates.append(text.decode("utf-8", "replace")[:500_000]) + kept += 1 + + try: + with path.open("rb") as handle: + magic, version = struct.unpack(" set[str]: + raw = config.get("capabilities") or [] + if isinstance(raw, str): + raw = [raw] + if not isinstance(raw, list): + return set() + return {str(item).strip().lower() for item in raw if str(item).strip()} + + +def _model_ids(model: OllamaModel, main: GGUFProfile) -> set[str]: + found = set() + if model.family: + found.add(model.family.lower()) + for item in model.config.get("model_families") or []: + found.add(str(item).lower()) + if main.architecture: + found.add(main.architecture.lower()) + return found + + +def _template_capabilities(text: str) -> set[str]: + if not text: + return set() + low = text.lower() + found: set[str] = set() + if "tools" in low or "tool_call" in low or ".tools" in low: + found.add("tools") + if "" in low or "" in low or "enable_thinking" in low or "<|think|>" in low: + found.add("thinking") + if ".suffix" in low: + found.add("insert") + return found + + +def _name_modalities(text: str) -> set[str]: + """Vision or audio claimed by the model name, not by a shared chat template.""" + low = text.lower() + found: set[str] = set() + if _NAME_VISION.search(low): + found.add("vision") + if _NAME_AUDIO.search(low): + found.add("audio") + return found + + +def _blob_text(path: Path, limit: int = 2_000_000) -> str: + if not path.is_file(): + return "" + try: + with path.open("r", encoding="utf-8", errors="replace") as handle: + return handle.read(limit) + except OSError: + return "" + + +def _looks_like_embedding(model: OllamaModel) -> bool: + if _model_ids(model, GGUFProfile()) & _EMBED_IDS: + return True + return "embed" in f"{model.name} {model.family}".lower() + + +def _gemma3_expects_vision(model: OllamaModel, main: GGUFProfile, ids: set[str]) -> bool: + if "gemma3n" in ids: + return False + if "gemma3" not in ids and "gemma3" not in model.name.lower(): + return False + text = " ".join((model.params, model.name, main.general_name)) + return _GEMMA3_VISION_SIZE.search(text.lower()) is not None + + +def _projector_modes(profile: GGUFProfile) -> tuple[bool, bool, bool]: + """Return vision, audio, speech for one projector header.""" + ptype = (profile.projector_type or "").lower() + speech = ptype in _SPEECH_PROJECTORS or profile.speech_tensors + audio = bool( + profile.has_audio_encoder + or profile.audio_blocks + or profile.audio_tensors + or profile.audio_tokens + or ptype in _AUDIO_PROJECTORS + ) + if ptype == "gemma3nv": + audio = False + if profile.has_vision_encoder is True: + vision = True + elif profile.has_vision_encoder is False: + vision = False + elif profile.vision_blocks or profile.vision_tensors or profile.image_tokens or profile.video_tokens: + vision = True + elif ptype in _VISION_PROJECTORS: + vision = True + elif audio or speech: + vision = False + else: + vision = True + return vision, audio, speech + + +def assess_capabilities( + model: OllamaModel, + *, + full: bool, + weights_present: bool, + main: GGUFProfile, + projectors: list[tuple[bool, GGUFProfile | None]], + templates: list[str], +) -> CapabilityReport: + """Decide which capabilities a model has, and which are missing their weights. + + `full` is the GGUF header scan. Without it, only the manifest, config, + parser, and small template blobs are used, so a packed-in encoder stays + invisible and a missing sidecar is reported only when the manifest names it. + """ + explicit = _config_capabilities(model.config) + ids = _model_ids(model, main if full else GGUFProfile()) + scanned = full and main.readable + present_projectors = [(ok, profile) for ok, profile in projectors if ok and profile is not None] + missing_projector = any(not ok for ok, _profile in projectors) + readable_modes = [_projector_modes(profile) for _ok, profile in present_projectors if profile.readable] + # A projector file that is on disk but unreadable still counts as present. + opaque_projector = any(ok and (profile is None or not profile.readable) for ok, profile in projectors) + + wanted: set[str] = set(explicit) + template_found: set[str] = set() + for text in templates: + template_found |= _template_capabilities(text) + if scanned: + for text in main.templates: + template_found |= _template_capabilities(text) + wanted |= template_found + wanted |= _name_modalities(model.name) + if scanned: + wanted |= _name_modalities(main.general_name) + + parser = str(model.config.get("parser") or "").lower() + renderer = str(model.config.get("renderer") or "").lower() + if parser in _TOOL_PARSERS or renderer in _TOOL_PARSERS: + wanted.add("tools") + if parser in _THINKING_PARSERS or renderer in _THINKING_PARSERS or ids & {"gptoss", "gpt-oss"}: + wanted.add("thinking") + + embed = "embedding" in explicit or (scanned and main.pooling) or (not scanned and _looks_like_embedding(model)) + decision = "decision" in explicit or (scanned and main.decision) + if embed: + wanted.add("embedding") + if decision: + wanted.add("decision") + if "completion" in explicit or (not embed and not decision): + wanted.add("completion") + + vision_inline = scanned and main.vision_tensors + audio_inline = scanned and main.audio_tensors + speech_inline = scanned and main.speech_tensors + projector_vision = any(vision for vision, _audio, _speech in readable_modes) or opaque_projector + projector_audio = any(audio for _vision, audio, _speech in readable_modes) + projector_speech = any(speech for _vision, _audio, speech in readable_modes) + + if projector_vision or missing_projector: + wanted.add("vision") + if scanned and (main.vision_blocks or main.image_tokens or main.video_tokens or vision_inline): + wanted.add("vision") + if scanned and (ids & _VISION_IDS or _gemma3_expects_vision(model, main, ids)): + wanted.add("vision") + if projector_audio or (scanned and (main.audio_blocks or main.audio_tokens or audio_inline)): + wanted.add("audio") + if "image" in explicit or (scanned and main.general_type.lower() in {"image", "flux", "sdxl"}): + wanted.add("image") + if projector_speech or speech_inline or (scanned and main.architecture.lower() in _SPEECH_ARCH): + wanted.add("speech") + if "speech" in explicit: + wanted.add("speech") + + if decision: + wanted.difference_update({"completion", "tools", "thinking", "insert"}) + + # Audio sidecars are separate files. A name that says audio, with no projector + # on disk, is missing its encoder even before the GGUF header is read. + # Vision is often packed into the model file, so a vision name stays + # unmarked until --full-scan looks for those tensors. + name_claims_audio = "audio" in _name_modalities(model.name) or ( + scanned and "audio" in _name_modalities(main.general_name) + ) + + have = set(wanted) + notes: list[str] = [] + if not weights_present: + have.clear() + notes.append("model blob missing") + else: + # Without a header scan, declared vision can be packed into the model + # file, so it is left unmarked. A projector blob the manifest names and + # cannot find is broken either way. A model whose name says audio is + # broken when that sidecar is absent, because the audio encoder is not + # packed into the language file. + if "vision" in wanted and not (vision_inline or projector_vision): + if scanned or missing_projector: + have.discard("vision") + if missing_projector: + notes.append("mmproj blob missing") + elif main.vision_blocks and not main.vision_tensors: + notes.append("vision encoder missing from the model file") + else: + notes.append("no mmproj") + if "audio" in wanted and not (audio_inline or projector_audio): + if scanned or missing_projector or name_claims_audio: + have.discard("audio") + if missing_projector and "mmproj blob missing" not in notes: + notes.append("mmproj blob missing") + elif scanned and main.audio_blocks and not main.audio_tensors: + notes.append("audio encoder missing from the model file") + elif "no mmproj" not in notes and "mmproj blob missing" not in notes: + notes.append("no audio mmproj") + if "speech" in wanted and not (speech_inline or projector_speech or opaque_projector): + needs_sidecar = scanned and main.architecture.lower() in _SPEECH_ARCH and not speech_inline + if needs_sidecar or missing_projector: + have.discard("speech") + if missing_projector and "mmproj blob missing" not in notes: + notes.append("mmproj blob missing") + elif "no mmproj" not in notes and "mmproj blob missing" not in notes: + notes.append("no speech mmproj") + + broken = [name for name in ("vision", "audio", "speech") if name in wanted and name not in have] + if missing_projector and not broken and "mmproj blob missing" not in notes: + notes.append("mmproj blob missing") + + present_count = sum(1 for ok, _profile in projectors if ok) + missing_count = len(projectors) - present_count + inline = vision_inline or audio_inline or speech_inline + if missing_count and present_count: + projector = f"yes, {missing_count} MISSING" + elif missing_count: + projector = "MISSING" + elif present_count > 1: + projector = f"yes x{present_count}" + elif present_count == 1: + projector = "yes+inline" if inline else "yes" + elif inline: + projector = "inline" + else: + projector = "-" + + marks = [] + for name, letter, label in CAPABILITY_LETTERS: + if name not in wanted: + continue + marks.append((letter, label, name in have and weights_present)) + return CapabilityReport(marks=marks, notes=notes, projector=projector, context=main.context) + + +def capability_report(root: Path, model: OllamaModel, full: bool) -> CapabilityReport: + weights_present = model.gguf_path.is_file() + projectors: list[tuple[bool, GGUFProfile | None]] = [] + templates: list[str] = [] + try: + for layer in model.layers: + media = str(layer.get("mediaType") or "") + digest = str(layer.get("digest") or "") + if not digest: + continue + path = blob_path(root, digest) + if media == PROJECTOR_MEDIA: + profile = _scan_gguf(path) if full and path.is_file() else None + projectors.append((path.is_file(), profile)) + elif media in _TEMPLATE_MEDIAS: + templates.append(_blob_text(path)) + main = _scan_gguf(model.gguf_path) if full and weights_present else GGUFProfile() + return assess_capabilities( + model, + full=full, + weights_present=weights_present, + main=main, + projectors=projectors, + templates=templates, + ) + except (OSError, ValueError): + return CapabilityReport(notes=["could not read capabilities"]) + + +def _report_context(model: OllamaModel, report: CapabilityReport, full: bool) -> str: + found = _context_from_metadata(model.metadata) + if found: + return found + if full and report.context: + return report.context + return _context_from_gguf(model.gguf_path) + + +def print_capability_legend(full: bool) -> None: + print() + print("CAPS " + " ".join(f"{letter} {label}" for _name, letter, label in CAPABILITY_LETTERS)) + print("A letter with ! has that capability, but the file that implements it is absent.") + print("MMPROJ yes = projector blob, inline = encoder is inside the model file, MISSING = the manifest names a blob that is absent, - = no projector file.") + if full: + print("Each GGUF header was read. An encoder packed into the model file shows up as inline. A capability whose encoder is absent is marked !.") + else: + print("Caps here come from the manifest, config, template blobs, and the model name.") + print("A model with audio in its name is marked A! when it has no projector. Vision from the name is left unmarked until --full-scan, because that encoder is sometimes inside the model file.") + print("--full-scan reads each GGUF header, which walks the vocabulary, and can add tools, thinking, and encoders stored only in that file.") + + +def modelfile_num_ctx(root: Path, model: OllamaModel) -> tuple[str, str]: + """Read PARAMETER num_ctx from the model's parameter blob. + + The first item is the value to print: the number, '-' when it is unset, + MISSING when the blob is gone, or '?' when the blob is not JSON. + The second item is '' unless this model needs the num_ctx removed. + """ + saw_params = False + value = None + for layer in model.layers: + if layer.get("mediaType") != PARAMS_MEDIA: + continue + digest = str(layer.get("digest") or "") + if not digest: + continue + saw_params = True + path = blob_path(root, digest) + if not path.is_file(): + return "MISSING", "" + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError): + return "?", "" + if isinstance(data, dict) and "num_ctx" in data: + value = data["num_ctx"] + if not saw_params or value is None: + return "-", "" + rendered = value if isinstance(value, str) else json.dumps(value, separators=(",", ":")) + return rendered, rendered + + +def cmd_list(root: Path, full_scan: bool = False, ctx_scan: bool = False) -> None: + models = load_models(root) + if not models: + print(f"No manifests in {root}") + return + rows = [] + needs_fix: list[tuple[str, str]] = [] + for model in models: + if full_scan: + print(f"Reading {model.name}...", file=sys.stderr) + report = capability_report(root, model, full_scan) + present = "MISSING" if not model.gguf_path.is_file() else format_size(model.model_size) + num_ctx = "-" + if ctx_scan: + num_ctx, fix = modelfile_num_ctx(root, model) + if fix: + needs_fix.append((model.name, fix)) + rows.append((model, present, _report_context(model, report, full_scan), report, num_ctx)) + name_width = max(len(model.name) for model, _present, _context, _report, _num_ctx in rows) + ctx_width = max([len("CTX")] + [len(context) for _model, _present, context, _report, _num_ctx in rows]) + proj_width = max([len("MMPROJ")] + [len(report.projector) for _model, _present, _context, report, _num_ctx in rows]) + num_width = max([len("NUM_CTX")] + [len(num_ctx) for *_rest, num_ctx in rows]) if ctx_scan else 0 + header = ( + f"{'NAME':<{name_width}} {'PARAMS':<8} {'QUANT':<12} {'FAMILY':<14} " + f"{'SIZE':>10} {'CTX':<{ctx_width}} {'MMPROJ':<{proj_width}} " + ) + if ctx_scan: + header += f"{'NUM_CTX':<{num_width}} " + header += "CAPS" + print(header) + print("-" * len(header)) + for model, present, context, report, num_ctx in rows: + line = ( + f"{model.name:<{name_width}} {model.params:<8} {model.quant:<12} " + f"{model.family:<14} {present:>10} {context:<{ctx_width}} " + f"{report.projector:<{proj_width}} " + ) + if ctx_scan: + line += f"{num_ctx:<{num_width}} " + line += report.column() + print(line) + blobs = list((root / "blobs").glob("sha256-*")) if (root / "blobs").is_dir() else [] + referenced = referenced_digests(root) + orphan = [path for path in blobs if "sha256:" + path.name.removeprefix("sha256-") not in referenced] + orphan_bytes = sum(path.stat().st_size for path in orphan) + print() + print(f"{len(models)} models, {len(blobs)} blobs, {len(orphan)} blobs not referenced by a manifest ({format_size(orphan_bytes)})") + if ctx_scan: + print() + if needs_fix: + noun = "model" if len(needs_fix) == 1 else "models" + print(f"{len(needs_fix)} {noun} set num_ctx in the Modelfile and need reinstalling without it:") + for name, value in needs_fix: + print(f" {name} {value}") + else: + print("No Modelfile sets num_ctx.") + print_capability_legend(full_scan) + + +def cmd_show(root: Path, query: str) -> None: + model = resolve_model(load_models(root), query) + report = capability_report(root, model, True) + print(model.name) + print(f" store {model.store}") + print(f" manifest {model.manifest_path}") + print(f" family {model.family}") + print(f" params {model.params}") + print(f" quant {model.quant}") + print(f" context {_report_context(model, report, True) or '-'}") + print(f" caps {report.letters() or '-'}") + if report.labels(): + print(f" cap detail {report.labels()}") + if report.notes: + print(f" broken {'; '.join(report.notes)}") + print(f" projector {report.projector}") + if model.config.get("renderer"): + print(f" renderer {model.config['renderer']}") + if model.config.get("parser"): + print(f" parser {model.config['parser']}") + if model.config.get("requires"): + print(f" requires {model.config['requires']}") + architecture = model.metadata.get("general.architecture", "") + if architecture: + print(f" arch {architecture}") + for suffix in ("block_count", "embedding_length", "expert_count", "vocab_size"): + key = f"{architecture}.{suffix}" + if key in model.metadata and not isinstance(model.metadata[key], list): + print(f" {suffix:<18} {model.metadata[key]}") + print(f" weights {model.model_digest}") + print(f" file {model.gguf_path} ({format_size(model.model_size)})") + print(" layers") + for layer in model.layers: + kind = str(layer.get("mediaType", "")).removeprefix("application/vnd.ollama.image.") + digest = str(layer.get("digest", "")) + short = digest[:19] + "…" if len(digest) > 19 else digest + path = blob_path(root, digest) + state = "ok" if path.is_file() else "MISSING" + print(f" {kind:<16} {format_size(int(layer.get('size') or 0)):>10} {short} {state}") + + +def _pythonize(value): + import numpy as np + if isinstance(value, np.generic): + return value.item() + if isinstance(value, list): + return [_pythonize(item) for item in value] + return value + + +def _logical_shape(tensor) -> tuple[int, ...]: + return tuple(int(dim) for dim in tensor.shape[::-1]) + + +def _to_float(tensor, gguf_module): + import numpy as np + qtype = tensor.tensor_type + if qtype == gguf_module.GGMLQuantizationType.F32: + return np.asarray(tensor.data, dtype=np.float32) + if qtype == gguf_module.GGMLQuantizationType.F16: + return np.asarray(tensor.data, dtype=np.float32) + return np.asarray(gguf_module.quants.dequantize(tensor.data, qtype), dtype=np.float32) + + +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, ...]): + import numpy as np + 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 + raw = np.array(blob["raw"], copy=True) + scale = np.array(blob["scale"], copy=True) + return raw, None if scale.size == 0 else scale + + +def _save_checkpoint(directory: Path, name: str, shape: tuple[int, ...], raw, scale) -> None: + import numpy as np + 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), + ) + temporary.replace(path) + + +def _copy_metadata(reader, writer, gguf_module, context_length: int | None = None) -> None: + from nvfp4gguf.bundle import is_model_context_key, normalize_chat_template, require_context_length, scalar_context_length + 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": + value = int(gguf_module.LlamaFileType.MOSTLY_NVFP4) + 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 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 _source_description(tensor) -> str: + return tensor.tensor_type.name + + +def quantize_gguf(args, source: Path, output: Path, quant_args) -> None: + from nvfp4gguf import ( + TensorQuantizer, + bind_exclude, + eligible_weight, + ensure_cuda_interpreter, + import_gguf, + load_learned_converter, + must_bake_nvfp4_scale, + mtp_layer_tensor, + preserve_scale_tensor, + quantize_and_pack, + report_reconstruction, + require_quant_settings, + scale_bake_notice, + ) + gguf = import_gguf(args.llama_cpp) + + ensure_cuda_interpreter(quant_args.device) + if output.exists() and not _wants_overwrite(args): + raise SystemExit(f"{output} already exists. Pass --overwrite to replace it.") + output.parent.mkdir(parents=True, exist_ok=True) + reader = gguf.GGUFReader(source) + if reader.byte_order != "I": + raise SystemExit(f"{source} is not little-endian GGUF") + architecture = reader.fields["general.architecture"].contents() + if isinstance(architecture, bytes): + architecture = architecture.decode("utf-8", "replace") + else: + architecture = str(architecture) + if not hasattr(quant_args, "exclude"): + quant_args.exclude = None + bind_exclude(quant_args, architecture) + notice = scale_bake_notice(architecture) + if notice: + log.warning(notice) + file_type = reader.fields.get("general.file_type") + file_type_name = "" + if file_type is not None: + try: + file_type_name = gguf.LlamaFileType(int(file_type.contents())).name + except ValueError: + file_type_name = str(file_type.contents()) + float_source = file_type_name in {"ALL_F32", "MOSTLY_F16", "MOSTLY_BF16"} + if not float_source: + message = ( + f"Source file type is {file_type_name or 'quantized'}. " + "NVFP4 will be fit to the dequantized weights, so detail already discarded by the source quant stays discarded." + ) + if args.float_only: + raise SystemExit(message) + log.warning(message) + + names = {tensor.name for tensor in reader.tensors} + + def field_int(key: str) -> int: + field = reader.fields.get(key) + if field is None: + return 0 + try: + return int(field.contents()) + except (TypeError, ValueError): + return 0 + + n_blocks = field_int(f"{architecture}.block_count") + n_nextn = field_int(f"{architecture}.nextn_predict_layers") + quant_names = [] + for tensor in reader.tensors: + logical = _logical_shape(tensor) + if eligible_weight(tensor.name, logical, quant_args, gguf): + quant_names.append(tensor.name) + 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)) + + work = 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, quant_args) + _converter_cls, unpack_uint4 = load_learned_converter(quant_args.convert_to_quant) + del _converter_cls + quantizer = TensorQuantizer(quant_args, gguf, unpack_uint4) + writer = gguf.GGUFWriter(output, architecture, use_temp_file=not args.no_temp_file) + _copy_metadata(reader, writer, gguf, getattr(args, "context_length", None)) + + done = 0 + for tensor in reader.tensors: + scale_for = "" + if tensor.name.endswith(".scale") or tensor.name.endswith(".input_scale"): + stem = tensor.name.rsplit(".", 1)[0] + scale_for = stem + ".weight" + if scale_for in quant_name_set and not ( + tensor.name.endswith(".scale") and preserve_scale_tensor(architecture, scale_for) + ): + continue + if tensor.name not in quant_name_set: + writer.add_tensor(tensor.name, tensor.data, raw_dtype=tensor.tensor_type) + continue + done += 1 + logical = _logical_shape(tensor) + bias_name = tensor.name.replace(".weight", ".bias") + bake = must_bake_nvfp4_scale( + architecture, + tensor.name, + (".ffn_" in tensor.name or tensor.name.startswith("ffn_")) and 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, _source_description(tensor), + " (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) + _save_checkpoint(work, tensor.name, logical, raw, scale) + del weight + else: + raw, scale = cached + writer.add_tensor(tensor.name, raw, raw_dtype=gguf.GGMLQuantizationType.NVFP4) + if scale is not None: + writer.add_tensor(tensor.name[: -len(".weight")] + ".scale", scale) + del raw + + log.info("Writing %s", output) + from nvfp4gguf.bundle import warn_missing_gguf_metadata + 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) + log.info("Wrote %s", output) + + +def _split_name(name: str) -> tuple[str, str, str]: + if ":" not in name: + name = name + ":latest" + model, tag = name.rsplit(":", 1) + if "/" in model: + namespace, model = model.split("/", 1) + else: + namespace = "library" + return namespace, model, tag + + +def _read_blob(root: Path, digest: str) -> str: + path = blob_path(root, digest) + if not path.is_file(): + raise FileNotFoundError(f"Missing blob {path}") + return path.read_text(encoding="utf-8") + + +def _quote_modelfile_value(text: str) -> str: + """Quote a Modelfile argument the way Ollama's parser reads it back.""" + if text == "" or "\n" in text or text[:1].isspace() or text[-1:].isspace() or '"' in text: + if '"""' in text: + raise ValueError("Modelfile value contains a triple quote, which a Modelfile cannot hold") + if "\n" in text or '"' in text: + return '"""' + text + '"""' + return '"' + text + '"' + return text + + +def _format_parameter(value) -> str: + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)) and not isinstance(value, bool): + return str(value) + if isinstance(value, (list, dict)): + return _quote_modelfile_value(json.dumps(value, separators=(",", ":"))) + return _quote_modelfile_value(str(value)) + + +def _config_lines(config: dict) -> list[str]: + """Renderer, parser, requires, and explicit capabilities. + + Ollama uses the renderer and parser for thinking and tool calls. Gemma 4, + Qwen3.5, and LFM2 thinking models stop and split thinking only when these + are present. A copied template alone does not turn thinking on. + """ + lines: list[str] = [] + if config.get("requires"): + lines.append(f"REQUIRES {_quote_modelfile_value(str(config['requires']))}") + if config.get("renderer"): + lines.append(f"RENDERER {_quote_modelfile_value(str(config['renderer']))}") + if config.get("parser"): + lines.append(f"PARSER {_quote_modelfile_value(str(config['parser']))}") + capabilities = config.get("capabilities") or [] + if isinstance(capabilities, str): + capabilities = [capabilities] + for capability in capabilities: + lines.append(f"CAPABILITY {_quote_modelfile_value(str(capability))}") + return lines + + +def _quote_block(command: str, text: str) -> str: + body = text.replace("\r\n", "\n") + return f"{command} {_quote_modelfile_value(body)}\n" + + +def _message_lines(text: str) -> list[str]: + try: + messages = json.loads(text) + except json.JSONDecodeError: + return ["# Messages blob was not JSON and was left out."] + if not isinstance(messages, list): + return ["# Messages blob was not a list and was left out."] + lines: list[str] = [] + for item in messages: + if not isinstance(item, dict): + continue + role = str(item.get("role") or "user") + content = item.get("content") if isinstance(item.get("content"), str) else "" + if not content and item.get("tool_calls"): + content = json.dumps(item["tool_calls"], separators=(",", ":")) + lines.append(f"MESSAGE {role} {_quote_modelfile_value(content)}") + return lines + + +# This fine-tune drafts inside a block and often never emits . +# temperature 0.3, repeat_penalty 1.2, and repeat_last_n 128 got that story prompt +# to close the thought and stop. The chat template stays the source template. +_GEMMA_THINK_SAMPLER = ( + ("temperature", 0.3), + ("repeat_penalty", 1.2), + ("repeat_last_n", 128), +) + + +def _gemma_thinking_sampler(source: OllamaModel | None) -> bool: + if source is None or source.family.lower() not in {"gemma3", "gemma3n"}: + return False + if source.config.get("renderer") or source.config.get("parser"): + return False + text = " ".join(( + source.name, + str(source.metadata.get("general.name") or ""), + str(source.metadata.get("general.finetune") or ""), + )).lower() + return "thinking" in text + + +def _extra_sampler(source: OllamaModel | None, existing: dict) -> list[tuple[str, object]]: + if not _gemma_thinking_sampler(source): + return [] + missing = [(key, value) for key, value in _GEMMA_THINK_SAMPLER if key not in existing] + if missing: + log.warning( + "This Gemma thinking fine-tune drafts without writing . " + "Adding temperature 0.3, repeat_penalty 1.2, and repeat_last_n 128 so the reply reaches the stop token." + ) + return missing + + +def render_modelfile(root: Path, source: OllamaModel | None, from_path: str) -> str: + """Rebuild the source Modelfile without changing its prompt or sampling. + + Renderer and parser are what turn thinking on for Gemma 4, Qwen, and LFM2. + The chat template and stop list are copied as stored, because rewriting them + is what made Gemma keep generating. + """ + lines = [f"FROM {from_path}", ""] + if source is None: + return "\n".join(lines) + if any(layer.get("mediaType") == PROJECTOR_MEDIA for layer in source.layers): + lines.append("FROM ./mmproj.gguf") + lines.append("") + config_lines = _config_lines(source.config) + if config_lines: + lines.extend(config_lines) + lines.append("") + notes: list[str] = [] + for layer in source.layers: + media = str(layer.get("mediaType") or "") + kind = LAYER_KIND.get(media) + if media in (PROJECTOR_MEDIA, ADAPTER_MEDIA) or kind in (None, "PROJECTOR", "ADAPTER"): + if media == ADAPTER_MEDIA: + notes.append("# Ollama 0.40 no longer loads LoRA adapters, so the adapter layer was not written as ADAPTER.") + elif kind is None and media and media != MODEL_MEDIA: + notes.append(f"# Layer {media} is kept by --install and has no Modelfile command.") + continue + try: + text = _read_blob(root, layer["digest"]) + except (OSError, KeyError) as exc: + notes.append(f"# Could not read {kind.lower()} blob: {exc}") + continue + if kind == "PARAMETER": + try: + params = json.loads(text) + except json.JSONDecodeError: + notes.append("# Parameters blob was not JSON and was left out.") + continue + if isinstance(params, dict): + if "num_ctx" in params: + log.info("Leaving num_ctx out of the Modelfile so OLLAMA_CONTEXT_LENGTH applies.") + for key, value in params.items(): + if key == "num_ctx": + continue + values = value if isinstance(value, list) else [value] + for item in values: + lines.append(f"PARAMETER {key} {_format_parameter(item)}") + for key, value in _extra_sampler(source, params): + lines.append(f"PARAMETER {key} {_format_parameter(value)}") + lines.append("") + continue + if kind == "MESSAGE": + lines.extend(_message_lines(text)) + lines.append("") + continue + lines.append(_quote_block(kind, text)) + if notes: + lines.extend(notes) + lines.append("") + return "\n".join(lines).rstrip() + "\n" + + +def export_bundle(root: Path, export_dir: Path, gguf_file: Path, source: OllamaModel | None, overwrite: bool, copy_weights: bool = True) -> Path: + export_dir.mkdir(parents=True, exist_ok=True) + modelfile_path = export_dir / "Modelfile" + if modelfile_path.exists() and not overwrite: + raise SystemExit(f"{modelfile_path} already exists. Pass --overwrite to replace it.") + try: + relative = Path(os.path.relpath(gguf_file, export_dir)) + from_path = relative.as_posix() if relative.is_absolute() else relative.as_posix() + except ValueError: + from_path = gguf_file.as_posix() + if not from_path.startswith(("/", "./", "../")): + from_path = "./" + from_path + modelfile_path.write_text(render_modelfile(root, source, from_path), encoding="utf-8") + if copy_weights and source is not None: + for layer in source.layers: + if layer.get("mediaType") != PROJECTOR_MEDIA: + continue + dest = export_dir / "mmproj.gguf" + src = blob_path(root, layer["digest"]) + if not src.is_file(): + log.warning("Projector blob is missing: %s", src) + continue + if dest.exists() and not overwrite: + raise SystemExit(f"{dest} already exists. Pass --overwrite to replace it.") + if dest.exists(): + dest.unlink() + try: + os.link(src, dest) + except OSError: + shutil.copy2(src, dest) + log.info("Copied vision projector to %s", dest) + log.info("Wrote %s", modelfile_path) + return modelfile_path + + +def install_model(root: Path, gguf_file: Path, source: OllamaModel, new_name: str) -> Path: + digest = sha256_file(gguf_file) + blob = root / "blobs" / f"sha256-{digest}" + blob.parent.mkdir(parents=True, exist_ok=True) + if not blob.exists(): + try: + os.link(gguf_file, blob) + except OSError: + shutil.copy2(gguf_file, blob) + config = {key: value for key, value in source.config.items() if key != "rootfs"} + config["model_format"] = source.config.get("model_format") or "gguf" + config["model_family"] = source.family + config["model_families"] = source.config.get("model_families") or ([source.family] if source.family else []) + config["model_type"] = source.params + config["file_type"] = "NVFP4" + config.setdefault("architecture", "amd64") + config.setdefault("os", "linux") + config["rootfs"] = {"type": "layers", "diff_ids": []} + layers = [] + diff_ids = [] + for layer in source.layers: + copied = dict(layer) + copied.pop("from", None) + if layer.get("mediaType") == MODEL_MEDIA: + copied = { + "mediaType": MODEL_MEDIA, + "digest": f"sha256:{digest}", + "size": gguf_file.stat().st_size, + } + elif layer.get("mediaType") == "application/vnd.ollama.image.params": + try: + current = json.loads(_read_blob(root, layer["digest"])) + except (OSError, KeyError, json.JSONDecodeError): + current = None + extra = _extra_sampler(source, current if isinstance(current, dict) else {}) + if isinstance(current, dict) and (extra or "num_ctx" in current): + current.pop("num_ctx", None) + for key, value in extra: + current[key] = value + raw = json.dumps(current, separators=(",", ":")).encode("utf-8") + params_digest = hashlib.sha256(raw).hexdigest() + params_blob = root / "blobs" / f"sha256-{params_digest}" + params_blob.parent.mkdir(parents=True, exist_ok=True) + if not params_blob.exists(): + params_blob.write_bytes(raw) + copied = { + "mediaType": layer["mediaType"], + "digest": f"sha256:{params_digest}", + "size": len(raw), + } + layers.append(copied) + diff_ids.append(copied["digest"]) + config["rootfs"]["diff_ids"] = diff_ids + config_bytes = json.dumps(config, separators=(",", ":")).encode("utf-8") + config_digest = hashlib.sha256(config_bytes).hexdigest() + config_blob = root / "blobs" / f"sha256-{config_digest}" + config_blob.write_bytes(config_bytes) + namespace, model, tag = _split_name(new_name) + store = "manifests-v2" if source.store == "manifests-v2" else "manifests" + host = "ollama.com" if store == "manifests-v2" else "registry.ollama.ai" + manifest_path = root / store / host / namespace / model / tag + manifest_path.parent.mkdir(parents=True, exist_ok=True) + manifest = { + "schemaVersion": 2, + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "config": {"mediaType": CONFIG_MEDIA, "digest": f"sha256:{config_digest}", "size": len(config_bytes)}, + "layers": layers, + } + if source.runner: + manifest["runner"] = source.runner + elif store == "manifests-v2": + manifest["runner"] = "llamacpp" + if source.manifest_format: + manifest["format"] = source.manifest_format + elif store == "manifests-v2": + manifest["format"] = "gguf" + manifest_path.write_text(json.dumps(manifest, separators=(",", ":")), encoding="utf-8") + return manifest_path + + +def _resolve_source(args) -> tuple[Path, OllamaModel | None, str]: + query = args.model + if Path(query).is_file(): + source = Path(query).resolve() + return source, None, source.stem + root = Path(args.models).resolve() + source_model = resolve_model(load_models(root), query) + if not source_model.gguf_path.is_file(): + raise SystemExit(f"Weight blob is missing: {source_model.gguf_path}") + stem = source_model.name.replace("/", "-").replace(":", "-") + return source_model.gguf_path, source_model, stem + + +def _output_dir(args, stem: str) -> Path: + if args.output: + return Path(args.output).resolve() + return Path.cwd() / stem + + +def _wants_overwrite(args) -> bool: + return bool(getattr(args, "overwrite", False) or getattr(args, "force", False)) + + +def _place_gguf(src: Path, dest: Path, overwrite: bool) -> None: + dest.parent.mkdir(parents=True, exist_ok=True) + if dest.exists() and not overwrite: + raise SystemExit(f"{dest} already exists. Pass --overwrite to replace it.") + if dest.exists(): + dest.unlink() + try: + os.link(src, dest) + except OSError: + shutil.copy2(src, dest) + + +def cmd_export(args) -> None: + """Write the original GGUF and a Modelfile into a directory. No quantization.""" + logging.basicConfig(level=logging.INFO, format="%(message)s") + source, source_model, stem = _resolve_source(args) + out_dir = _output_dir(args, stem) + gguf_file = out_dir / f"{out_dir.name}.gguf" + overwrite = _wants_overwrite(args) + if not args.modelfile_only: + _place_gguf(source, gguf_file, overwrite) + else: + out_dir.mkdir(parents=True, exist_ok=True) + log.info("Skipping GGUF copy; writing Modelfile only") + export_bundle(Path(args.models).resolve(), out_dir, gguf_file, source_model, overwrite, copy_weights=not args.modelfile_only) + log.info("Exported %s", out_dir) + + +def cmd_quant(args) -> None: + logging.basicConfig(level=logging.INFO, format="%(message)s") + source, source_model, stem = _resolve_source(args) + out_dir = _output_dir(args, f"{stem}-NVFP4") + output = out_dir / f"{out_dir.name}.gguf" + modelfile_path = out_dir / "Modelfile" + overwrite = _wants_overwrite(args) + if modelfile_path.exists() and not overwrite: + raise SystemExit(f"{modelfile_path} already exists. Pass --overwrite to replace it.") + quant_args = argparse.Namespace( + device=args.device, + optimizer=args.optimizer, + num_iter=args.num_iter, + lr=args.lr, + lr_min=args.lr_min, + top_p=args.top_p, + min_k=args.min_k, + max_k=args.max_k, + full_matrix=args.full_matrix, + simple=args.simple, + lr_schedule=args.lr_schedule, + early_stop_loss=args.early_stop_loss, + early_stop_stall=args.early_stop_stall, + early_stop_floor_stall=args.early_stop_floor_stall, + target_loss=args.target_loss, + scale_refinement_rounds=args.scale_refinement_rounds, + scale_optimization=args.scale_optimization, + convert_to_quant=args.convert_to_quant, + quantize_output=args.quantize_output, + exclude=args.exclude, + exclude_re=None, + calib_samples=args.calib_samples, + seed=args.seed, + no_bias_correction=args.no_bias_correction, + ) + if args.modelfile_only: + out_dir.mkdir(parents=True, exist_ok=True) + log.info("Skipping quantization and GGUF creation; writing Modelfile only") + else: + quantize_gguf(args, source, output, quant_args) + export_bundle(Path(args.models).resolve(), out_dir, output, source_model, overwrite, copy_weights=not args.modelfile_only) + log.info("Quantized bundle %s" if not args.modelfile_only else "Wrote Modelfile %s", out_dir) + if args.install: + if source_model is None: + raise SystemExit("--install needs an Ollama model name, not a bare GGUF path") + if not output.is_file(): + raise SystemExit(f"{output} is not there, so --install has no GGUF. Run quant without --modelfile-only first.") + root = Path(args.models).resolve() + new_name = args.name or _nvfp4_name(source_model.name) + manifest = install_model(root, output, source_model, new_name) + log.info("Registered %s", new_name) + log.info("Manifest %s", manifest) + + +_INFO_USAGE = "usage: ollama_nvfp4.py [--models DIR] --info MODEL [arguments for gguf_info.py...]" + + +def _without_models_dir(argv: list[str]) -> tuple[list[str], Path]: + """Pull --models out of an --info command so it is not forwarded to gguf_info.py.""" + kept: list[str] = [] + models: Path | None = None + index = 0 + while index < len(argv): + arg = argv[index] + if arg == "--models" or arg.startswith("--models="): + if arg == "--models": + if index + 1 >= len(argv): + raise SystemExit(f"{_INFO_USAGE}\n--models needs a directory") + value = argv[index + 1] + index += 2 + else: + value = arg.split("=", 1)[1] + index += 1 + models = Path(value) + continue + kept.append(arg) + index += 1 + return kept, models if models is not None else default_models_dir() + + +def cmd_info(argv: list[str]) -> None: + """Resolve MODEL to its sha256 weight blob and run gguf_info.py on that file. + + Arguments after the model name are forwarded unchanged. --models is kept + here and may appear before or after --info. + """ + kept, models_dir = _without_models_dir(argv) + try: + info_at = kept.index("--info") + except ValueError: + raise SystemExit(_INFO_USAGE) from None + head, tail = kept[:info_at], kept[info_at + 1:] + if head or not tail or tail[0].startswith("-"): + extra = f"\nunrecognized: {' '.join(head)}" if head else "" + raise SystemExit(_INFO_USAGE + extra) + model_name, forwarded = tail[0], tail[1:] + root = Path(models_dir).resolve() + model = resolve_model(load_models(root), model_name) + if not model.gguf_path.is_file(): + raise SystemExit(f"Weight blob is missing: {model.gguf_path}") + script = Path(__file__).resolve().parent / "gguf_info.py" + if not script.is_file(): + raise SystemExit(f"gguf_info.py is not next to this script: {script}") + command = [sys.executable, str(script), str(model.gguf_path), *forwarded] + completed = subprocess.run(command) + raise SystemExit(completed.returncode) + + +def _nvfp4_name(name: str) -> str: + if ":" not in name: + return name + ":nvfp4" + model, tag = name.rsplit(":", 1) + if tag == "latest": + return model + ":nvfp4" + return f"{model}:{tag}-nvfp4" + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + from nvfp4gguf import ( + DEFAULT_EARLY_STOP_FLOOR_STALL, + DEFAULT_TARGET_LOSS, + DEFAULT_EARLY_STOP_STALL, + DEFAULT_SCALE_OPTIMIZATION, + DEFAULT_SCALE_REFINEMENT_ROUNDS, + ) + parser = argparse.ArgumentParser( + description=( + "Read an Ollama sha256 model store and quantize a model to NVFP4 GGUF. " + "--info MODEL runs gguf_info.py on that model's sha256 weight blob. " + "Arguments after MODEL are passed through, for example --metadata or --tensors." + ), + ) + parser.add_argument("--models", type=Path, default=default_models_dir(), help="Ollama models directory (blobs/ and manifests/)") + sub = parser.add_subparsers(dest="command", required=True) + + listed = sub.add_parser("list", help="Show every manifest as a readable model") + listed.add_argument( + "--full-scan", + action="store_true", + help="Read each GGUF header for capabilities and missing encoders. Slower on large models.", + ) + listed.add_argument( + "--ctx-scan", + action="store_true", + help="Read parameter blobs and list models whose Modelfile sets num_ctx.", + ) + + show = sub.add_parser("show", help="Show one model, its blobs, and its GGUF metadata") + show.add_argument("model", help="Name from list, or a unique substring") + + export = sub.add_parser("export", help="Write the original GGUF and a Modelfile into a directory") + export.add_argument("model", help="Ollama model name, or a path to a GGUF file") + export.add_argument("-o", "--output", type=Path, help="Output directory. Default: ./ in the current directory") + export.add_argument("--modelfile-only", action="store_true", help="Write the Modelfile and skip copying the GGUF and projector") + export.add_argument("--overwrite", action="store_true", help="Replace any output file that already exists") + export.add_argument("--force", action="store_true", help="Same as --overwrite") + + quant = sub.add_parser("quant", help="Quantize into a directory containing the NVFP4 GGUF and a Modelfile") + quant.add_argument("model", help="Ollama model name, or a path to a GGUF file") + quant.add_argument("-o", "--output", type=Path, help="Output directory. Default: ./-NVFP4 in the current directory") + quant.add_argument("--name", help="Ollama name to register with --install. Default: :nvfp4 or -nvfp4") + quant.add_argument("--install", action="store_true", help="Register the NVFP4 GGUF in the Ollama store") + quant.add_argument("--modelfile-only", action="store_true", help="Write the Modelfile and skip quantization and GGUF creation") + quant.add_argument("--overwrite", action="store_true", help="Replace any output file that already exists") + quant.add_argument("--force", action="store_true", help="Same as --overwrite") + quant.add_argument("--float-only", action="store_true", help="Refuse to requantize a model that is already quantized") + quant.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.") + quant.add_argument("--llama-cpp", type=Path, default=Path.home() / "llama.cpp", + help="llama.cpp checkout used for the GGUF reader and writer (default: ~/llama.cpp)") + quant.add_argument("--convert-to-quant", type=Path, default=None) + quant.add_argument("--device", default="auto") + quant.add_argument("--simple", action="store_true", help="Round onto the E2M1 grid without learned rounding") + quant.add_argument("--optimizer", default="auto", choices=("auto", "prodigy", "adamw", "radam", "original")) + quant.add_argument("--num-iter", type=int, default=4000) + quant.add_argument("--lr", type=float, default=1.0, help="Initial learning rate") + quant.add_argument("--lr-min", type=float, default=1e-8, help="Lowest learning rate. The flat-loss check starts here (default: 1e-8).") + quant.add_argument("--lr-schedule", default="plateau", choices=("plateau", "exponential", "adaptive")) + quant.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)", + ) + quant.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.", + ) + quant.add_argument("--full-matrix", action="store_true") + quant.add_argument("--top-p", type=float, default=0.2) + quant.add_argument("--min-k", type=int, default=128) + quant.add_argument("--max-k", type=int, default=1280) + quant.add_argument("--early-stop-loss", type=float, default=5e-9) + quant.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)", + ) + quant.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.", + ) + quant.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)", + ) + quant.add_argument("--calib-samples", type=int, default=3072) + quant.add_argument("--seed", type=int, default=42) + quant.add_argument("--no-bias-correction", action="store_true") + quant.add_argument("--quantize-output", action="store_true") + quant.add_argument("--exclude", default=None, help="Regex of GGUF tensor names to leave unchanged") + quant.add_argument("--fresh", action="store_true") + quant.add_argument("--keep-checkpoints", action="store_true") + quant.add_argument("--no-temp-file", action="store_true") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> None: + if argv is None: + argv = sys.argv[1:] + if "--info" in argv: + cmd_info(argv) + return + args = parse_args(argv) + root = Path(args.models).resolve() + if args.command == "list": + cmd_list(root, full_scan=args.full_scan, ctx_scan=args.ctx_scan) + return + if args.command == "show": + cmd_show(root, args.model) + return + if args.command == "export": + cmd_export(args) + return + if args.command == "quant": + cmd_quant(args) + return + raise SystemExit(f"Unknown command {args.command}") + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..086b8c6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,19 @@ +# Dependencies for hf_to_nvfp4_gguf.py, ollama_nvfp4.py, +# safetensors_to_nvfp4_gguf.py, and nvfp4gguf. +# +# llama.cpp (GGUF writer and Hugging Face conversion) and convert_to_quant +# (LearnedNVFP4Converter) are local checkouts, not pip packages. Point the +# scripts at them with --llama-cpp and --convert-to-quant. +# +# Install a CUDA build of torch that matches your GPU. A CPU wheel works +# with --device cpu. + +numpy>=2.2.6 +torch>=2.6.0 +safetensors>=0.4.2 +tqdm>=4.27 +PyYAML>=5.1 +transformers>=4.46 +sentencepiece>=0.1.98,<0.3.0 +protobuf>=4.21.0,<5.0.0 +prodigy-plus-schedule-free diff --git a/safetensors_to_nvfp4_gguf.py b/safetensors_to_nvfp4_gguf.py new file mode 100644 index 0000000..7af827e --- /dev/null +++ b/safetensors_to_nvfp4_gguf.py @@ -0,0 +1,532 @@ +#!/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: -NVFP4.gguf, or .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()