18 Commits
Author SHA1 Message Date
City 6ea2651e7d Only include metadata on new comfy versions
For #407 since old comfy versions don't support passing metadata (added in #399 )
2026-01-12 13:55:28 +01:00
City 9ecc3c48e7 Add Gemma3 12B Support (#402)
* Add Gemma3 12B Support

* Update loader.py

* Support tokenizer recreation from metadata

* Update loader.py

* update loader.py
2026-01-12 13:38:42 +01:00
jayn7 2e7f529b29 update loader.py 2026-01-12 07:09:08 +07:00
hpr 243a525df9 Update loader.py 2026-01-12 06:23:00 +07:00
jayn7 36b16deb09 Support tokenizer recreation from metadata 2026-01-12 06:19:49 +07:00
hpr 5a537ab64e Update loader.py 2026-01-12 06:19:44 +07:00
jayn7 a4c94821cb Add Gemma3 12B Support 2026-01-12 06:17:14 +07:00
Adam Niederer c2e3b0adc3 Accept size-one ndarrays of any dimension from gguf reader (#394)
Sometimes gguf will return size-one one-dimensional memmaps for scalar values. I'm not exactly sure when or why, but this will accept any dimension as long as it only contains one value

Resolves #384
2026-01-11 19:47:18 +01:00
Vantage with AIandCity 58625e1cb6 Added support to load model config from Metadata. (#399)
* Implement GGUF metadata extraction function

Added function to extract metadata from GGUF files.

* Updated the GGUF model loading and patching classes to include metadata handling.

* Clean up return logic for extra metadata

This should be more future proof in case we need to return other attributes in the future. Possible breaking change for anyone using `gguf_sd_loader` directly either way, though.

---------

Co-authored-by: City <125218114+city96@users.noreply.github.com>
2026-01-11 19:30:59 +01:00
City 795e45156e Use int64 for index in dequant
This is the fix provided by @LSXAxeller in #385
2025-12-18 09:11:56 +01:00
City 8493db6481 Limit this fix to BF16 2025-12-18 09:03:26 +01:00
City a57094a3d3 Fix for scales being in BF16
The main conversion code shouldn't allow this, but apparently some models still end up with quantized 1D tensors, see #383
2025-12-17 15:52:16 +01:00
City 01f8845bf3 Add VL version of Qwen3 4B as valid arch
According to #361 the thinking/abliterated versions work even though the model wasn't trained with them, maybe the vision one does too.
Does not have mmproj support, since nothing uses that atm.
2025-12-01 01:48:19 +01:00
City 79379af338 Add tokenizer convert logic for Mistral
#367
2025-11-29 22:21:30 +01:00
City 0c9edef33b Improve invalid tenc file message 2025-11-29 22:20:13 +01:00
City d3e14efda6 Hotfix for fallback
#369
2025-11-27 06:25:54 +01:00
City 6aa9866ef3 Add IQ4_NL and IQ4_XS support
Based on the reference numpy code in gguf-py.
Mostly useful for tenc, since main model won't have imatrix for quantizing.
2025-11-27 05:17:04 +01:00
City fa48477de8 Qwen3-4B support for Z-Image
for #368
2025-11-26 23:22:52 +01:00
3 changed files with 230 additions and 16 deletions
+54 -1
View File
@@ -23,7 +23,7 @@ def dequantize_tensor(tensor, dtype=None, dequant_dtype=None):
return dequantize(tensor.data, qtype, oshape, dtype=dequant_dtype).to(dtype)
else:
# this is incredibly slow
tqdm.write(f"Falling back to numpy dequant for qtype: {qtype}")
tqdm.write(f"Falling back to numpy dequant for qtype: {getattr(qtype, 'name', repr(qtype))}")
new = gguf.quants.dequantize(tensor.cpu().numpy(), qtype)
return torch.from_numpy(new).to(tensor.device, dtype=dtype)
@@ -48,6 +48,10 @@ def to_uint32(x):
x = x.view(torch.uint8).to(torch.int32)
return (x[:, 0] | x[:, 1] << 8 | x[:, 2] << 16 | x[:, 3] << 24).unsqueeze(1)
def to_uint16(x):
x = x.view(torch.uint8).to(torch.int32)
return (x[:, 0] | x[:, 1] << 8).unsqueeze(1)
def split_block_dims(blocks, *args):
n_max = blocks.shape[1]
dims = list(args) + [n_max - sum(args)]
@@ -233,6 +237,53 @@ def dequantize_blocks_Q2_K(blocks, block_size, type_size, dtype=None):
return qs.reshape((n_blocks, -1))
# IQ quants
KVALUES = torch.tensor([-127, -104, -83, -65, -49, -35, -22, -10, 1, 13, 25, 38, 53, 69, 89, 113], dtype=torch.int8)
def dequantize_blocks_IQ4_NL(blocks, block_size, type_size, dtype=None):
n_blocks = blocks.shape[0]
d, qs = split_block_dims(blocks, 2)
d = d.view(torch.float16).to(dtype)
qs = qs.reshape((n_blocks, -1, 1, block_size//2)) >> torch.tensor([0, 4], device=d.device, dtype=torch.uint8).reshape((1, 1, 2, 1))
qs = (qs & 0x0F).reshape((n_blocks, -1, 1)).to(torch.int64)
kvalues = KVALUES.to(qs.device).expand(*qs.shape[:-1], 16)
qs = torch.gather(kvalues, dim=-1, index=qs).reshape((n_blocks, -1))
del kvalues # should still be view, but just to be safe
return (d * qs)
def dequantize_blocks_IQ4_XS(blocks, block_size, type_size, dtype=None):
n_blocks = blocks.shape[0]
d, scales_h, scales_l, qs = split_block_dims(blocks, 2, 2, QK_K // 64)
d = d.view(torch.float16).to(dtype)
scales_h = to_uint16(scales_h)
shift_a = torch.tensor([0, 4], device=d.device, dtype=torch.uint8).reshape((1, 1, 2))
shift_b = torch.tensor([2 * i for i in range(QK_K // 32)], device=d.device, dtype=torch.uint8).reshape((1, -1, 1))
scales_l = scales_l.reshape((n_blocks, -1, 1)) >> shift_a.reshape((1, 1, 2))
scales_h = scales_h.reshape((n_blocks, -1, 1)) >> shift_b.reshape((1, -1, 1))
scales_l = scales_l.reshape((n_blocks, -1)) & 0x0F
scales_h = scales_h.reshape((n_blocks, -1)).to(torch.uint8) & 0x03
scales = (scales_l | (scales_h << 4)).to(torch.int8) - 32
dl = (d * scales.to(dtype)).reshape((n_blocks, -1, 1))
qs = qs.reshape((n_blocks, -1, 1, 16)) >> shift_a.reshape((1, 1, 2, 1))
qs = qs.reshape((n_blocks, -1, 32, 1)) & 0x0F
kvalues = KVALUES.to(qs.device).expand(*qs.shape[:-1], 16)
qs = torch.gather(kvalues, dim=-1, index=qs.to(torch.int64)).reshape((n_blocks, -1, 32))
del kvalues # see IQ4_NL
del shift_a
del shift_b
return (dl * qs).reshape((n_blocks, -1))
dequantize_functions = {
gguf.GGMLQuantizationType.BF16: dequantize_blocks_BF16,
gguf.GGMLQuantizationType.Q8_0: dequantize_blocks_Q8_0,
@@ -245,4 +296,6 @@ dequantize_functions = {
gguf.GGMLQuantizationType.Q4_K: dequantize_blocks_Q4_K,
gguf.GGMLQuantizationType.Q3_K: dequantize_blocks_Q3_K,
gguf.GGMLQuantizationType.Q2_K: dequantize_blocks_Q2_K,
gguf.GGMLQuantizationType.IQ4_NL: dequantize_blocks_IQ4_NL,
gguf.GGMLQuantizationType.IQ4_XS: dequantize_blocks_IQ4_XS,
}
+166 -13
View File
@@ -10,7 +10,7 @@ from .ops import GGMLTensor
from .dequant import is_quantized, dequantize_tensor
IMG_ARCH_LIST = {"flux", "sd1", "sdxl", "sd3", "aura", "hidream", "cosmos", "ltxv", "hyvid", "wan", "lumina2", "qwen_image"}
TXT_ARCH_LIST = {"t5", "t5encoder", "llama", "qwen2vl"}
TXT_ARCH_LIST = {"t5", "t5encoder", "llama", "qwen2vl", "qwen3", "qwen3vl", "gemma3"}
VIS_TYPE_LIST = {"clip-vision", "mmproj"}
def get_orig_shape(reader, tensor_name):
@@ -33,7 +33,7 @@ def get_field(reader, field_name, field_type):
raise TypeError(f"Bad type for GGUF {field_name} key: expected string, got {field.types!r}")
return str(field.parts[field.data[-1]], encoding="utf-8")
elif field_type in [int, float, bool]:
return field_type(field.parts[field.data[-1]])
return field_type(field.parts[field.data[-1]].item())
else:
raise TypeError(f"Unknown field type {field_type}")
@@ -48,7 +48,26 @@ def get_list_field(reader, field_name, field_type):
else:
raise TypeError(f"Unknown field type {field_type}")
def gguf_sd_loader(path, handle_prefix="model.diffusion_model.", return_arch=False, is_text_model=False):
def get_gguf_metadata(reader):
"""Extract all simple metadata fields like safetensors"""
metadata = {}
for field_name in reader.fields:
try:
field = reader.get_field(field_name)
if len(field.types) == 1: # Simple scalar fields only
if field.types[0] == gguf.GGUFValueType.STRING:
metadata[field_name] = str(field.parts[field.data[-1]], "utf-8")
elif field.types[0] == gguf.GGUFValueType.INT32:
metadata[field_name] = int(field.parts[field.data[-1]])
elif field.types[0] == gguf.GGUFValueType.F32:
metadata[field_name] = float(field.parts[field.data[-1]])
elif field.types[0] == gguf.GGUFValueType.BOOL:
metadata[field_name] = bool(field.parts[field.data[-1]])
except:
continue
return metadata
def gguf_sd_loader(path, handle_prefix="model.diffusion_model.", is_text_model=False):
"""
Read state dict as fake tensors
"""
@@ -74,9 +93,9 @@ def gguf_sd_loader(path, handle_prefix="model.diffusion_model.", return_arch=Fal
compat = None
arch_str = get_field(reader, "general.architecture", str)
type_str = get_field(reader, "general.type", str)
if arch_str in [None, "pig"]:
if arch_str in [None, "pig", "cow"]:
if is_text_model:
raise ValueError(f"This text model is incompatible with llama.cpp!\nConsider using the safetensors version\n({path})")
raise ValueError(f"This gguf file is incompatible with llama.cpp!\nConsider using safetensors or a compatible gguf file\n({path})")
compat = "sd.cpp" if arch_str is None else arch_str
# import here to avoid changes to convert.py breaking regular models
from .tools.convert import detect_arch
@@ -119,6 +138,10 @@ def gguf_sd_loader(path, handle_prefix="model.diffusion_model.", return_arch=Fal
torch_tensor = torch_tensor.view(*shape)
state_dict[sd_key] = GGMLTensor(torch_tensor, tensor_type=tensor.tensor_type, tensor_shape=shape)
# 1D tensors shouldn't be quantized, this is a fix for BF16
if len(shape) <= 1 and tensor.tensor_type == gguf.GGMLQuantizationType.BF16:
state_dict[sd_key] = dequantize_tensor(state_dict[sd_key], dtype=torch.float32)
# keep track of loaded tensor types
tensor_type_str = getattr(tensor.tensor_type, "name", repr(tensor.tensor_type))
qtype_dict[tensor_type_str] = qtype_dict.get(tensor_type_str, 0) + 1
@@ -132,9 +155,12 @@ def gguf_sd_loader(path, handle_prefix="model.diffusion_model.", return_arch=Fal
max_key = max(qsd.keys(), key=lambda k: qsd[k].numel())
state_dict[max_key].is_largest_weight = True
if return_arch:
return (state_dict, arch_str)
return state_dict
# extra info to return
extra = {
"arch_str": arch_str,
"metadata": get_gguf_metadata(reader)
}
return (state_dict, extra)
# for remapping llama.cpp -> original key names
T5_SD_MAP = {
@@ -157,6 +183,9 @@ T5_SD_MAP = {
LLAMA_SD_MAP = {
"blk.": "model.layers.",
"attn_norm": "input_layernorm",
"attn_q_norm.": "self_attn.q_norm.",
"attn_k_norm.": "self_attn.k_norm.",
"attn_v_norm.": "self_attn.v_norm.",
"attn_q": "self_attn.q_proj",
"attn_k": "self_attn.k_proj",
"attn_v": "self_attn.v_proj",
@@ -170,6 +199,13 @@ LLAMA_SD_MAP = {
"output.weight": "lm_head.weight",
}
GEMMA3_SD_MAP = LLAMA_SD_MAP.copy()
GEMMA3_SD_MAP.update({
"ffn_norm": "pre_feedforward_layernorm",
"post_ffw_norm": "post_feedforward_layernorm",
"post_attention_norm": "post_attention_layernorm",
})
CLIP_VISION_SD_MAP = {
"mm.": "visual.merger.mlp.",
"v.post_ln.": "visual.merger.ln_q.",
@@ -203,6 +239,28 @@ def llama_permute(raw_sd, n_head, n_head_kv):
sd[k] = v
return sd
def gemma3_norm_corrections(sd):
# Reverse change from Gemma3Model modify_tensors in llama.cpp convert script
norm_patterns = [
"input_layernorm.weight",
"post_attention_layernorm.weight",
"pre_feedforward_layernorm.weight",
"post_feedforward_layernorm.weight",
"self_attn.q_norm.weight",
"self_attn.k_norm.weight",
"model.norm.weight"
]
corrected = 0
for key in list(sd.keys()):
if any(p in key for p in norm_patterns):
if is_quantized(sd[key]):
sd[key] = dequantize_tensor(sd[key], dtype=torch.float32) - 1.0
else:
sd[key] = sd[key].float() - 1.0
corrected += 1
#logging.info(f"Gemma3: Applied -1 norm correction to {corrected} tensors")
return sd
def strip_quant_suffix(name):
pattern = r"[-_]?(?:ud-)?i?q[0-9]_[a-z0-9_\-]{1,8}$"
match = re.search(pattern, name, re.IGNORECASE)
@@ -239,7 +297,7 @@ def gguf_mmproj_loader(path):
logging.info(f"Using mmproj '{target[0]}' for text encoder '{tenc_fname}'.")
target = os.path.join(root, target[0])
vsd = gguf_sd_loader(target, is_text_model=True)
vsd, _ = gguf_sd_loader(target, is_text_model=True)
# concat 4D to 5D
if "v.patch_embd.weight.1" in vsd:
@@ -324,8 +382,94 @@ def gguf_tokenizer_loader(path, temb_shape):
del reader
return torch.ByteTensor(list(spm.SerializeToString()))
def gguf_tekken_tokenizer_loader(path, temb_shape):
# convert ggml (hf) tokenizer metadata to tekken/comfy data
logging.info("Attempting to recreate tekken tokenizer from GGUF file metadata...")
import json
import base64
from transformers.convert_slow_tokenizer import bytes_to_unicode
reader = gguf.GGUFReader(path)
model_str = get_field(reader, "tokenizer.ggml.model", str)
if model_str == "gpt2":
if temb_shape == (131072, 5120): # probably Mistral
data = {
"config": {"num_vocab_tokens": 150000, "default_vocab_size": 131072},
"vocab": [],
"special_tokens": [],
}
else:
raise NotImplementedError("Unknown model, can't set tokenizer!")
else:
raise NotImplementedError("Unknown model, can't set tokenizer!")
tokens = get_list_field(reader, "tokenizer.ggml.tokens", str)
toktypes = get_list_field(reader, "tokenizer.ggml.token_type", int)
decoder = {v: k for k, v in bytes_to_unicode().items()}
for idx, (token, toktype) in enumerate(zip(tokens, toktypes)):
if toktype == 3:
data["special_tokens"].append(
{'rank': idx, 'token_str': token, 'is_control': True}
)
else:
tok = bytes([decoder[char] for char in token])
data["vocab"].append({
"rank": len(data["vocab"]),
"token_bytes": base64.b64encode(tok).decode("ascii"),
"token_str": tok.decode("utf-8", errors="replace") # ?
})
logging.info(f"Created tekken tokenizer with vocab size of {len(data['vocab'])} (+{len(data['special_tokens'])})")
del reader
return torch.ByteTensor(list(json.dumps(data).encode('utf-8')))
def gguf_gemma3_tokenizer_loader(path):
#TODO: merge into gguf_tokenizer_loader
logging.info("Attempting to recreate sentencepiece tokenizer from GGUF file metadata...")
try:
from sentencepiece import sentencepiece_model_pb2 as model
except ImportError:
raise ImportError("Please install sentencepiece and protobuf.\npip install sentencepiece protobuf")
spm = model.ModelProto()
reader = gguf.GGUFReader(path)
spm.normalizer_spec.name = "identity"
spm.normalizer_spec.add_dummy_prefix = False
spm.trainer_spec.model_type = 2
spm.trainer_spec.input_format = "tsv"
spm.trainer_spec.byte_fallback = True
spm.trainer_spec.max_sentence_length = 4192
spm.trainer_spec.bos_piece = "<bos>"
tokens = get_list_field(reader, "tokenizer.ggml.tokens", str)
scores = get_list_field(reader, "tokenizer.ggml.scores", float)
toktype = get_list_field(reader, "tokenizer.ggml.token_type", int)
if not tokens or not scores or not toktype:
raise ValueError("Missing tokenizer metadata")
for idx in range(len(tokens)):
piece = spm.SentencePiece()
piece.piece = tokens[idx]
if idx == 3: # UNK position
piece.type = 2 # UNK Token
piece.score = 0.0 # UNK Score
else:
piece.type = toktype[idx]
piece.score = scores[idx]
spm.pieces.append(piece)
spm.trainer_spec.vocab_size = len(spm.pieces)
logging.info(f"Created tokenizer with vocab size of {len(spm.pieces)}")
del reader
return torch.ByteTensor(list(spm.SerializeToString()))
def gguf_clip_loader(path):
sd, arch = gguf_sd_loader(path, return_arch=True, is_text_model=True)
sd, extra = gguf_sd_loader(path, is_text_model=True)
arch = extra.get("arch_str", None)
if arch in {"t5", "t5encoder"}:
temb_key = "token_embd.weight"
if temb_key in sd and sd[temb_key].shape == (256384, 4096):
@@ -335,16 +479,25 @@ def gguf_clip_loader(path):
logging.warning(f"Dequantizing {temb_key} to prevent runtime OOM.")
sd[temb_key] = dequantize_tensor(sd[temb_key], dtype=torch.float16)
sd = sd_map_replace(sd, T5_SD_MAP)
elif arch in {"llama", "qwen2vl"}:
elif arch in {"llama", "qwen2vl", "qwen3", "qwen3vl", "gemma3"}:
# TODO: pass model_options["vocab_size"] to loader somehow
temb_key = "token_embd.weight"
if temb_key in sd and sd[temb_key].shape[0] >= (64 * 1024):
if arch == "llama" and sd[temb_key].shape == (131072, 5120):
# non-standard Comfy-Org tokenizer
sd["tekken_model"] = gguf_tekken_tokenizer_loader(path, sd[temb_key].shape)
elif arch == "gemma3":
sd["spiece_model"] = gguf_gemma3_tokenizer_loader(path)
# See note above for T5.
logging.warning(f"Dequantizing {temb_key} to prevent runtime OOM.")
sd[temb_key] = dequantize_tensor(sd[temb_key], dtype=torch.float16)
sd = sd_map_replace(sd, LLAMA_SD_MAP)
if arch == "gemma3":
sd = sd_map_replace(sd, GEMMA3_SD_MAP)
sd = gemma3_norm_corrections(sd)
else:
sd = sd_map_replace(sd, LLAMA_SD_MAP)
if arch == "llama":
sd = llama_permute(sd, 32, 8) # L3
sd = llama_permute(sd, 32, 8) # L3 / Mistral
if arch == "qwen2vl":
vsd = gguf_mmproj_loader(path)
sd.update(vsd)
+10 -2
View File
@@ -1,6 +1,7 @@
# (c) City96 || Apache-2.0 (apache.org/licenses/LICENSE-2.0)
import torch
import logging
import inspect
import collections
import nodes
@@ -165,9 +166,15 @@ class UnetLoaderGGUF:
# init model
unet_path = folder_paths.get_full_path("unet", unet_name)
sd = gguf_sd_loader(unet_path)
sd, extra = gguf_sd_loader(unet_path)
kwargs = {}
valid_params = inspect.signature(comfy.sd.load_diffusion_model_state_dict).parameters
if "metadata" in valid_params:
kwargs["metadata"] = extra.get("metadata", {})
model = comfy.sd.load_diffusion_model_state_dict(
sd, model_options={"custom_operations": ops}
sd, model_options={"custom_operations": ops}, **kwargs,
)
if model is None:
logging.error("ERROR UNSUPPORTED UNET {}".format(unet_path))
@@ -319,3 +326,4 @@ NODE_CLASS_MAPPINGS = {
"QuadrupleCLIPLoaderGGUF": QuadrupleCLIPLoaderGGUF,
"UnetLoaderGGUFAdvanced": UnetLoaderGGUFAdvanced,
}