feat(llm): grosse MoE-Modelle auf kleinen Karten — Experten in den Arbeitsspeicher

Bei MoE-Modellen (z.B. Qwen3-Coder-30B-A3B: 18,6 GB, davon 16,35 GB
Experten) rechnet pro Wort nur ein kleiner Teil. llama.cpp kann die
Experten in den RAM legen (--cpu-moe / --n-cpu-moe N), der Rest bleibt auf
der Karte.

Adapter:
- Registry-Feld cpuMoe: 0 = Karte, -1 = alle Experten in den RAM,
  N = die der ersten N Schichten; -2 beim Laden = automatisch aufteilen.
- Automatisch: liest nur den Kopf der GGUF bei HuggingFace (HTTP-Range,
  Tensor-Verzeichnis), rechnet Experten je Schicht + KV-Cache gegen den
  VRAM der Karte und legt so viele Experten auf die Karte wie passen
  (12 GB/32k: 16 von 48 Schichten auf der Karte, 32 im RAM).
- Kontext-Auswahl: GB-Schaetzung fuer die Karte ohne ausgelagerte Experten;
  slot meldet moe, cpuMoe, ramMB. KV-Rechnung als _kv_bytes() geteilt.

Diagnostic: MoE-Modell zu gross fuer die Karte → Rueckfrage "Experten
automatisch aufteilen?"; pro Karte Auswahl Experten: Karte / automatisch /
Arbeitsspeicher. Kontextwechsel rechnet die Aufteilung neu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 17:24:41 +01:00
co-authored by Claude Opus 5.5
parent 26ad872c6c
commit dd5d1fd2cb
3 changed files with 199 additions and 32 deletions
+44 -4
View File
@@ -9465,6 +9465,14 @@
rows.push(`<div style="display:grid;grid-template-columns:minmax(220px,1fr) minmax(260px,2fr) 260px 160px;gap:10px;align-items:center;">
<div><b style="color:#E0E0F0;">${escapeHtml(b.node)}</b> · Karte ${escapeHtml(sl.gpu)}
<div style="color:#555570;font-size:11px;">${escapeHtml(sl.gpuName || '')}${vramGB ? ' · ' + vramGB.toFixed(0) + ' GB' : ''}${sl.vramUsedMB >= 0 ? ' · belegt ' + (sl.vramUsedMB / 1024).toFixed(1) + ' GB' : ''}${sl.ctx ? ' · Kontext ' + sl.ctx : ''}</div>
${sl.moe ? `<div style="margin-top:4px;font-size:11px;color:#8888AA;">Experten (MoE):
<select data-box="${escapeHtml(b.instanceId)}" data-gpu="${escapeHtml(sl.gpu)}" data-model="${escapeHtml(sl.model)}" data-current="${sl.cpuMoe > 0 ? -2 : (sl.cpuMoe || 0)}"
onchange="onLlmCardMoe(this)" ${job ? 'disabled' : ''} title="Bei MoE-Modellen rechnet pro Wort nur ein Teil — die Experten dürfen in den Arbeitsspeicher. Langsamer, aber große Modelle passen so auf kleine Karten."
style="background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:2px 4px;color:#E0E0F0;font-size:11px;">
<option value="0"${!sl.cpuMoe ? ' selected' : ''}>alle auf der Karte</option>
<option value="-2"${sl.cpuMoe > 0 ? ' selected' : ''}>automatisch aufteilen</option>
<option value="-1"${sl.cpuMoe < 0 ? ' selected' : ''}>alle im Arbeitsspeicher</option>
</select>${sl.ramMB ? ' · ' + (sl.ramMB / 1024).toFixed(1).replace('.', ',') + ' GB im RAM' : ''}${sl.cpuMoe > 0 ? ' (' + sl.cpuMoe + ' Schichten)' : ''}</div>` : ''}
${sl.onCpu ? '<div style="color:#FF9F0A;font-size:11px;" title="llama.cpp findet die Karte nicht und rechnet langsam auf der CPU. Ursache meist: Treiber zu alt fuer das CUDA im llama-swap-Image. Abhilfe: auf der Box sudo ./bootstrap.sh --upgrade-driver">⚠ Modell läuft auf der CPU, Karte leer — Treiber zu alt? (bootstrap.sh --upgrade-driver)</div>' : ''}</div>
<select data-box="${escapeHtml(b.instanceId)}" data-gpu="${escapeHtml(sl.gpu)}" data-current="${escapeHtml(sl.model || '')}"
onchange="onLlmCardSelect(this)" ${job ? 'disabled' : ''}
@@ -9615,7 +9623,9 @@
if (!confirm(`${model} auf ${_boxName(target)} · Karte ${gpu} mit ${next} statt ${cur || '?'} Tokens Kontext neu laden?\n\n`
+ (more ? 'Mehr Kontext braucht mehr Grafikspeicher. Passt es nicht, setzt die Box die Karte von selbst auf den alten Stand zurück.\n\n' : '')
+ 'Die Box startet ihre Modelle dabei kurz neu. Programme bekommen den neuen Wert automatisch.')) { sel.value = cur; return; }
send({ action: 'llm_provision_model', targetInstance: target, gpu, key: m.id, hfRepo: m.hfRepo, quant: m.quant, ctx: next });
const sl = (workers.find(w => w.instanceId === target)?.slots || []).find(x => String(x.gpu) === String(gpu)) || {};
const cpuMoe = sl.cpuMoe > 0 ? -2 : (sl.cpuMoe || 0); // teilweise aufgeteilt → fuer den neuen Kontext neu rechnen
send({ action: 'llm_provision_model', targetInstance: target, gpu, key: m.id, hfRepo: m.hfRepo, quant: m.quant, ctx: next, cpuMoe });
openLlmDownloads();
}
@@ -9633,11 +9643,25 @@
if (!m || !m.hfRepo) { alert(`${next} steht nicht im Modell-Katalog — Herkunft unbekannt.`); sel.value = current; return; }
const sl = (workers.find(w => w.instanceId === target)?.slots || []).find(x => String(x.gpu) === String(gpu)) || {};
const vramGB = sl.vramMB ? sl.vramMB / 1024 : 0;
const warn = (vramGB && m.sizeGB && m.sizeGB > vramGB * 0.92)
const cpuMoe = askCpuMoe(m, sl.vramMB || 0);
const warn = (!cpuMoe && vramGB && m.sizeGB && m.sizeGB > vramGB * 0.92)
? `\n\n⚠ ${next} ist etwa ${m.sizeGB} GB groß, die Karte hat ${vramGB.toFixed(0)} GB — wahrscheinlich passt es nicht.` : '';
const repl = current ? `\n\nErsetzt ${current} — Programme, die es nutzen, verlieren es.` : '';
if (!confirm(`${next} auf ${node} · Karte ${gpu} laden?${repl}${warn}\n\nDie anderen Karten dieser Box starten dabei kurz neu.`)) { sel.value = current; return; }
send({ action: 'llm_provision_model', targetInstance: target, gpu, key: m.id, hfRepo: m.hfRepo, quant: m.quant, ctx: m.ctx });
send({ action: 'llm_provision_model', targetInstance: target, gpu, key: m.id, hfRepo: m.hfRepo, quant: m.quant, ctx: m.ctx, cpuMoe });
openLlmDownloads();
}
// MoE: Experten zwischen Karte und Arbeitsspeicher — gleiches Modell neu laden
function onLlmCardMoe(sel) {
const target = sel.dataset.box, gpu = sel.dataset.gpu, model = sel.dataset.model;
const cur = Number(sel.dataset.current || 0), next = Number(sel.value);
const m = _llmCatalog.find(x => x.id === model);
if (!m || !m.hfRepo) { alert(`${model} steht nicht im Modell-Katalog.`); sel.value = String(cur); return; }
const txt = { '0': 'alle Experten auf die Karte', '-2': 'automatisch aufteilen (so viele wie passen auf die Karte)', '-1': 'alle Experten in den Arbeitsspeicher' };
if (!confirm(`${model} neu laden: ${txt[String(next)] || next}?\n\nDie Box startet ihre Modelle dabei kurz neu.`)) { sel.value = String(cur); return; }
const sl = (workers.find(w => w.instanceId === target)?.slots || []).find(x => String(x.gpu) === String(gpu)) || {};
send({ action: 'llm_provision_model', targetInstance: target, gpu, key: m.id, hfRepo: m.hfRepo, quant: m.quant, ctx: sl.ctx || m.ctx, cpuMoe: next });
openLlmDownloads();
}
@@ -9687,6 +9711,21 @@
return out;
}
// MoE-Modelle (Mixture of Experts): pro Wort rechnet nur ein Teil — die
// "Experten" duerfen darum in den Arbeitsspeicher, ohne dass es unbrauchbar
// langsam wird. Erkennung am Namen (A3B, A4B, MoE …); sicher weiss es die Box
// erst nach dem Laden (slot.moe).
function looksMoe(m) { return /(\bA\d+(\.\d+)?B\b|moe|mixtral)/i.test((m.id || '') + ' ' + (m.hfRepo || '')); }
/** 0 = normal laden, -2 = Experten automatisch aufteilen, null = abbrechen */
function askCpuMoe(m, vramMB) {
if (!looksMoe(m) || !vramMB || !m.sizeGB || m.sizeGB * 1024 <= vramMB * 0.85) return 0;
return confirm(`${m.id} ist ein MoE-Modell (pro Wort rechnet nur ein kleiner Teil) und mit ~${m.sizeGB} GB `
+ `größer als die Karte (${(vramMB / 1024).toFixed(0)} GB).\n\n`
+ `Die Experten automatisch zwischen Karte und Arbeitsspeicher aufteilen? So viele wie passen bleiben `
+ `auf der Karte, der Rest geht in den RAM der Box. Langsamer als ganz auf der Karte, aber es läuft.\n\n`
+ `OK = aufteilen · Abbrechen = normal laden (passt dann vermutlich nicht)`) ? -2 : 0;
}
function provisionModel(i) {
const m = _llmCatalog[i];
if (!m) return;
@@ -9701,9 +9740,10 @@
!confirm(`Karte ${gpu} auf ${tgt.box.node} hält gerade ${tgt.slot.model}.\n\n`
+ `${m.id} ersetzt es. Clients, die ${tgt.slot.model} nutzen, verlieren es dann.\n`
+ `Die anderen Karten dieser Box starten dabei kurz neu.\n\nErsetzen?`)) return;
const cpuMoe = askCpuMoe(m, (tgt && tgt.slot && tgt.slot.vramMB) || 0);
const st = document.getElementById('llm-catalog-status');
if (st) { st.textContent = `Lade '${m.id}' auf ${target}… (GGUF-Download kann dauern)`; st.style.color = '#FFD60A'; }
send({ action: 'llm_provision_model', targetInstance: target, gpu: gpu || '', key: m.id, hfRepo: m.hfRepo, quant: m.quant, ctx: m.ctx });
send({ action: 'llm_provision_model', targetInstance: target, gpu: gpu || '', key: m.id, hfRepo: m.hfRepo, quant: m.quant, ctx: m.ctx, cpuMoe });
openLlmDownloads();
}
function runLlmTest() {
+1
View File
@@ -3601,6 +3601,7 @@ wss.on("connection", (ws) => {
sendToRVS_raw({ type: "llm_provision_model", payload: {
targetInstance: msg.targetInstance || "",
key: msg.key, hfRepo: msg.hfRepo, quant: msg.quant, ctx: msg.ctx, ngl: msg.ngl,
cpuMoe: msg.cpuMoe ?? 0, // MoE: 0 Karte, -1 alle Experten in den RAM, -2 automatisch verteilen
gpu: msg.gpu ?? "", // Ziel-Karte auf der Box
}, timestamp: Date.now() });
log("info", "llm", `provision '${msg.key}' (${msg.hfRepo}) → ${msg.targetInstance || "?"}`);
+154 -28
View File
@@ -226,6 +226,31 @@ def _base_config() -> dict:
return {}
def _cpu_moe_flag(v) -> str:
"""MoE-Modelle: Experten in den Arbeitsspeicher. -1 = alle (--cpu-moe),
N > 0 = die der ersten N Schichten (--n-cpu-moe N), 0/leer = alles auf die Karte.
Der Rest (Attention, gemeinsame Teile, KV-Cache) bleibt auf der Karte — pro
Wort rechnet ein MoE nur wenige Experten, darum bleibt es brauchbar schnell."""
try:
n = int(v or 0)
except (TypeError, ValueError):
return ""
if n < 0:
return " --cpu-moe"
return f" --n-cpu-moe {n}" if n > 0 else ""
def _model_cpu_moe(key: str) -> int:
for e in _load_registry():
if (e.get("key") or "").strip() == key:
try:
return int(e.get("cpuMoe") or 0)
except (TypeError, ValueError):
return 0
m = re.search(r"--n-cpu-moe\s+(\d+)", _model_cmd(key) or "")
return int(m.group(1)) if m else (-1 if "--cpu-moe" in (_model_cmd(key) or "") else 0)
def _model_cmd(key: str) -> str:
"""llama-server-Aufruf fuer ein Modell: aus der Registry (ueber die
Diagnostic geladen) oder aus dem Basis-Template (qwen3-8b & Co.)."""
@@ -234,7 +259,8 @@ def _model_cmd(key: str) -> str:
quant = (e.get("quant") or "Q4_K_M").strip()
return (f"llama-server --port ${{PORT}} --host 127.0.0.1\n"
f"-hf {e['hfRepo'].strip()}:{quant}\n"
f"-ngl {int(e.get('ngl') or 99)} -c {int(e.get('ctx') or 8192)} --jinja")
f"-ngl {int(e.get('ngl') or 99)} -c {int(e.get('ctx') or 8192)} --jinja"
+ _cpu_moe_flag(e.get("cpuMoe")))
spec = (_base_config().get("models") or {}).get(key) or {}
return str(spec.get("cmd") or "")
@@ -534,15 +560,16 @@ VRAM_OVERHEAD_MB = 1400
UBATCH = 512
def _gguf_meta(path: str) -> dict:
def _gguf_meta(path: str, fsize: int = 0) -> dict:
"""Metadaten-Block einer GGUF-Datei (gecacht). Lange String-Listen (der
Tokenizer) werden uebersprungen, nicht gespeichert."""
Tokenizer) werden uebersprungen, nicht gespeichert. fsize: wahre Groesse,
wenn path nur der Anfang der Datei ist (Kopf per HTTP-Range geholt)."""
try:
mt = os.path.getmtime(path)
except OSError:
return {}
hit = _gguf_cache.get(path)
if hit and hit[0] == mt:
if hit and hit[0] == mt and not fsize:
return hit[1]
import struct
meta: dict = {}
@@ -575,12 +602,35 @@ def _gguf_meta(path: str) -> dict:
with open(path, "rb") as f:
if f.read(4) != b"GGUF":
return {}
rd(f, "I"); rd(f, "Q")
rd(f, "I")
n_tensors = rd(f, "Q")
for _ in range(rd(f, "Q")):
k = rstr(f)
v = rval(f, rd(f, "I"))
if v is not None:
meta[k] = v
# Tensor-Verzeichnis: Groesse der MoE-Experten (…_exps) je Schicht —
# fuer die Schaetzung "was bleibt auf der Karte, was geht in den RAM"
infos = []
for _ in range(n_tensors):
name = rstr(f)
nd = rd(f, "I")
for _ in range(nd):
rd(f, "Q")
rd(f, "I")
infos.append((rd(f, "Q"), name))
align = int(meta.get("general.alignment") or 32)
data_start = (f.tell() + align - 1) // align * align
fsize = fsize or os.path.getsize(path)
infos.sort()
exp_by_layer: dict = {}
for i, (off, name) in enumerate(infos):
nxt = infos[i + 1][0] if i + 1 < len(infos) else fsize - data_start
if "_exps" in name:
mm = re.match(r"blk\.(\d+)\.", name)
lay = int(mm.group(1)) if mm else -1
exp_by_layer[lay] = exp_by_layer.get(lay, 0) + max(0, nxt - off)
meta["_expert_bytes_by_layer"] = exp_by_layer
except Exception as e:
logger.debug("GGUF %s nicht lesbar: %s", path, e)
meta = {}
@@ -588,6 +638,74 @@ def _gguf_meta(path: str) -> dict:
return meta
def _kv_bytes(meta: dict, ctx: int) -> int:
"""KV-Cache (f16) fuer ctx Tokens laut GGUF-Metadaten — Sliding-Window-
Schichten (Gemma) halten nur ihr Fenster."""
arch = meta.get("general.architecture")
if not arch:
return 0
g = lambda k, d=None: meta.get(f"{arch}.{k}", d)
n_layer = int(g("block_count", 0) or 0)
if not n_layer:
return 0
def per_layer(v, d=0):
return list(v) if isinstance(v, list) else [v if v is not None else d] * n_layer
kv_heads = per_layer(g("attention.head_count_kv", g("attention.head_count", 0)))
k_len = int(g("attention.key_length", 0) or 0)
v_len = int(g("attention.value_length", 0) or 0)
if not (k_len and v_len):
emb, heads = int(g("embedding_length", 0) or 0), int(g("attention.head_count", 0) or 0)
k_len = v_len = emb // heads if heads else 0
k_swa = int(g("attention.key_length_swa", k_len) or k_len)
v_swa = int(g("attention.value_length_swa", v_len) or v_len)
window = int(g("attention.sliding_window", 0) or 0)
pattern = g("attention.sliding_window_pattern")
swa = per_layer(pattern, False) if isinstance(pattern, list) and window else [False] * n_layer
per_tok = sum(int(kv_heads[i]) * (k_len + v_len) * 2 for i in range(n_layer) if not swa[i])
swa_tok = sum(int(kv_heads[i]) * (k_swa + v_swa) * 2 for i in range(n_layer) if swa[i])
return per_tok * ctx + swa_tok * min(ctx, window + UBATCH)
async def _auto_cpu_moe(repo: str, file: dict, vram_mb: int, ctx: int) -> int:
"""Wie viele Schichten mit Experten muessen in den RAM, damit der Rest auf
die Karte passt? Holt nur den Kopf der GGUF (Metadaten + Tensor-Verzeichnis,
wenige MB per HTTP-Range) — die Datei selbst laedt erst llama.cpp.
0 = passt komplett, -1 = alle Experten in den RAM (oder unbekannt)."""
import tempfile
url = f"https://huggingface.co/{repo}/resolve/main/{file['path']}"
headers = {"Range": "bytes=0-41943039"}
if HF_TOKEN:
headers["Authorization"] = f"Bearer {HF_TOKEN}"
try:
async with httpx.AsyncClient(timeout=60, follow_redirects=True) as c:
r = await c.get(url, headers=headers)
r.raise_for_status()
with tempfile.NamedTemporaryFile(suffix=".gguf", delete=False) as tf:
tf.write(r.content)
tmp = tf.name
try:
meta = _gguf_meta(tmp, fsize=int(file.get("size") or 0))
finally:
_gguf_cache.pop(tmp, None)
os.unlink(tmp)
except Exception as e:
logger.warning("MoE-Aufteilung fuer %s nicht berechenbar (%s) — alle Experten in den RAM", repo, e)
return -1
exp = meta.get("_expert_bytes_by_layer") or {}
if not exp:
return 0 # kein MoE
total_mb = int(file.get("size") or 0) / 1048576
exp_mb = sum(exp.values()) / 1048576
budget = vram_mb * 0.92 - VRAM_OVERHEAD_MB - _kv_bytes(meta, ctx) / 1048576 - (total_mb - exp_mb)
per_layer = exp_mb / len(exp)
on_gpu = max(0, int(budget // per_layer)) if per_layer else 0
n = max(0, len(exp) - on_gpu)
logger.info("MoE %s: %d von %d Schichten mit Experten in den RAM (Karte %d MB, ctx %d)",
repo, n, len(exp), vram_mb, ctx)
return -1 if n >= len(exp) else n
def _model_files(key: str) -> tuple:
"""(Gewichte.gguf, mmproj.gguf|None) eines Modells im HF-Cache unter /models
— aus "-hf org/repo:QUANT" bzw. "-m pfad" im llama-server-Aufruf."""
@@ -624,35 +742,29 @@ def _ctx_profile(key: str) -> dict:
if not n_layer:
return {}
def per_layer(v, d=0):
return list(v) if isinstance(v, list) else [v if v is not None else d] * n_layer
kv_heads = per_layer(g("attention.head_count_kv", g("attention.head_count", 0)))
k_len = int(g("attention.key_length", 0) or 0)
v_len = int(g("attention.value_length", 0) or 0)
if not (k_len and v_len):
emb, heads = int(g("embedding_length", 0) or 0), int(g("attention.head_count", 0) or 0)
k_len = v_len = emb // heads if heads else 0
k_swa = int(g("attention.key_length_swa", k_len) or k_len)
v_swa = int(g("attention.value_length_swa", v_len) or v_len)
window = int(g("attention.sliding_window", 0) or 0)
pattern = g("attention.sliding_window_pattern")
swa = per_layer(pattern, False) if isinstance(pattern, list) else [False] * n_layer
if not window:
swa = [False] * n_layer
per_tok = sum(int(kv_heads[i]) * (k_len + v_len) * 2 for i in range(n_layer) if not swa[i])
swa_tok = sum(int(kv_heads[i]) * (k_swa + v_swa) * 2 for i in range(n_layer) if swa[i])
files_mb = (os.path.getsize(weights) + (os.path.getsize(mmproj) if mmproj else 0)) / 1048576
ctx_max = int(g("context_length", 0) or 0)
exp_by_layer = meta.get("_expert_bytes_by_layer") or {}
moe = bool(exp_by_layer) or int(g("expert_count", 0) or 0) > 0
cpu_moe = _model_cpu_moe(key) if moe else 0
if cpu_moe:
layers = sorted(exp_by_layer) if cpu_moe < 0 else sorted(exp_by_layer)[:cpu_moe]
ram_mb = sum(exp_by_layer[l] for l in layers) / 1048576
else:
ram_mb = 0.0
gpu_files_mb = files_mb - ram_mb
steps = [c for c in CTX_STEPS if not ctx_max or c <= ctx_max]
if ctx_max and ctx_max not in steps:
steps.append(ctx_max) # krummes Maximum (z.B. 40960) mit anbieten
opts = []
for c in steps:
kv_mb = (per_tok * c + swa_tok * min(c, window + UBATCH)) / 1048576
opts.append({"ctx": c, "estMB": int(files_mb + kv_mb + VRAM_OVERHEAD_MB)})
return {"ctxMax": ctx_max, "options": opts}
kv_mb = _kv_bytes(meta, c) / 1048576
opts.append({"ctx": c, "estMB": int(gpu_files_mb + kv_mb + VRAM_OVERHEAD_MB)})
out = {"ctxMax": ctx_max, "options": opts, "moe": moe, "cpuMoe": cpu_moe}
if moe:
out["expertsMB"] = int(sum(exp_by_layer.values()) / 1048576)
out["ramMB"] = int(ram_mb)
return out
async def _vram_used() -> dict:
@@ -1112,10 +1224,24 @@ async def _handle_provision(ws, payload: dict) -> None:
if quant != wanted.upper():
quant_note = f"{wanted} gibt es in {repo} nicht — nehme {quant}"
logger.info("Karte %s: %s", card, quant_note)
ctx_req = int(payload.get("ctx") or 8192)
try:
cpu_moe = int(payload.get("cpuMoe") or 0)
except (TypeError, ValueError):
cpu_moe = 0
if cpu_moe == -2:
# "automatisch verteilen": so viele Experten auf die Karte wie passen
qfiles = _files_for_quant(repo_files, quant) if repo_files else []
vram = int(((await _gpu_names()).get(card) or {}).get("vramMB") or 0)
if len(qfiles) == 1 and vram:
cpu_moe = await _auto_cpu_moe(repo, qfiles[0], vram, ctx_req)
else:
cpu_moe = -1 # gesplittete Datei / Karte unbekannt: alle in den RAM
entry = {
"key": key, "hfRepo": repo, "quant": quant,
"ctx": int(payload.get("ctx") or 8192),
"ctx": ctx_req,
"ngl": int(payload.get("ngl") or 99),
"cpuMoe": cpu_moe,
}
_save_registry([e for e in _load_registry() if e.get("key") != key] + [entry])
elif not _model_cmd(key):