From ae79d9e8f624fc9e40cdc81aff3c09e42deba8e2 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sun, 18 Oct 2026 16:43:55 +0200 Subject: [PATCH] fix(llm): Modelle mit anderer Quantisierung laden (Gemma 4 von unsloth) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Laden von unsloth/gemma-4-12B-it-qat-GGUF scheiterte mit "500": der Katalog trug pauschal Q4_K_M ein, das Repo hat aber nur UD-Q4_K_XL. llama-server fand die Datei nicht und beendete sich sofort; llama-swap reicht den Grund nicht weiter ("upstream command exited prematurely"), es kam nur "500" an. llm-adapter: fragt vor dem Laden die Dateiliste des Repos ab. Gibt es die gewuenschte Quantisierung nicht, nimmt er die naechstbeste aus einer festen Reihenfolge (Q4_K_M, UD-Q4_K_XL, Q4_K_XL, Q4_K_S, IQ4_XS, …) und sagt das im Download-Fenster an. Kein ladbares GGUF im Repo → sofortige Absage, die Karte bleibt unberuehrt. Bild-Projektor (mmproj), MTP-Zusatzdateien und imatrix zaehlen nicht als Modell. Endet llama-server trotzdem sofort, nennt die Meldung die haeufigsten Gruende statt nur "500". Diagnostic: "Von HuggingFace aktualisieren" traegt pro HF-Repo die tatsaechlich vorhandene Quantisierung und die echte Groesse ein (bisher 0 GB) — auch bei schon vorhandenen HF-Eintraegen. Repos ohne ladbares Modell kommen nicht mehr in den Katalog bzw. werden als "nicht ladbar" markiert. Katalog zeigt die Quantisierung pro Modell. Gemma 4 selbst: llama.cpp kann es seit Juni (#24077), der Stand auf der Box ist vom 14.08. — es lag nur an der Datei. Getestet gegen echte HF-Repos (Gemma → UD-Q4_K_XL 6,7 GB, Qwen/Mistral bleiben Q4_K_M) in Adapter und Diagnostic identisch, und gegen llama-swap: Ausweichen angesagt + geladen; Repo ohne GGUF → Absage ohne Download. Co-Authored-By: Claude Opus 5.5 --- diagnostic/index.html | 6 +- diagnostic/server.js | 63 +++++++++++++++++++- xtts/llm-adapter/adapter.py | 114 +++++++++++++++++++++++++++++------- 3 files changed, 157 insertions(+), 26 deletions(-) diff --git a/diagnostic/index.html b/diagnostic/index.html index fb27c31..5248363 100644 --- a/diagnostic/index.html +++ b/diagnostic/index.html @@ -8980,7 +8980,10 @@ : ''; return '' + '' + escapeHtml(m.id) + '' + - '' + escapeHtml(m.description || m.hfRepo || '') + '' + + '' + escapeHtml(m.description || m.hfRepo || '') + + (m.quant ? ' · ' + escapeHtml(m.quant) + '' : '') + + (m.unusable ? ' · nicht ladbar' : '') + + '' + '' + vram + '' + '' + haveTxt + '' + '' + @@ -9076,6 +9079,7 @@ ${bar}
${line}
${sub ? `
${sub}
` : ''} + ${j.note && j.phase !== 'error' ? `
ℹ ${escapeHtml(j.note)}
` : ''} `; }).join(''); } diff --git a/diagnostic/server.js b/diagnostic/server.js index 00225f3..d6bef07 100644 --- a/diagnostic/server.js +++ b/diagnostic/server.js @@ -473,8 +473,50 @@ function saveLlmCatalog(arr) { function slugModelId(repo) { return String(repo).toLowerCase().replace(/^.*\//, "").replace(/-gguf$/,"").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "model"; } +// ── Quantisierung pro Repo ────────────────────────────────────────── +// Nicht jedes Repo hat eine Q4_K_M-Datei (unsloth z.B. "UD-Q4_K_XL"). Steht +// eine Quantisierung im Katalog, die es nicht gibt, beendet sich llama-server +// sofort und die Box meldet nur "500". Darum fragen wir pro Repo die Dateiliste +// ab und nehmen die beste vorhandene — dieselbe Reihenfolge wie im llm-adapter. +const QUANT_PREFERENCE = ["Q4_K_M", "UD-Q4_K_XL", "Q4_K_XL", "Q4_K_S", "IQ4_XS", "IQ4_NL", "Q4_0", + "Q5_K_M", "UD-Q5_K_XL", "Q6_K", "Q8_0"]; +const QUANT_RE = /((?:UD-)?(?:IQ[1-4]_(?:XXS|XS|S|M|NL)|Q[2-8]_K(?:_(?:XL|L|M|S))?|Q[4-8]_[01]|BF16|F16|F32))/gi; +const SPLIT_RE = /-(\d{5})-of-(\d{5})\.gguf$/i; +function ggufQuant(p) { + const base = String(p).split("/").pop().replace(SPLIT_RE, ".gguf").replace(/\.gguf$/i, ""); + const m = base.match(QUANT_RE); + return m ? m[m.length - 1].toUpperCase() : ""; +} +function isMainGguf(p) { + const low = String(p).toLowerCase(); + const base = low.split("/").pop(); + return low.endsWith(".gguf") && !base.includes("mmproj") && !base.startsWith("mtp") + && !("/" + low).includes("/mtp/") && !base.includes("imatrix"); +} +// → { quant, sizeGB, quants } oder null (Repo nicht lesbar / kein Modell darin) +async function hfRepoQuant(repo, wanted = "Q4_K_M") { + try { + const r = await fetch(`https://huggingface.co/api/models/${repo}/tree/main?recursive=1`, + { headers: { "User-Agent": "aria-diagnostic" } }); + if (!r.ok) return null; + const files = (await r.json()).filter(e => e.type === "file" && isMainGguf(e.path)) + .map(e => ({ path: e.path, size: (e.lfs && e.lfs.size) || e.size || 0, quant: ggufQuant(e.path) })); + const have = new Set(files.map(f => f.quant).filter(Boolean)); + const quant = have.has(String(wanted).toUpperCase()) ? String(wanted).toUpperCase() + : QUANT_PREFERENCE.find(q => have.has(q)) || ""; + if (!quant) return null; + const match = files.filter(f => f.quant === quant); + const parts = match.some(f => SPLIT_RE.test(f.path)) ? match.filter(f => SPLIT_RE.test(f.path)) + : [match.reduce((a, b) => (a.path.length <= b.path.length ? a : b))]; + const bytes = parts.reduce((n, f) => n + f.size, 0); + return { quant, sizeGB: Math.round(bytes / 1e8) / 10, quants: [...have].sort() }; + } catch { return null; } +} + // Holt populaere GGUF-Modelle von der HuggingFace-API und merged sie in den -// Katalog (kuratierte Eintraege + Beschreibungen bleiben erhalten). +// Katalog (kuratierte Eintraege + Beschreibungen bleiben erhalten). Fuer jedes +// HF-Repo wird die TATSAECHLICH vorhandene Quantisierung und Groesse +// eingetragen — auch bei schon vorhandenen HF-Eintraegen. async function refreshLlmCatalogFromHF() { const url = "https://huggingface.co/api/models?search=GGUF&sort=downloads&direction=-1&limit=40"; const r = await fetch(url, { headers: { "User-Agent": "aria-diagnostic" } }); @@ -489,9 +531,24 @@ async function refreshLlmCatalogFromHF() { const id = slugModelId(repo); if (byId.has(id)) continue; // kuratierte/vorhandene nicht ueberschreiben const entry = { id, hfRepo: repo, quant: "Q4_K_M", ctx: 8192, sizeGB: 0, - description: `HuggingFace · ${(m.downloads || 0).toLocaleString("de")} Downloads`, source: "hf" }; - byId.set(id, entry); added++; + description: `HuggingFace · ${(m.downloads || 0).toLocaleString("de")} Downloads`, source: "hf", _new: true }; + byId.set(id, entry); } + // Quantisierung + Groesse pruefen: alle HF-Eintraege (kuratierte haben + // gepflegte Werte). Je 6 gleichzeitig, damit HF nicht drosselt. + const toCheck = [...byId.values()].filter(e => e.source === "hf"); + for (let i = 0; i < toCheck.length; i += 6) { + await Promise.all(toCheck.slice(i, i + 6).map(async e => { + const q = await hfRepoQuant(e.hfRepo, e.quant || "Q4_K_M"); + if (!q) { + // Kein ladbares Modell im Repo: neue nicht aufnehmen, alte markieren. + if (e._new) byId.delete(e.id); else e.unusable = true; + return; + } + e.quant = q.quant; e.sizeGB = q.sizeGB; e.quants = q.quants; delete e.unusable; + })); + } + for (const e of byId.values()) { if (e._new) { added++; delete e._new; } } const merged = Array.from(byId.values()); saveLlmCatalog(merged); return { models: merged, added }; diff --git a/xtts/llm-adapter/adapter.py b/xtts/llm-adapter/adapter.py index 45dd5cb..dfe9a90 100644 --- a/xtts/llm-adapter/adapter.py +++ b/xtts/llm-adapter/adapter.py @@ -645,8 +645,33 @@ _INPROGRESS = ".downloadInProgress" _SPLIT_RE = re.compile(r"-(\d{5})-of-(\d{5})\.gguf$", re.IGNORECASE) -async def _hf_expected_files(repo: str, quant: str) -> list: - """Welche GGUF-Dateien zieht 'repo:quant'? [{path, oid, size}]. Fail-soft: [].""" +# Quantisierung aus einem Dateinamen: "gemma-4-12B-it-qat-UD-Q4_K_XL.gguf" → "UD-Q4_K_XL" +_QUANT_RE = re.compile(r"(?i)((?:UD-)?(?:IQ[1-4]_(?:XXS|XS|S|M|NL)|Q[2-8]_K(?:_(?:XL|L|M|S))?|Q[4-8]_[01]|BF16|F16|F32))") +# Wenn die gewuenschte fehlt: die erste vorhandene aus dieser Liste. 4-Bit zuerst +# (beste Groesse/Qualitaet fuer 12-GB-Karten), dann groessere. +QUANT_PREFERENCE = ["Q4_K_M", "UD-Q4_K_XL", "Q4_K_XL", "Q4_K_S", "IQ4_XS", "IQ4_NL", "Q4_0", + "Q5_K_M", "UD-Q5_K_XL", "Q6_K", "Q8_0"] + + +def _quant_of(path: str) -> str: + base = os.path.basename(path) + base = _SPLIT_RE.sub(".gguf", base) + m = _QUANT_RE.findall(base[:-5] if base.lower().endswith(".gguf") else base) + return m[-1].upper() if m else "" + + +def _is_main_gguf(path: str) -> bool: + """Das eigentliche Modell — nicht Bild-Projektor (mmproj), nicht die + Zusatzdatei fuers spekulative Dekodieren (mtp), keine imatrix.""" + low = path.lower() + base = os.path.basename(low) + return (low.endswith(".gguf") and "mmproj" not in base and not base.startswith("mtp") + and "/mtp/" not in ("/" + low) and "imatrix" not in base) + + +async def _hf_repo_files(repo: str): + """Alle Modell-GGUFs eines Repos: [{path, oid, size, quant}] — oder None, + wenn Hugging Face nicht erreichbar ist (dann pruefen wir eben nicht).""" url = f"https://huggingface.co/api/models/{repo}/tree/main?recursive=1" headers = {"Authorization": f"Bearer {HF_TOKEN}"} if HF_TOKEN else {} try: @@ -655,28 +680,42 @@ async def _hf_expected_files(repo: str, quant: str) -> list: r.raise_for_status() entries = r.json() except Exception as e: - logger.warning("HF-Dateiliste fuer %s nicht abrufbar (%s) — Fortschritt ohne Gesamtgroesse", repo, e) - return [] - q = (quant or "").lower() - files = [] + logger.warning("HF-Dateiliste fuer %s nicht abrufbar (%s)", repo, e) + return None + out = [] for e in entries if isinstance(entries, list) else []: path = e.get("path") or "" - low = path.lower() - if e.get("type") != "file" or not low.endswith(".gguf") or "mmproj" in low: - continue - if q and q not in low: + if e.get("type") != "file" or not _is_main_gguf(path): continue lfs = e.get("lfs") or {} - files.append({"path": path, "oid": lfs.get("oid") or e.get("oid") or "", - "size": int(lfs.get("size") or e.get("size") or 0)}) - if not files: - return [] - # Gesplittetes Modell → alle Teile; sonst die eine passende Datei (bei - # mehreren Treffern die mit dem kuerzesten Namen, z.B. ohne "-imat"). - split = [f for f in files if _SPLIT_RE.search(f["path"])] + out.append({"path": path, "oid": lfs.get("oid") or e.get("oid") or "", + "size": int(lfs.get("size") or e.get("size") or 0), "quant": _quant_of(path)}) + return out + + +def _pick_quant(files: list, wanted: str) -> str: + """Die gewuenschte Quantisierung, wenn es sie gibt — sonst die erste aus + QUANT_PREFERENCE, die das Repo hat. "" wenn gar nichts Brauchbares da ist.""" + have = {f["quant"] for f in files if f["quant"]} + if wanted and wanted.upper() in have: + return wanted.upper() + return next((q for q in QUANT_PREFERENCE if q in have), "") + + +def _files_for_quant(files: list, quant: str) -> list: + """Die Datei(en), die llama.cpp fuer repo:quant zieht. Gesplittet → alle + Teile; sonst die eine (bei mehreren die mit dem kuerzesten Namen).""" + match = [f for f in files if f["quant"] == quant.upper()] + split = [f for f in match if _SPLIT_RE.search(f["path"])] if split: return split - return [min(files, key=lambda f: len(f["path"]))] + return [min(match, key=lambda f: len(f["path"]))] if match else [] + + +async def _hf_expected_files(repo: str, quant: str) -> list: + """Welche GGUF-Dateien zieht 'repo:quant'? [{path, oid, size}]. Fail-soft: [].""" + files = await _hf_repo_files(repo) + return _files_for_quant(files, quant) if files else [] def _scan_download(oids: set) -> tuple: @@ -792,10 +831,32 @@ async def _handle_provision(ws, payload: dict) -> None: return await _provision_result( ws, key, False, gpu=card, error=f"{key} liegt schon auf Karte {elsewhere} dieser Box — ein Modell pro Box nur einmal.") + quant_note = "" + repo_files = None if repo: + wanted = (payload.get("quant") or "Q4_K_M").strip() + quant = wanted + # Gibt es die Datei ueberhaupt? Fehlt sie, beendet sich llama-server + # sofort und llama-swap meldet nur "500" — der Grund geht verloren. + # Lieber vorher pruefen und ggf. die naechstbeste Quantisierung nehmen + # (unsloth z.B. hat statt Q4_K_M ein UD-Q4_K_XL). + repo_files = await _hf_repo_files(repo) + if repo_files is not None: + if not repo_files: + return await _provision_result( + ws, key, False, gpu=card, + error=f"In {repo} gibt es keine GGUF-Modelldatei.") + quant = _pick_quant(repo_files, wanted) + if not quant: + have = sorted({f["quant"] for f in repo_files if f["quant"]}) or ["?"] + return await _provision_result( + ws, key, False, gpu=card, + error=f"{repo}: keine brauchbare Quantisierung. Vorhanden: {', '.join(have)}") + if quant != wanted.upper(): + quant_note = f"{wanted} gibt es in {repo} nicht — nehme {quant}" + logger.info("Karte %s: %s", card, quant_note) entry = { - "key": key, "hfRepo": repo, - "quant": (payload.get("quant") or "Q4_K_M").strip(), + "key": key, "hfRepo": repo, "quant": quant, "ctx": int(payload.get("ctx") or 8192), "ngl": int(payload.get("ngl") or 99), } @@ -809,9 +870,10 @@ async def _handle_provision(ws, payload: dict) -> None: _generate_config() quant = next((e.get("quant") for e in _load_registry() if e.get("key") == key), "") or "Q4_K_M" - files = await _hf_expected_files(repo, quant) if repo else [] + files = _files_for_quant(repo_files, quant) if repo_files else [] await _emit_progress(ws, key, "start", total=sum(f["size"] for f in files), - files=len(files), gpu=card, replaces=previous) + files=len(files), gpu=card, replaces=previous, quant=quant, + note=quant_note) await _reload_llama() await _announce(ws) stop = asyncio.Event() @@ -842,6 +904,14 @@ async def _handle_provision(ws, payload: dict) -> None: # ein kaputtes Modell in der Config, und jedes Neuladen von llama-swap # risse die anderen Karten mit. err = (res.get("error") or "Laden fehlgeschlagen")[:200] + if "500" in err and dt < 120: + # llama-swap reicht den Grund nicht weiter ("upstream command exited + # prematurely"). Kam der Abbruch schnell, lief kein Download — dann ist + # es fast immer eins von dreien. + err = ("llama.cpp hat sich beim Start sofort beendet. Haeufigste Gruende: " + "die Modell-Architektur ist neuer als die llama.cpp-Version der Box, " + "zu wenig (V)RAM, oder die Datei passt nicht. (llama-swap gibt den " + "genauen Grund nicht weiter.)") slots = _load_slots() if previous: slots[card] = previous