""" GGUF-Tokenizer-Fingerprint — liest die Tokenizer-Identität direkt aus dem GGUF-Header (ohne das Modell zu laden), um zu entscheiden, ob ein Draft-Modell **vocab-kompatibel** mit einem Ziel-Modell ist (Voraussetzung für Speculative Decoding in llama.cpp — sonst: "draft model vocab type must match target"). Wir lesen nur die Metadaten-KV-Sektion am Dateianfang und brechen ab, sobald `tokenizer.ggml.tokens` erreicht ist (dessen Länge = n_vocab). model+pre+n_vocab identifizieren den Tokenizer eindeutig genug, um die in der Praxis relevanten Fälle zu unterscheiden (Qwen2.5 vs Qwen3 vs Qwen3.6 etc.). Die llama.cpp-Prüfung beim Laden bleibt der letzte Schiedsrichter. """ import hashlib import struct from functools import lru_cache # GGUF value types (https://github.com/ggml-org/ggml/blob/master/docs/gguf.md) _T_UINT8, _T_INT8, _T_UINT16, _T_INT16, _T_UINT32, _T_INT32, _T_FLOAT32, \ _T_BOOL, _T_STRING, _T_ARRAY, _T_UINT64, _T_INT64, _T_FLOAT64 = range(13) _SCALAR_FMT = { _T_UINT8: " bytes: b = self.f.read(n) if len(b) != n: raise EOFError("unerwartetes Dateiende beim GGUF-Parsen") return b def u32(self) -> int: return struct.unpack(" int: return struct.unpack(" str: n = self.u64() return self.read(n).decode("utf-8", "replace") def scalar(self, vtype: int): """Liest einen Skalar-Wert (für die Architektur-Metadaten). None bei Nicht-Skalar.""" fmt = _SCALAR_FMT.get(vtype) if not fmt: self.skip_value(vtype) return None return struct.unpack(fmt, self.read(_SCALAR_SIZE[vtype]))[0] def skip_value(self, vtype: int) -> None: """Liest einen Wert und verwirft ihn (um den Datei-Pointer korrekt weiterzuschieben). Arrays werden elementweise konsumiert.""" if vtype == _T_STRING: self.f.seek(self.u64(), 1) elif vtype in _SCALAR_SIZE: self.f.seek(_SCALAR_SIZE[vtype], 1) elif vtype == _T_ARRAY: etype = self.u32() count = self.u64() if etype == _T_STRING: for _ in range(count): self.f.seek(self.u64(), 1) elif etype in _SCALAR_SIZE: self.f.seek(_SCALAR_SIZE[etype] * count, 1) else: raise ValueError(f"unbekannter Array-Elementtyp {etype}") else: raise ValueError(f"unbekannter GGUF-Wertetyp {vtype}") def _read_fingerprint(path: str) -> dict | None: """Liest model/pre/n_vocab aus dem GGUF-Header. None bei Fehler/kein GGUF.""" try: with open(path, "rb") as fh: r = _Reader(fh) if r.read(4) != b"GGUF": return None r.u32() # version r.u64() # tensor_count kv_count = r.u64() fp: dict = {"model": None, "pre": None, "arch": None, "n_vocab": None, "tokens_sha": None} for _ in range(kv_count): key = r.gstr() vtype = r.u32() if key == "tokenizer.ggml.tokens" and vtype == _T_ARRAY: etype = r.u32() count = r.u64() fp["n_vocab"] = count if etype != _T_STRING: return None # ECHTE Vocab-Identität: sha256 über die tatsächliche Token-Liste # (familienunabhängig — funktioniert für Qwen, Llama, Mistral, …). h = hashlib.sha256() h.update(count.to_bytes(8, "little")) for _ in range(count): n = r.u64() h.update(r.read(n)) fp["tokens_sha"] = h.hexdigest() # model/pre kommen vor tokens → wir haben alles. Abbrechen. break if key in _WANT_STRINGS and vtype == _T_STRING: val = r.gstr() if key == "tokenizer.ggml.model": fp["model"] = val elif key == "tokenizer.ggml.pre": fp["pre"] = val else: fp["arch"] = val else: r.skip_value(vtype) if fp["model"] is None and fp["n_vocab"] is None: return None return fp except (OSError, EOFError, ValueError, struct.error): return None @lru_cache(maxsize=256) def _cached(path: str, mtime: float, size: int) -> tuple | None: fp = _read_fingerprint(path) if fp is None: return None return (fp.get("model"), fp.get("pre"), fp.get("n_vocab"), fp.get("arch"), fp.get("tokens_sha")) def fingerprint(path: str) -> dict | None: """Tokenizer-Fingerprint eines GGUF (gecacht nach Pfad+mtime+size). Returns dict(model, pre, n_vocab, arch, tokens_sha) oder None wenn nicht lesbar.""" import os try: st = os.stat(path) except OSError: return None t = _cached(path, st.st_mtime, st.st_size) if t is None: return None return {"model": t[0], "pre": t[1], "n_vocab": t[2], "arch": t[3], "tokens_sha": t[4]} def vocab_key(path: str) -> tuple | None: """ECHTER Vergleichsschlüssel für Vocab-Kompatibilität: (model, pre, n_vocab, sha256 der vollständigen Token-Liste). Vergleicht den TATSÄCHLICHEN Vokabular-Inhalt, nicht nur Metadaten — familienunabhängig (Qwen, Llama, Mistral, …). Genau diese Identität verlangt llama.cpp für Speculative Decoding.""" fp = fingerprint(path) if not fp or fp["n_vocab"] is None or not fp.get("tokens_sha"): return None return (fp["model"], fp["pre"], fp["n_vocab"], fp["tokens_sha"]) def compatible(target_path: str, draft_path: str) -> bool | None: """True/False ob draft vocab-kompatibel zum target ist. None = unbestimmbar (eine Datei nicht lesbar) → UI behandelt das als 'nicht bestätigt'.""" a = vocab_key(target_path) b = vocab_key(draft_path) if a is None or b is None: return None return a == b # ── Architektur-Metadaten für EHRLICHE KV-Cache-Größen ────────────────────────────── # Der KV-Cache hängt an (Layer × KV-Heads × Head-Dim), NICHT an den Gesamt-Parametern. # Bei MoE (z.B. Qwen3.6-35B-A3B) ist das entscheidend: die alte params-basierte Schätzung # überschätzte grob (aktive vs. gesamte Params + GQA), reale KV liest man direkt hier. # Schlüssel sind arch-präfixiert ('qwen3moe.block_count', 'llama.attention.head_count_kv' …), # gegen echte GGUFs verifiziert. Wir sammeln die gewünschten Skalar-Schlüssel per Suffix. _ARCH_WANT = ( ".block_count", ".attention.head_count_kv", ".attention.head_count", ".attention.key_length", ".attention.value_length", ".embedding_length", ".context_length", ) def _read_arch_meta(path: str) -> dict | None: try: with open(path, "rb") as fh: r = _Reader(fh) if r.read(4) != b"GGUF": return None r.u32() # version r.u64() # tensor_count kv_count = r.u64() raw: dict = {} arch = None for _ in range(kv_count): key = r.gstr() vtype = r.u32() if key == "general.architecture" and vtype == _T_STRING: arch = r.gstr() continue if key == "tokenizer.ggml.tokens": break # Arch-Metadaten stehen davor → fertig, Rest überspringen suf = next((s for s in _ARCH_WANT if key.endswith(s)), None) if suf is not None and vtype in _SCALAR_SIZE: raw[suf] = r.scalar(vtype) else: r.skip_value(vtype) n_layers = raw.get(".block_count") n_head = raw.get(".attention.head_count") n_head_kv = raw.get(".attention.head_count_kv") or n_head # GQA fehlt → MHA n_embd = raw.get(".embedding_length") hd_k = raw.get(".attention.key_length") \ or (int(n_embd / n_head) if (n_embd and n_head) else None) hd_v = raw.get(".attention.value_length") or hd_k if not (n_layers and n_head_kv and hd_k and hd_v): return None # unvollständig → Aufrufer nutzt Heuristik-Fallback return {"arch": arch, "n_layers": int(n_layers), "n_head_kv": int(n_head_kv), "head_dim_k": int(hd_k), "head_dim_v": int(hd_v), "n_ctx_train": int(raw[".context_length"]) if raw.get(".context_length") else None} except (OSError, EOFError, ValueError, struct.error): return None @lru_cache(maxsize=128) def _arch_cached(path: str, mtime: float, size: int) -> dict | None: return _read_arch_meta(path) def arch_meta(path: str) -> dict | None: """Architektur-Metadaten eines GGUF (gecacht nach Pfad+mtime+size): {arch, n_layers, n_head_kv, head_dim_k, head_dim_v, n_ctx_train}. None wenn nicht lesbar oder unvollständig.""" import os try: st = os.stat(path) except OSError: return None return _arch_cached(path, st.st_mtime, st.st_size) # Bytes pro KV-Cache-Element je cache-type (inkl. Block-Overhead der k-Quants). _KV_BPE = { "f32": 4.0, "f16": 2.0, "bf16": 2.0, "q8_0": 1.0625, "q5_1": 0.75, "q5_0": 0.6875, "q4_1": 0.625, "q4_0": 0.5625, "iq4_nl": 0.5625, } _GIB = 1024 ** 3 def _bpe(cache_type: str | None) -> float: return _KV_BPE.get((cache_type or "f16").lower(), 2.0) def kv_cache_gb(meta: dict, ctx: int, ck: str | None = None, cv: str | None = None) -> float: """Echte KV-Cache-Größe (GiB) für ctx Tokens, K/V ggf. quantisiert. Formel wie llama.cpp: je Layer & Token hält der Cache n_head_kv × head_dim Elemente für K und für V.""" per_tok = meta["n_layers"] * meta["n_head_kv"] * ctx k = per_tok * meta["head_dim_k"] * _bpe(ck) v = per_tok * meta["head_dim_v"] * _bpe(cv) return (k + v) / _GIB def kv_gb_per_token(meta: dict, ck: str | None = None, cv: str | None = None) -> float: """KV-GiB pro Kontext-Token — für den analytischen ctx-Solver (linear in ctx).""" return kv_cache_gb(meta, 1, ck, cv)