4e5a7eed35
a) Update-Meldungen: generischer Release-Summarizer in Lucys Stimme mit
strukturiertem Aktions-Verdikt ("Musst du etwas tun? NEIN/JA") für
Hermes, Engine (llama.cpp) und llama-swap; Fangnetz-Hinweis verheiratet
Breaking-Change-Sorge mit dem Postcheck.
b) OS ehrlich: zurückgestellte Pakete (Phasen-Rollout / kept back) werden
ausgewiesen statt scheinbar zu hängen.
d) Ehrliche Speicher-Zahlen: KV-Cache aus echten GGUF-Architektur-Daten
(Layer × KV-Köpfe × head_dim) + KV-Quant aus dem cmd statt params-blinder
Schätzung — footprint_gb als eine Zahlensprache (Zentrale, Modell-Manager,
fits-Check auf warmset+largest). Auto-Rewarm-Nudge nach Config-Reload.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
267 lines
11 KiB
Python
267 lines
11 KiB
Python
"""
|
||
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: "<B", _T_INT8: "<b", _T_UINT16: "<H", _T_INT16: "<h",
|
||
_T_UINT32: "<I", _T_INT32: "<i", _T_FLOAT32: "<f", _T_BOOL: "<?",
|
||
_T_UINT64: "<Q", _T_INT64: "<q", _T_FLOAT64: "<d",
|
||
}
|
||
_SCALAR_SIZE = {t: struct.calcsize(f) for t, f in _SCALAR_FMT.items()}
|
||
|
||
_WANT_STRINGS = {"tokenizer.ggml.model", "tokenizer.ggml.pre", "general.architecture"}
|
||
|
||
|
||
class _Reader:
|
||
def __init__(self, f):
|
||
self.f = f
|
||
|
||
def read(self, n: int) -> 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("<I", self.read(4))[0]
|
||
|
||
def u64(self) -> int:
|
||
return struct.unpack("<Q", self.read(8))[0]
|
||
|
||
def gstr(self) -> 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)
|