Files
Claude 3adebb731f Rename FunPack to FunUI (5.0.3 Classy Chopper): /fun endpoint, wink logo, load-time migration of old names
Old project/workflow names and browser settings migrate on load; /funpack redirects to /fun.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016acRss69eH1235eRdFTZHY
2026-10-25 13:11:03 +00:00

387 lines
17 KiB
Python

"""Load .gguf model files through FunUI's own loaders.
Not a module: see common.py's own note -- a plain file at the domain level is a
helper the loaders import, and ships no node of its own.
GGUF is two problems, and only the first is small:
1. **Reading the container.** A header, tensor metadata, then quantized blocks.
The `gguf` package (llama.cpp's own) does this, and ships a reference
dequantizer for every quant type it defines.
2. **Using the weights without dequantizing them.** The reason people reach for
GGUF is that a Q4 model occupies a quarter of the VRAM -- which only holds
if the weights STAY quantized and are expanded per-layer during the forward
pass. That needs a custom torch ops class, and hand-written kernels per
quant type.
So this module does not hand-roll (2). It picks the best backend actually
present:
* **ComfyUI-GGUF installed** -- use it. It is the mature implementation of
(2), and its output feeds `comfy.sd.load_diffusion_model_state_dict`
exactly like ours does, so FunUI's loader stays the only UI while its
runtime does the quantized maths.
* **`gguf` package only** -- read and dequantize at LOAD time, then hand over
an ordinary state dict. Fully native and correct, but the model occupies
its dequantized size, so the VRAM saving is gone. Said out loud rather than
left to be discovered.
* **Neither** -- refuse with the two ways to fix it, instead of a stack trace.
Nothing here downloads or installs anything.
"""
from __future__ import annotations
import importlib
import importlib.util
import os
import sys
import types
from typing import Optional
GGUF_EXT = ".gguf"
def is_gguf(name: str) -> bool:
return str(name or "").lower().endswith(GGUF_EXT)
GGUF_MAGIC = b"GGUF"
def has_gguf_magic(path: str) -> bool:
"""True when the FILE is a GGUF container, whatever it is called.
Renaming a .gguf to .safetensors is an easy mistake to make, and the
result is an unreadable error from the safetensors parser trying to read a
binary header as UTF-8 JSON. Four bytes settle it, so the content decides.
"""
try:
with open(path, "rb") as f:
return f.read(4) == GGUF_MAGIC
except OSError:
return False
# ── finding .gguf files ───────────────────────────────────────────────────────
def _walk(root: str, limit: int = 5000, depth: int = 12):
"""Every .gguf under `root` as a relative name, following links.
A folder is not entered again while it is one of the folders above it (same device and inode, so case, roots and
cycles through a sibling are all caught), and the walk stops after `limit` folders: a link to a whole disk is slow, not endless.
A link to a sibling folder is not a loop; it is a second name for the same files.
"""
seen = [0]
cut = []
def rec(path, rel, above):
seen[0] += 1
try:
entries = sorted(os.scandir(path), key=lambda e: e.name)
except OSError:
return
for e in entries:
try:
if e.is_dir():
st = os.stat(e.path)
key = (st.st_dev, st.st_ino)
if key in above:
continue
if len(above) >= depth or seen[0] >= limit:
cut.append(e.path)
continue
yield from rec(e.path, rel + e.name + "/", above | {key})
elif is_gguf(e.name) and e.is_file(): # is_file: a dangling link is not a model
yield rel + e.name
except OSError:
continue
st = os.stat(root)
yield from rec(root, "", frozenset({(st.st_dev, st.st_ino)}))
if cut:
from .._core import log
log.once(f"gguf_walk_cut:{root}", log.WARNING, "FunUI .gguf listing",
f"{root}: stopped looking inside {len(cut)} folder(s) (more than {depth - 1} levels deep or more than {limit} folders in all), so .gguf files there are not listed")
def gguf_names(folder: str) -> list[str]:
"""`.gguf` files under a ComfyUI model folder, relative-named like get_filename_list().
Core's `supported_pt_extensions` has no `.gguf`, so `get_filename_list`
never returns one -- the files are on disk and simply invisible to every
picker. Scanned directly rather than by mutating core's global extension
set, which would change what every other node in the install offers.
"""
try:
import folder_paths
except ImportError:
return []
out: list[str] = []
seen: set[str] = set()
roots = list(folder_paths.get_folder_paths(folder) or [])
# ComfyUI-GGUF registers its own folders; include them under the same
# picker so a user who already keeps files there does not have to move them.
for extra in ("unet_gguf" if folder == "diffusion_models" else "clip_gguf",):
try:
roots += list(folder_paths.get_folder_paths(extra) or [])
except Exception:
pass
for root in roots:
if not root or not os.path.isdir(root):
continue
for rel in _walk(root):
if rel not in seen:
seen.add(rel)
out.append(rel)
return sorted(out)
def gguf_path(folder: str, name: str) -> Optional[str]:
"""Absolute path of a `.gguf` picked out of `gguf_names`."""
try:
import folder_paths
except ImportError:
return None
roots = list(folder_paths.get_folder_paths(folder) or [])
for extra in ("unet_gguf" if folder == "diffusion_models" else "clip_gguf",):
try:
roots += list(folder_paths.get_folder_paths(extra) or [])
except Exception:
pass
if ".." in name.replace("\\", "/").split("/"):
return None
for root in roots:
cand = os.path.join(root or "", name)
if not os.path.normpath(cand).startswith(os.path.normpath(root or ".") + os.sep):
continue # a name that climbs out of the model folder (symlinks inside it are fine)
if os.path.isfile(cand):
return cand
return None
# ── backends ──────────────────────────────────────────────────────────────────
_PKG_NAME = "_funui_gguf_pack"
def _ensure_pack_package(dirname: str) -> str:
"""Register the pack's directory as an importable package, once.
Loading `loader.py` as a standalone module by file path fails the moment
it does `from .ops import ...` -- "attempted relative import with no known
parent package", because a module loaded that way has no package to be
relative TO. So a parent package is synthesized with `__path__` pointing at
the directory, and submodules are imported underneath it, which is what
makes relative imports resolve.
The pack's own `__init__.py` is deliberately NOT executed: it registers
the pack's nodes, and we want its loader, not a second copy of its node
list.
"""
pkg = sys.modules.get(_PKG_NAME)
if pkg is not None and getattr(pkg, "__path__", [None])[0] == dirname:
return _PKG_NAME
# Pointing at a different directory: drop the cached SUBMODULES too.
# Replacing only the parent leaves `<pkg>.loader` in sys.modules, and
# import_module would hand back the old pack's code from the new path's name.
for name in [m for m in sys.modules if m == _PKG_NAME or m.startswith(_PKG_NAME + ".")]:
del sys.modules[name]
pkg = types.ModuleType(_PKG_NAME)
pkg.__path__ = [dirname]
pkg.__package__ = _PKG_NAME
sys.modules[_PKG_NAME] = pkg
return _PKG_NAME
def _load_pack_module(dirname: str, mod: str):
"""Import one module out of a custom-node package by path.
Custom node folders are not importable by name (``ComfyUI-GGUF`` is not an
identifier), so the directory is mounted as a package first and the
submodule imported from it.
"""
pkg = _ensure_pack_package(dirname)
return importlib.import_module(f"{pkg}.{mod}")
def _pack_dir() -> Optional[str]:
"""ComfyUI-GGUF's directory, identified by what it CONTAINS rather than by
its name -- the folder gets renamed by Manager, by forks, and by hand."""
try:
import folder_paths
base = os.path.join(folder_paths.base_path, "custom_nodes")
except Exception:
return None
if not os.path.isdir(base):
return None
for entry in sorted(os.listdir(base)):
if entry.endswith(".disabled"): # Manager's way of switching a pack off
continue
d = os.path.join(base, entry)
loader = os.path.join(d, "loader.py")
if not os.path.isfile(loader) or not os.path.isfile(os.path.join(d, "ops.py")):
continue
try:
with open(loader, "r", encoding="utf-8", errors="replace") as fh:
if "gguf_sd_loader" in fh.read():
return d
except OSError:
continue
return None
def backend() -> Optional[str]:
""""pack" (quantized in VRAM), "native" (dequantized at load), or None."""
if _pack_dir():
return "pack"
if importlib.util.find_spec("gguf") is not None:
return "native"
return None
UNAVAILABLE = (
"No GGUF backend is available. The `gguf` package is one of FunUI's requirements, "
"so an install that predates .gguf support just needs them re-run:\n"
" pip install -r custom_nodes/FunUI/requirements.txt\n"
"That gives a load-time dequantize: the file loads, but at its full size. To keep the "
"weights quantized in VRAM -- which is the point of GGUF -- install the ComfyUI-GGUF "
"node pack as well; FunUI uses it as the runtime and stays the only loader you wire."
)
PACK_NEEDS_GGUF = (
"ComfyUI-GGUF is installed, but the `gguf` package it needs is not. Install it in ComfyUI's Python "
"(pip install -r custom_nodes/FunUI/requirements.txt) and restart ComfyUI."
)
def load_state_dict(path: str) -> tuple[dict, dict, str]:
"""Read one .gguf into (state_dict, model_options, note).
`model_options` carries the custom operations the quantized path needs; it
is empty for the native path, whose tensors are ordinary ones by the time
they are returned.
"""
return _load(path, clip=False)
def load_clip_state_dict(path: str) -> tuple[dict, dict, str]:
"""Same as `load_state_dict`, for a text encoder.
ComfyUI-GGUF reads encoders through a separate entry point: an encoder's
GGUF carries llama.cpp tensor names that have to be mapped back to the
transformer's, which its diffusion-model reader does not do.
"""
return _load(path, clip=True)
def _load(path: str, clip: bool) -> tuple[dict, dict, str]:
"""Try the quantized backend, fall back to dequantizing, in that order.
The pack refuses architectures it has no handling for -- "Unexpected
architecture type in GGUF file: 'minimax_h3'" -- which is a reasonable
refusal on its part and a dead end for us. Since the pack DEPENDS on the
`gguf` package, its presence guarantees the fallback is available, so a
refusal costs memory rather than the load.
"""
which = backend()
if clip and which != "pack":
# Only the pack maps llama.cpp's tensor names back to a text encoder's; the native reader would hand over
# a state dict that looks loaded and is the wrong model.
raise RuntimeError("a text-encoder .gguf needs the ComfyUI-GGUF node pack to be read, and it is not installed. "
"Installing it helps only for architectures it handles (not Gemma 4, not vision files); "
"otherwise use a .safetensors encoder of the same model.")
if which is None:
raise RuntimeError(UNAVAILABLE)
if which == "pack":
d = _pack_dir()
try:
loader = _load_pack_module(d, "loader")
ops = _load_pack_module(d, "ops")
read = (getattr(loader, "gguf_clip_loader", None) if clip else None) or loader.gguf_sd_loader
sd = read(path)
if isinstance(sd, tuple): # newer ComfyUI-GGUF: (state dict, {arch_str, metadata})
sd = sd[0]
if clip:
# A vision projector (mmproj) comes back from the pack with llama.cpp's own v./mm. names and nothing
# else; built as a text encoder it would be an empty shell that "loaded". Vision tensors beside mapped
# text ones are harmless (ComfyUI loads encoders non-strictly), so only "no text keys" or unmapped
# text names (blk./token_embd) are refused.
text = [str(k) for k in sd if not str(k).startswith(("v.", "mm."))]
if not text or any(k.startswith(("blk.", "token_embd")) for k in text):
raise ValueError("it holds no text-encoder weights ComfyUI-GGUF can map (a vision projector / mmproj file?)")
options = {}
ggml_ops = getattr(ops, "GGMLOps", None)
if ggml_ops is not None:
options["custom_operations"] = ggml_ops()
return sd, options, f"gguf: quantized (ComfyUI-GGUF at {os.path.basename(d)})"
except Exception as e: # noqa: BLE001 -- any refusal is a reason to fall back
no_gguf = importlib.util.find_spec("gguf") is None
if no_gguf and isinstance(e, ModuleNotFoundError) and "gguf" in str(e):
raise RuntimeError(PACK_NEEDS_GGUF) from e
if clip:
# The native reader keeps llama.cpp's tensor names; only the pack maps them back to a text encoder's.
# Loading them anyway gives a model that looks loaded and is the wrong one (a Gemma 4 .gguf did).
raise RuntimeError(f"this text-encoder .gguf could not be loaded as one: {e}. "
"If the file is intact, its architecture is not one ComfyUI-GGUF handles (Gemma 4, vision files) "
"or ComfyUI-GGUF is out of date. Use a .safetensors encoder of the same model.") from e
if no_gguf:
raise
sd, options, note = _load_native(path, pack=True)
return sd, options, (f"gguf: ComfyUI-GGUF could not read this file ({e}) -- "
f"fell back. {note}")
return _load_native(path)
def _load_native(path: str, pack: bool = False) -> tuple[dict, dict, str]:
"""Dequantize every tensor to torch at load time using gguf's reference implementation.
Correct but not thrifty: the model ends up at its dequantized size, so
this buys the ability to LOAD a .gguf, not GGUF's memory saving. The
caller states that.
"""
import gguf
import torch
reader = gguf.GGUFReader(path)
sd: dict = {}
quantized = 0
for tensor in reader.tensors:
name = str(tensor.name)
qtype = tensor.tensor_type
if qtype in (gguf.GGMLQuantizationType.F32, gguf.GGMLQuantizationType.F16):
arr = tensor.data
else:
# gguf.quants.dequantize is the reference implementation for every
# type the package knows -- do not reimplement it per quant type.
try:
arr = gguf.quants.dequantize(tensor.data, qtype)
except (NotImplementedError, ValueError) as e:
raise RuntimeError(f"{os.path.basename(path)}: tensor {name!r} uses a storage type the gguf package cannot "
f"dequantize or is damaged ({e}).") from e
quantized += 1
t = torch.from_numpy(arr.copy())
# GGUF stores dimensions in the opposite order to torch.
shape = tuple(int(d) for d in reversed(tensor.shape))
expected = 1
for d in shape:
expected *= d
if shape and t.numel() != expected:
# Loading the tensor unshaped would hand the model a weight of the
# wrong rank and fail somewhere far from here, so stop with the
# name of the culprit.
raise RuntimeError(
f"{os.path.basename(path)}: tensor {name!r} dequantized to {t.numel()} "
f"elements but its header declares {shape} ({expected}). This container "
f"is not laid out the way the gguf package describes it.")
if shape:
t = t.reshape(shape)
if t.dtype == torch.float32 and t.numel(): # fp16 halves the size; a tensor too big for its range goes bf16 instead of inf
t = t.to(torch.float16) if t.abs().max() < 60000 else t.to(torch.bfloat16)
sd[name] = t
note = (f"gguf: dequantized at load ({quantized} quantized tensors expanded) -- the "
f"file loads, but it occupies its full size in VRAM."
+ ("" if pack else " Install ComfyUI-GGUF to keep it quantized."))
return sd, {}, note