feat(llm): Speicherplatz vor dem Download pruefen, klarer Aufteilen-Dialog

llm-adapter: Vor jeder Aenderung an Registry/Belegung rechnet die Box aus,
was vom Modell noch fehlt (fertige Blobs zaehlen nicht), und vergleicht es
mit dem freien Platz minus Puffer (LLM_DISK_RESERVE_GB, Standard 2). Reicht
es nicht, werden halbe Downloads, an denen seit 30 min niemand schreibt,
geloescht und neu gerechnet; reicht es dann immer noch nicht, bricht das
Laden mit klarer Meldung im Download-Fenster ab ("braucht ca. X GB, frei
sind Y GB"). Frueher brach llama.cpp mitten im Download ab, und der Grund
kam ueber llama-swap nicht an.

Diagnostic: Die MoE-Frage war ein confirm(), bei dem "Abbrechen" "ganz auf
die Karte laden" hiess. Jetzt ein Dialog mit drei Knoepfen: Aufteilen
(empfohlen) / Ganz auf die Karte / Abbrechen (laedt nichts). Esc und Klick
daneben brechen ab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 15:12:42 +01:00
co-authored by Claude Opus 5.5
parent 1e5afe7f65
commit 739ab85deb
2 changed files with 100 additions and 11 deletions
+35 -11
View File
@@ -9740,7 +9740,7 @@
openLlmDownloads();
}
function onLlmCardSelect(sel) {
async function onLlmCardSelect(sel) {
const target = sel.dataset.box, gpu = sel.dataset.gpu, current = sel.dataset.current, next = sel.value;
sel.blur(); // Fokus weg → aufgeschobenes Neuzeichnen holt den Stand nach
const node = _boxName(target);
@@ -9755,7 +9755,8 @@
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 cpuMoe = askCpuMoe(m, sl.vramMB || 0);
const cpuMoe = await askCpuMoe(m, sl.vramMB || 0);
if (cpuMoe === null) { sel.value = current; return; }
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.` : '';
@@ -9829,17 +9830,39 @@
// 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 */
/** Promise: 0 = normal laden, -2 = Experten automatisch aufteilen, null = abbrechen.
* Eigener Dialog mit drei Knoepfen — bei confirm() hiess "Abbrechen" frueher
* "normal laden", und das liest jeder als "gar nicht laden". */
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;
if (!looksMoe(m) || !vramMB || !m.sizeGB || m.sizeGB * 1024 <= vramMB * 0.85) return Promise.resolve(0);
return new Promise(resolve => {
const bg = document.createElement('div');
bg.style.cssText = 'position:fixed;inset:0;z-index:10000;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;padding:16px;';
const btn = (val, label, sub, primary) => `<button type="button" data-v="${val}" class="btn ${primary ? '' : 'secondary'}"
style="display:block;width:100%;text-align:left;padding:8px 12px;margin-top:8px;font-size:12px;">
<b>${label}</b><div style="font-weight:normal;opacity:.75;font-size:11px;margin-top:2px;">${sub}</div></button>`;
bg.innerHTML = `<div style="background:#12121E;border:1px solid #444466;border-radius:8px;padding:16px 18px;max-width:460px;width:100%;color:#E0E0F0;font-size:13px;line-height:1.5;">
<div style="font-weight:700;margin-bottom:6px;">${escapeHtml(m.id)} ist größer als die Karte</div>
<div style="color:#9a9ab5;font-size:12px;">Das Modell braucht ~${m.sizeGB} GB, die Karte hat ${(vramMB / 1024).toFixed(0)} GB.
Es ist ein MoE-Modell — pro Wort rechnet nur ein kleiner Teil, deshalb dürfen die „Experten" in den Arbeitsspeicher der Box.</div>
${btn(-2, 'Aufteilen (empfohlen)', 'So viele Experten wie passen auf die Karte, der Rest in den RAM. Langsamer, aber es läuft.', true)}
${btn(0, 'Ganz auf die Karte', 'Passt vermutlich nicht — die Box setzt die Karte dann von selbst zurück.', false)}
${btn('x', 'Abbrechen', 'Nichts laden.', false)}
</div>`;
const done = v => { document.removeEventListener('keydown', onKey, true); bg.remove(); resolve(v); };
const onKey = e => { if (e.key === 'Escape') { e.preventDefault(); done(null); } };
bg.addEventListener('click', e => {
const b = e.target.closest('[data-v]');
if (b) done(b.dataset.v === 'x' ? null : Number(b.dataset.v));
else if (e.target === bg) done(null);
});
document.addEventListener('keydown', onKey, true);
document.body.appendChild(bg);
bg.querySelector('[data-v="-2"]').focus();
});
}
function provisionModel(i) {
async function provisionModel(i) {
const m = _llmCatalog[i];
if (!m) return;
const sel = document.getElementById('llm-cat-box-' + i);
@@ -9853,7 +9876,8 @@
!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 cpuMoe = await askCpuMoe(m, (tgt && tgt.slot && tgt.slot.vramMB) || 0);
if (cpuMoe === null) return;
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, cpuMoe });
+65
View File
@@ -29,6 +29,7 @@ import logging
import os
import re
import shlex
import shutil
import time
import httpx
@@ -1141,6 +1142,63 @@ def _scan_download(oids: set) -> tuple:
return done, active, found
# Freier Platz vor dem Download: llama.cpp bricht bei voller Platte mitten
# im Download ab, und der Grund kommt ueber llama-swap nicht an. Vorher
# rechnen: was fehlt noch (fertige Blobs zaehlen nicht) gegen den freien
# Platz minus Puffer. Halbe Downloads, an denen seit STALE_PARTIAL_S niemand
# mehr schreibt, sind Reste abgebrochener Versuche — die duerfen weg.
DISK_RESERVE_BYTES = int(float(os.getenv("LLM_DISK_RESERVE_GB", "2")) * 1024**3)
STALE_PARTIAL_S = 30 * 60
def _blob_state(oids: set) -> tuple:
"""({oid: Bytes fertig}, [(pfad, bytes, mtime)] aller halben Downloads)."""
done, partial = {}, []
for root, _dirs, fns in os.walk(MODELS_DIR):
for fn in fns:
path = os.path.join(root, fn)
try:
st = os.stat(path)
except OSError:
continue
if fn.endswith(_INPROGRESS):
partial.append((path, st.st_size, st.st_mtime))
elif fn in oids:
done[fn] = st.st_size
return done, partial
def _check_disk(files: list) -> tuple:
"""(ok, Meldung). Ohne HF-Dateiliste (files leer) wird nicht geprueft."""
if not files:
return True, ""
done, partial = _blob_state({f["oid"] for f in files if f.get("oid")})
need = sum(f["size"] for f in files if f.get("oid") not in done)
if not need:
return True, ""
free = shutil.disk_usage(MODELS_DIR).free
note = ""
if need + DISK_RESERVE_BYTES > free:
stale = [(p, b) for p, b, mt in partial if time.time() - mt > STALE_PARTIAL_S]
for path, _b in stale:
try:
os.remove(path)
except OSError:
pass
if stale:
freed = sum(b for _p, b in stale)
note = f"Reste abgebrochener Downloads geloescht ({len(stale)} Datei(en), {freed / 1024**3:.1f} GB)"
logger.info("Speicherplatz: %s", note)
free = shutil.disk_usage(MODELS_DIR).free
if need + DISK_RESERVE_BYTES > free:
gb = lambda n: f"{n / 1024**3:.1f}".replace(".", ",")
return False, (f"Zu wenig Speicherplatz auf {NODE_NAME}: das Modell braucht ca. {gb(need)} GB, "
f"frei sind {gb(free)} GB (dazu {gb(DISK_RESERVE_BYTES)} GB Puffer). "
f"Nicht mehr gebrauchte Modelle im Katalog entfernen oder Platz auf der Box schaffen."
+ (f" ({note})" if note else ""))
return True, note
async def _gpu_info(card: str) -> dict:
"""Name + VRAM einer Karte nach dem Laden (fuer 'in Karte N geladen')."""
if not card:
@@ -1258,6 +1316,13 @@ 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)
# Vor jeder Aenderung an Registry/Belegung: passt es auf die Platte?
disk_ok, disk_note = await asyncio.to_thread(_check_disk, _files_for_quant(repo_files, quant))
if not disk_ok:
await _emit_progress(ws, key, "error", gpu=card, error=disk_note)
return await _provision_result(ws, key, False, gpu=card, error=disk_note)
if disk_note:
quant_note = "; ".join(x for x in (quant_note, disk_note) if x)
ctx_req = int(payload.get("ctx") or 8192)
try:
cpu_moe = int(payload.get("cpuMoe") or 0)