From ba524b0a70ac0437a800fc7cb98dd3fd50bd640c Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:45:24 +0200 Subject: [PATCH] Enable llama.cpp builds, release management and reference model auto-fit --- DEVELOPMENT.md | 64 ++++++++++++- INSTALL.md | 8 +- README.md | 2 +- STUDIO.md | 7 ++ deploy/Dockerfile | 7 +- deploy/install.py | 15 ++- index.html | 2 +- network/install_remote.py | 2 +- runtime-ui.js | 19 ++++ runtime.py | 192 ++++++++++++++++++++++++++++++++++++++ server.py | 23 ++++- studio.js | 7 +- test_runtime.py | 83 ++++++++++++++++ 13 files changed, 411 insertions(+), 20 deletions(-) create mode 100644 runtime-ui.js create mode 100644 runtime.py create mode 100644 test_runtime.py diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index e1b5542..55bc91b 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -7,7 +7,7 @@ Editor/SSH-Client und Browser. Die Anwendung und ihre Tests laufen auf Athena. - Eigener Container: `athena-deck-dev` - Zustand und Installationsmanifest: `/opt/athena-deck-dev/runtime` - Serverbindung: ausschließlich `127.0.0.1:8108` -- GPU-Zugriff: NVIDIA `utility` für Telemetrie; keine Modellstarts. +- GPU-Zugriff: NVIDIA `compute,utility` für Telemetrie und Fit-Prüfung; keine Modellstarts. Vom Arbeitsplatz: @@ -72,3 +72,65 @@ noch keine vollständige Installation eines mehrteiligen Modells dar. Update-Sicherungen enthalten den Konfigurationszustand, nicht die heruntergeladenen Modelldateien. Diese bleiben im persistenten Zustandsverzeichnis erhalten. + +## Native Laufzeitverwaltung + +`runtime.py` verwendet ausschließlich native Prozesse (git, cmake, llama-server), +keine Docker-API. Die Testverpackung enthält CUDA 12.8.1 und Build-Werkzeuge; +Debian-Host und Treiber werden nicht verändert. Build-Last ist auf 2 CPUs, +8 GiB RAM und maximal zwei Compiler-Jobs begrenzt. Builds liegen unter +`state/runtime`, werden nicht in Konfigurations-Backups dupliziert und bleiben +bei Updates erhalten. Geprüfte Versionen können als Standard ausgewählt und +zurückgeschaltet werden; das startet kein Modell. + +GET `/api/v1/runtime`, `/prerequisites`, `/releases`, `/log`; POST `/build` +(revision, backend, jobs), `/cancel` ({}), `/activate` (build_id), `/rollback` ({}), +jeweils unter `/api/v1/runtime`. Nur Administratorsitzungen sind zugelassen. +Release-Quellen: https://github.com/ggml-org/llama.cpp/releases . + +Die feste VRAM-Reserve entfällt. Die Buildprüfung ermittelt die tatsächliche +Unterstützung von `--fit`; modellbezogene Layer-/Kontextprognose wird über das unten beschriebene +Fit-Werkzeug bereitgestellt. Modellstarts sind noch nicht angebunden. Keine erfundenen Schätzungen, +keine absichtlichen OOM-Versuche und keine Eingriffe in produktive Worker. + +### Auto-Einpassung mit den bisherigen Routermodellen + +`llama-fit-params` wird mitgebaut. GET `/api/v1/runtime/references` liefert die +am 28.09.2026 aus dem alten Router gelesenen Profile Fast/Medium/Large/Ultra/ +Uncensored. POST `/api/v1/runtime/fit` nimmt profile, context und slots entgegen. +Die drei GGUF-Dateien werden einzeln und nur lesend in den Testcontainer +eingebunden (`reference_models` im Entwicklungsmanifest). Keine Kopie der +Gewichte, kein Zugriff auf Prompts oder Anwendungslogs. + +Das offizielle Fit-Werkzeug verwendet `no_alloc` für Modellgewichte und liefert +eine Prognose anhand des aktuellen freien Speichers, q4_0-KV-Cache und explizitem +Kontext/Slots. Die automatische Reserve beträgt als Sicherheitsregel 5 % des jeweiligen +Gesamt-VRAM, mindestens 512 MiB. Karten mit weniger als 10 % freiem VRAM +(mindestens 1 GiB) werden vor CUDA-Initialisierung aus der Prüfung ausgeschlossen. +Diese Regeln sind Sicherheitsmargen, keine gemessene exakte Modellkapazität. Es findet kein OOM-Stresstest und kein Modellstart statt. +Eine Prognose ist keine Garantie unter wechselnder Parallelbelegung. Der native +Betrieb kann dieselben Dateien über DECK_REFERENCE_MODELS bereitstellen. + +Fit-Adapter: Das Upstream-Fit-CLI bietet die Serveroption `--kv-unified` nicht an. +Deck setzt deshalb beim Build in `tools/fit-params/fit-params.cpp` vor der +Backend-Initialisierung explizit `params.kv_unified = true`, entsprechend den +bestehenden Profilen. Die Anpassung ist als `shared-kv-pool-v1` im Buildstand +vermerkt; die eigentliche Fit-Berechnung bleibt upstream. Fehlt der erwartete +Quellanker, bricht der Build ab, statt eine andere Semantik zu verwenden. + +Die Fit-Prognose gilt zunächst für das Textmodell. Vision-Projektoren und MTP +der bisherigen Routerprofile sind nicht eingerechnet. Außerdem ersetzt sie keine +RAM-/Lastprüfung eines späteren Worker-Starts im begrenzten Testcontainer. + +Verifikation: CUDA-Release b11229 (c2a9e1606807970f4ee3167bacd951699c89caea) +für 86/120 erfolgreich gebaut. Medium, 160000 Kontext, zwei Slots: echte +Fit-Prognose erfolgreich. Bei damaliger Parallelbelegung 0 GPU-Modelllayer; +GPU-Rechenpuffer 1044 MiB, Host Modell 13635 MiB, Kontext 3111 MiB, Compute +93 MiB. Das überschreitet das 8-GiB-RAM-Limit der Testinstanz und wird als +nicht startbar markiert. Keine Modellgewichte allokiert, alle zuvor vorhandenen +Container-Startzeiten unverändert. 46 Tests erfolgreich. + +Der verifizierte Erstbuild liegt in `state/runtime-verification`; +`state/runtime/` verweist intern darauf, damit seine Build-RPATHs +erhalten bleiben. Beide Verzeichnisse gehören ausschließlich Deck und bleiben +bei Updates bestehen. Künftige Builds entstehen direkt unter `state/runtime`. diff --git a/INSTALL.md b/INSTALL.md index 93364e9..4b61711 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -2,7 +2,9 @@ Eigenständige Installation ohne WireGuard-Modul und ohne Änderungen am bestehenden Athena-Router. Der Installer installiert den aktuellen Deck-Anwendungsstand; -Modellkatalog, Downloads und llama.cpp-Builds bleiben weiterhin GUI-Vorschau. +Modellkatalog, Downloads und llama.cpp-Buildverwaltung sind aktiv. Das Docker-Image +enthält das CUDA-Build-Toolkit; der Debian-Host wird nicht verändert. Zielprodukt +bleibt ein nativer systemd-Dienst. Modellstarts sind noch nicht angebunden. ## Voraussetzungen @@ -21,7 +23,7 @@ auf einem bereits genutzten Server. Offizielle Anleitung: Optional für GPU-Messwerte: vorhandener NVIDIA-Treiber und vorhandenes NVIDIA Container Toolkit. `--gpu-telemetry` bindet ausschließlich Treiber-Capability -`utility` ein; es installiert keinen Treiber und startet kein Modell. Ohne diese +`compute,utility` für Telemetrie und Fit-Prüfung ein; es installiert keinen Treiber und startet kein Modell. Ohne diese Option bleiben GPU-Werte in der Server-GUI ausdrücklich nicht verfügbar. ## Installation @@ -158,7 +160,7 @@ sudo ./install.sh --status --directory /opt/athena-deck-test Die Webanwendung läuft als UID/GID 65534, mit schreibgeschütztem Root-Dateisystem, allen Linux-Capabilities entzogen, ohne Docker-Socket, ohne privilegierten Modus -und ohne Host-Netzwerk. Ressourcenlimit: eine CPU, 512 MiB RAM, 128 Prozesse. +und ohne Host-Netzwerk. Ressourcenlimit: zwei CPUs, 8 GiB RAM, 128 Prozesse. GPU-Zugriff ist standardmäßig aus. Nur `state/` und ein temporäres tmpfs sind für die Anwendung beschreibbar. Docker erstellt seine normalen Regeln für den neuen Container; vorhandene Gateways und ihre Konfiguration werden nicht bearbeitet. diff --git a/README.md b/README.md index fcf8c55..e7b2c22 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Vorhandenes Docker wird vorausgesetzt; produktive Dienste werden nicht veränder Katalog, Bibliothek, Profilentwürfe und eine Laufzeit-/Update-Ansicht sind als bedienbarer GUI-Prototyp vorhanden. Entwürfe bleiben im Browser; Modellstarts, -Builds und Modellstarts sind deaktiviert. Katalogsuche und Datei-Downloads sind live; siehe DEVELOPMENT.md. Details: [GUI-Prototyp](STUDIO.md). +Katalog, Datei-Downloads und llama.cpp-Buildverwaltung sind live. Modellstarts sind noch nicht angebunden; siehe DEVELOPMENT.md. Details: [GUI-Prototyp](STUDIO.md). ## Zugang und API-Token diff --git a/STUDIO.md b/STUDIO.md index 9aceb3c..8ae06e8 100644 --- a/STUDIO.md +++ b/STUDIO.md @@ -1,3 +1,10 @@ +# Stand Laufzeitverwaltung + +llama.cpp-Prüfung, Releases, Builds, Abbruch und Standard-/Rückfallauswahl sind +jetzt live. Kein manuelles VRAM-Reservefeld mehr. Modellbezogene Prognosen verwenden die lesend eingebundenen alten Routermodelle +und llama-fit-params; ein Modellstart erfolgt nicht. Die folgenden +0.4/0.5-Beschreibungen der Laufzeitansicht sind historisch. + # Stand 0.5 Katalog und Datei-Downloads sind jetzt live. Die folgende Beschreibung der diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 077e1d7..eca979e 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -1,7 +1,8 @@ -FROM python:3.13-slim-bookworm +FROM nvidia/cuda:12.8.1-devel-ubuntu24.04 +RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends python3 git cmake g++ make ca-certificates libssl-dev && rm -rf /var/lib/apt/lists/* WORKDIR /app -COPY server.py catalog.py auth.py collect_hardware.py demo.py /app/ -COPY index.html app.js studio.js catalog-ui.js style.css login.html login.js access-ui.js network-ui.js /app/ +COPY server.py runtime.py catalog.py auth.py collect_hardware.py demo.py /app/ +COPY index.html app.js studio.js runtime-ui.js catalog-ui.js style.css login.html login.js access-ui.js network-ui.js /app/ COPY network/__init__.py network/client.py network/config.py network/rpc.py /app/network/ ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \ DECK_BIND_HOST=0.0.0.0 DECK_STATE_DIR=/var/lib/deck \ diff --git a/deploy/install.py b/deploy/install.py index 075d98b..1bdf50f 100644 --- a/deploy/install.py +++ b/deploy/install.py @@ -14,7 +14,7 @@ import urllib.request ROOT = Path(__file__).resolve().parent.parent LABEL = 'de.casaderoll.athena-deck.standalone' -FILES = ['catalog.py','catalog-ui.js','server.py','auth.py','collect_hardware.py','demo.py','index.html','app.js','studio.js','style.css','login.html','login.js','access-ui.js','network-ui.js','network/__init__.py','network/client.py','network/config.py','network/rpc.py','deploy/Dockerfile'] +FILES = ['runtime.py','runtime-ui.js','catalog.py','catalog-ui.js','server.py','auth.py','collect_hardware.py','demo.py','index.html','app.js','studio.js','style.css','login.html','login.js','access-ui.js','network-ui.js','network/__init__.py','network/client.py','network/config.py','network/rpc.py','deploy/Dockerfile'] def run(*args, check=True, interactive=False): @@ -93,7 +93,7 @@ def launch(config): base=Path(config['base']) args=['docker','run','-d','--name',config['name'],'--label',LABEL+'='+str(base), '--restart','unless-stopped','--read-only','--cap-drop','ALL','--security-opt','no-new-privileges:true', - '--pids-limit','128','--memory','512m','--cpus','1', '--tmpfs','/tmp:rw,nosuid,nodev,size=16m', + '--pids-limit','128','--memory','8g','--cpus','2', '--tmpfs','/tmp:rw,nosuid,nodev,size=256m', '-v',str(base/'state')+':/var/lib/deck:rw','-e','DECK_ALLOWED_HOSTS=127.0.0.1:'+str(config['port'])+',localhost:'+str(config['port']), '-p','127.0.0.1:'+str(config['port'])+':8108', '--health-cmd', 'python3 -c "import urllib.request,json; assert json.load(urllib.request.urlopen(\'http://127.0.0.1:8108/api/v1/auth/status\',timeout=3))[\'initialized\']"', @@ -103,7 +103,12 @@ def launch(config): # Explicit isolated development bootstrap, reachable only through host loopback/SSH. args += ['-e','DECK_REQUIRE_SETUP=0'] if config['gpu_telemetry']: - args += ['--gpus','all','-e','NVIDIA_DRIVER_CAPABILITIES=utility'] + args += ['--gpus','all','-e','NVIDIA_DRIVER_CAPABILITIES=compute,utility'] + if config.get('reference_models'): + for folder,filename in [('qwen3.8-27b-iq4-mix','Qwen3.8-27B-IQ4-MIX.gguf'),('qwen3.8-27b-iq4-xs-pure','qwen3.8-27b-IQ4_XS-pure.gguf'),('qwen3.8-27b-abliterated','Qwen3.8-27B-ABLITERATED-Q4_K_M.gguf')]: + source=Path('/data/models')/folder/filename + if not source.is_file():raise RuntimeError('Referenzmodell fehlt; keine Ersatzpfade.') + args+=['--mount','type=bind,src='+str(source)+',dst=/reference-models/'+filename+',readonly'] args.append(config['image']) run(*args) @@ -181,7 +186,7 @@ def update(base, rollback=False): backup_name=config['name']+'-previous' if inspect(backup_name):raise RuntimeError('Rückfall-Containername belegt. Keine Änderung ausgeführt.') stamp=str(time.time_ns()) - shutil.copytree(base/'state',base/'backups'/stamp,ignore=shutil.ignore_patterns('models')) + shutil.copytree(base/'state',base/'backups'/stamp,ignore=shutil.ignore_patterns('models','runtime','runtime-verification')) was_running=previous['State']['Running'] if was_running:run('docker','stop','--time','15',config['name']) run('docker','rename',config['name'],backup_name) @@ -206,7 +211,7 @@ def main(): parser.add_argument('--directory',type=Path,default=Path('/opt/athena-deck-standalone')) parser.add_argument('--name',default='athena-deck-standalone') parser.add_argument('--port',type=int,default=8110) - parser.add_argument('--gpu-telemetry',action='store_true',help='Vorhandenes NVIDIA Container Toolkit nur für Messwerte nutzen; installiert keine Treiber.') + parser.add_argument('--gpu-telemetry',action='store_true',help='Vorhandenes NVIDIA Container Toolkit für Messwerte und Fit-Prüfung nutzen; installiert keine Treiber.') args=parser.parse_args() import re if not re.fullmatch(r'athena-deck-[a-z0-9-]{1,40}',args.name): diff --git a/index.html b/index.html index fe5aa35..4940520 100644 --- a/index.html +++ b/index.html @@ -1 +1 @@ -Athena Deck
ATHENA CONTROL SURFACEv0.5 · Entwicklung
+Athena Deck
ATHENA CONTROL SURFACEv0.6 · Entwicklung
diff --git a/network/install_remote.py b/network/install_remote.py index 94999eb..4f3d7bd 100644 --- a/network/install_remote.py +++ b/network/install_remote.py @@ -12,7 +12,7 @@ import sys NAME = 'athena-deck-network' BASE = Path('/opt/athena-deck') -FILES = {'catalog.py', 'catalog-ui.js', 'studio.js', 'auth.py', 'access-ui.js', 'server.py', 'demo.py', 'collect_hardware.py', 'index.html', 'app.js', 'style.css', 'network-ui.js', 'login.html', 'login.js', 'network/__init__.py', 'network/config.py', 'network/policy.py', 'network/rpc.py', 'network/agent.py', 'network/client.py', 'network/Dockerfile', '.dockerignore'} +FILES = {'runtime.py', 'runtime-ui.js', 'catalog.py', 'catalog-ui.js', 'studio.js', 'auth.py', 'access-ui.js', 'server.py', 'demo.py', 'collect_hardware.py', 'index.html', 'app.js', 'style.css', 'network-ui.js', 'login.html', 'login.js', 'network/__init__.py', 'network/config.py', 'network/policy.py', 'network/rpc.py', 'network/agent.py', 'network/client.py', 'network/Dockerfile', '.dockerignore'} def run(*args, **kwargs): return subprocess.run(args, capture_output=True, timeout=600, **kwargs) diff --git a/runtime-ui.js b/runtime-ui.js new file mode 100644 index 0000000..9a498d1 --- /dev/null +++ b/runtime-ui.js @@ -0,0 +1,19 @@ +const RuntimeUI=(()=>{ + const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); + async function api(path='',data){const r=await fetch('/api/v1/runtime'+path,data?{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)}:{});const v=await r.json();if(!r.ok)throw Error(v.error||'Anfrage fehlgeschlagen');return v;} + function html(){return `
ATHENA / LLAMA.CPP

llama.cpp & Updates

Offizielle Versionen getrennt bauen und prüfen. Ein Build lädt kein Modell und ändert keine produktiven Laufzeiten.

Voraussetzungen

Installation & Build

GPU-Architekturen werden automatisch aus beiden Karten übernommen. Begrenzte Build-Last für den Parallelbetrieb.

Build-Ausgabe (eigener Build)

Versionen & Änderungen

Geprüfte Builds

Als Standard auswählen startet keinen Prozess. Bestehende Router bleiben unverändert.

Automatische Speicheranpassung

Kein pauschales Reservefeld: Die Laufzeit soll Modell, gewünschten Kontext, Slots und den aktuell freien Speicher gemeinsam berücksichtigen. Unterstützte Builds verwenden llama.cpp --fit on; der Kontext wird ausdrücklich vorgegeben. Keine absichtlichen OOM-Tests auf den produktiv genutzten GPUs.

Ein Build allein kann keine Modell-Layerzahl bestimmen. Die Berechnung verwendet llama-fit-params ohne Gewichtsallokation. Sie ist eine Prognose für das Textmodell und kein Lasttest. Vision-Projektor und MTP sind noch nicht eingerechnet. Ein Modellstart erfolgt nicht.

`;} + function bind(){const root=document.querySelector('#runtime-live');if(!root)return;let running=false; + const msg=t=>{if(root.isConnected)root.querySelector('#rt-message').textContent=t;}; + async function status(){try{const s=await api();if(!root.isConnected)return;running=s.job?.state==='running';root.querySelector('#rt-job').textContent=s.job?`${s.job.revision} · ${s.job.state} · ${s.job.phase} ${s.job.error||''}`:'Noch kein Build gestartet.';root.querySelector('#rt-cancel').disabled=!running;root.querySelector('#rt-build button').disabled=running;root.querySelector('#rt-rollback').disabled=!s.previous;root.querySelector('#rt-builds').innerHTML=s.builds.map(b=>`

${e(b.revision)} · ${e(b.backend)} ${s.active===b.id?'· STANDARD':''}

Commit ${e(b.commit)} · GPU-Ziele ${e(b.architectures.join(', ')||'CPU')} · Auto-Fit ${b.fit_supported?'unterstützt':'nicht unterstützt'}

`).join('')||'

Noch kein geprüfter Build vorhanden.

';root.querySelectorAll('[data-build]').forEach(b=>b.onclick=()=>action('/activate',{build_id:b.dataset.build}));if(s.job){const log=await api('/log');if(root.isConnected)root.querySelector('#rt-log').textContent=log.text;}if(running)setTimeout(()=>{if(root.isConnected)status();},3000);}catch(err){msg(err.message);}} + async function action(path,data){try{await api(path,data);msg('Aktion angenommen.');await status();}catch(err){msg(err.message);}} + async function prerequisites(){try{const p=await api('/prerequisites');if(!root.isConnected)return;root.querySelector('#rt-prereq').innerHTML=`

CPU-Build: ${p.cpu_ready?'bereit':'Werkzeuge fehlen'} · CUDA-Build: ${p.cuda_ready?'bereit':'Werkzeuge oder GPUs fehlen'}

${['git','cmake','g++','nvcc'].map(k=>e(k)+': '+e(p[k]||'fehlt')).join(' · ')}

`+p.gpus.map(g=>`

${e(g.name)} · Architektur ${e(g.architecture)} · aktuell ${Math.round(g.free_mib)} / ${Math.round(g.total_mib)} MiB frei

`).join('');}catch(err){msg(err.message);}} + root.querySelector('#rt-check').onclick=prerequisites; + root.querySelector('#rt-build').onsubmit=event=>{event.preventDefault();const d=Object.fromEntries(new FormData(event.target));d.jobs=Number(d.jobs);action('/build',d);}; + root.querySelector('#rt-cancel').onclick=()=>action('/cancel',{});root.querySelector('#rt-rollback').onclick=()=>action('/rollback',{}); + root.querySelector('#rt-releases').onclick=async()=>{msg('Offizielle Releases werden abgefragt …');try{const v=await api('/releases');if(!root.isConnected)return;root.querySelector('#rt-releases-list').innerHTML=v.releases.map(r=>`

${e(r.tag)} ${r.prerelease?'· Vorabversion':''} · ${e(r.published)}

Original auf GitHub
Änderungen / Release-Notes
${e(r.notes)}
`).join('');root.querySelectorAll('[data-release]').forEach(b=>b.onclick=()=>{root.querySelector('[name=revision]').value=b.dataset.release;msg('Version ausgewählt. Build kann gestartet werden.');});msg(v.releases.length+' offizielle Releases geladen.');}catch(err){msg(err.message);}}; + api('/references').then(v=>{if(!root.isConnected)return;const select=root.querySelector('#rt-profiles');select.innerHTML=v.profiles.map(p=>``).join('');select.onchange=()=>{const p=v.profiles.find(p=>p.id===select.value);root.querySelector('#rt-fit [name=context]').value=p.context;root.querySelector('#rt-fit [name=slots]').value=p.slots;};}).catch(err=>msg(err.message)); + root.querySelector('#rt-fit').onsubmit=async event=>{event.preventDefault();const b=event.target.querySelector('button');b.disabled=true;msg('Modellmetadaten und Speicherbedarf werden berechnet …');const d=Object.fromEntries(new FormData(event.target));d.context=Number(d.context);d.slots=Number(d.slots);try{const v=await api('/fit',d);if(root.isConnected)root.querySelector('#rt-fit-result').textContent=(v.success?'Prognose erfolgreich. Kein Modell geladen.':'Aktuell keine passende Einpassung ermittelt.')+(v.within_ram_limit===false?'\nNICHT STARTBAR in der Testinstanz: berechneter Host-RAM überschreitet deren Limit von '+v.ram_limit_mib+' MiB.':'')+'\nGPU-Layer (Prognose): '+(v.fitted?.gpu_layers??'nicht ermittelt')+'\nGPU-Verteilung: '+(v.fitted?.tensor_split??'ein Gerät / nicht ermittelt')+'\nAusgelassene belegte GPUs: '+v.excluded_gpus.join(', ')+'\nAutomatische Reserve (MiB): '+v.reserve_mib.join(', ')+'\n'+v.memory.map(m=>`${m.device}: Modell ${m.model_mib} MiB · Kontext ${m.context_mib} MiB · Rechenspeicher ${m.compute_mib} MiB`).join('\n')+'\n'+v.arguments+'\n'+v.details;msg('Berechnung abgeschlossen. Freier Speicher kann sich durch andere Dienste ändern.');}catch(err){msg(err.message);}finally{b.disabled=false;}}; + prerequisites();status(); + } + return {html,bind}; +})(); diff --git a/runtime.py b/runtime.py new file mode 100644 index 0000000..b363d02 --- /dev/null +++ b/runtime.py @@ -0,0 +1,192 @@ +"""Unprivileged native build manager. No Docker calls, shell commands or host changes.""" +import json +import os +from pathlib import Path +import re +import shutil +import shlex +import signal +import subprocess +import threading +import time +import urllib.request +import uuid + +ORIGIN='https://github.com/ggml-org/llama.cpp.git' + +def github(path): + req=urllib.request.Request('https://api.github.com/repos/ggml-org/llama.cpp/'+path,headers={'Accept':'application/vnd.github+json','User-Agent':'Athena-Deck'}) + with urllib.request.urlopen(req,timeout=20) as r:return json.load(r) + +def command(args,timeout=10): + return subprocess.run(args,capture_output=True,text=True,timeout=timeout,check=True).stdout.strip() + +def prepare_fit_source(directory): + # The reference profiles share a KV pool; upstream fit CLI omits its switch. + path=Path(directory)/'tools/fit-params/fit-params.cpp' + if not path.is_file():raise ValueError('Diese Version enthält kein unterstütztes Fit-Werkzeug.') + source=path.read_text();marker=' params.kv_unified = true; // Athena Deck reference profiles' + if marker in source:return + anchor=' llama_backend_init();' + if source.count(anchor)!=1:raise ValueError('Fit-Adapter passt nicht zu dieser Version; Build abgebrochen.') + path.write_text(source.replace(anchor,marker+'\n'+anchor)) + +class Runtime: + def __init__(self,root): + self.root=Path(root);self.lock=threading.RLock();self.process=None;self.cancelled=threading.Event();self.busy=False + self.state={'job':None,'builds':[],'active':None,'previous':None} + if (self.root/'state.json').exists(): + self.state=json.loads((self.root/'state.json').read_text()) + if (self.state.get('job') or {}).get('state')=='running': + self.state['job'].update(state='interrupted',phase='Deck wurde neu gestartet; Build erneut starten.') + def save(self): + self.root.mkdir(parents=True,exist_ok=True,mode=0o700) + p=self.root/'state.tmp';p.write_text(json.dumps(self.state));p.replace(self.root/'state.json') + def prerequisites(self): + result={x:shutil.which(x) for x in ('git','cmake','g++','nvcc')};gpus=[] + try: + for line in command(['nvidia-smi','--query-gpu=index,uuid,name,compute_cap,memory.total,memory.free','--format=csv,noheader,nounits']).splitlines(): + idx,gpu_uuid,name,cap,total,free=[x.strip() for x in line.split(',')] + gpus.append(dict(index=int(idx),uuid=gpu_uuid,name=name,architecture=cap.replace('.',''),total_mib=float(total),free_mib=float(free))) + except (OSError,ValueError,subprocess.SubprocessError):pass + result.update(gpus=gpus,cpu_ready=all(result[x] for x in ('git','cmake','g++')),cuda_ready=all(result[x] for x in ('git','cmake','g++','nvcc')) and bool(gpus)) + return result + def status(self): + with self.lock:return json.loads(json.dumps(self.state)) + def releases(self): + rows=github('releases?per_page=10') + return {'releases':[dict(tag=r['tag_name'],name=r['name'],published=r['published_at'],notes=r.get('body',''),url=r['html_url'],prerelease=r['prerelease']) for r in rows if not r['draft']]} + def run(self,args,cwd,phase,timeout=7200): + with self.lock: + if self.cancelled.is_set():raise InterruptedError() + self.state['job']['phase']=phase;self.save() + log=self.root/'build.log' + stream=log.open('ab') + try: + self.process=subprocess.Popen(args,cwd=cwd,stdout=stream,stderr=subprocess.STDOUT,start_new_session=True) + except Exception: + stream.close();raise + try: + code=self.process.wait(timeout=timeout) + if self.cancelled.is_set():raise InterruptedError() + if code:raise ValueError('Build-Schritt fehlgeschlagen: '+phase) + except subprocess.TimeoutExpired: + self.stop();raise ValueError('Build-Zeitlimit erreicht.') from None + finally: + stream.close() + with self.lock:self.process=None + def start(self,revision,backend='CUDA',jobs=1): + if not isinstance(revision,str) or not re.fullmatch(r'(?:b[0-9]{3,8}|[a-f0-9]{40})',revision):raise ValueError('Offizielles b-Release oder vollständigen Commit-SHA wählen.') + if backend not in ('CUDA','CPU') or type(jobs)!=int or not 1<=jobs<=2:raise ValueError('CPU/CUDA und 1–2 Build-Jobs unterstützt.') + with self.lock: + if self.busy:raise ValueError('Ein Build läuft bereits.') + p=self.prerequisites() + if not p['cuda_ready' if backend=='CUDA' else 'cpu_ready']:raise ValueError('Build-Werkzeuge fehlen. Voraussetzungen prüfen.') + self.root.mkdir(parents=True,exist_ok=True) + if shutil.disk_usage(self.root).free<15*1024**3:raise ValueError('Mindestens 15 GiB freier Speicher erforderlich.') + ident=uuid.uuid4().hex + self.state['job']=dict(id=ident,state='running',phase='Vorbereitung',revision=revision,backend=backend,started=time.time(),error=None) + self.cancelled.clear();self.busy=True;self.save() + threading.Thread(target=self.build,args=(ident,revision,backend,jobs,p),daemon=True).start() + return self.status() + def build(self,ident,revision,backend,jobs,prereq): + directory=self.root/ident + try: + directory.mkdir();(self.root/'build.log').write_text('') + self.run(['git','init',str(directory)],self.root,'Quellverzeichnis anlegen',30) + self.run(['git','-C',str(directory),'fetch','--depth','1',ORIGIN,revision],self.root,'Offizielle Quellen herunterladen',300) + self.run(['git','checkout','--detach','FETCH_HEAD'],directory,'Feste Version auswählen',30) + commit=command(['git','-C',str(directory),'rev-parse','HEAD']) + prepare_fit_source(directory) + arch=sorted(set(g['architecture'] for g in prereq['gpus'])) + args=['cmake','-S','.', '-B','build','-DCMAKE_BUILD_TYPE=Release','-DGGML_NATIVE=OFF','-DLLAMA_BUILD_TESTS=OFF','-DLLAMA_BUILD_EXAMPLES=OFF','-DLLAMA_BUILD_SERVER=ON','-DGGML_CUDA='+('ON' if backend=='CUDA' else 'OFF')] + if backend=='CUDA':args+=['-DCMAKE_CUDA_ARCHITECTURES='+';'.join(arch)] + self.run(args,directory,'Build konfigurieren',300) + self.run(['cmake','--build','build','--target','llama-server','llama-fit-params','-j',str(jobs)],directory,'llama.cpp-Werkzeuge kompilieren') + binary=directory/'build/bin/llama-server' + self.run([str(binary),'--version'],directory,'Binärdatei prüfen',30) + help_text=command([str(binary),'--help'],30) + item=dict(id=ident,revision=revision,commit=commit,backend=backend,architectures=arch if backend=='CUDA' else [],created=time.time(),fit_supported='--fit ' in help_text,fit_tool=(directory/'build/bin/llama-fit-params').exists(),fit_adapter='shared-kv-pool-v1') + with self.lock: + self.state['builds'].append(item);self.state['job'].update(state='complete',phase='Build geprüft; kann als Standard ausgewählt werden.');self.save() + except Exception as exc: + with self.lock: + self.state['job'].update(state='cancelled' if isinstance(exc,InterruptedError) else 'failed',error=str(exc) if isinstance(exc,ValueError) else 'Build unterbrochen oder Werkzeug nicht erreichbar.');self.save() + finally: + with self.lock:self.busy=False + def stop(self): + with self.lock: + self.cancelled.set() + if self.process and self.process.poll() is None: + try:os.killpg(self.process.pid,signal.SIGTERM) + except ProcessLookupError:pass + try:self.process.wait(timeout=3) + except subprocess.TimeoutExpired: + try:os.killpg(self.process.pid,signal.SIGKILL) + except ProcessLookupError:pass + return {'cancellation_requested':True} + def activate(self,build_id): + with self.lock: + if not any(b['id']==build_id for b in self.state['builds']):raise ValueError('Kein erfolgreich geprüfter Build.') + if self.state['active']!=build_id: + self.state['previous']=self.state['active'];self.state['active']=build_id;self.save() + return self.status() + def rollback(self): + with self.lock: + if not self.state['previous']:raise ValueError('Kein vorheriger Build vorhanden.') + return self.activate(self.state['previous']) + def log(self): + p=self.root/'build.log' + if not p.exists():return {'text':''} + with p.open('rb') as f: + f.seek(max(0,p.stat().st_size-24000));return {'text':f.read().decode(errors='replace')} + + def references(self): + base=Path(os.environ.get('DECK_REFERENCE_MODELS','/reference-models')) + specs=[('fast','Qwen3.8-27B-IQ4-MIX.gguf',76800,1,64),('medium','qwen3.8-27b-IQ4_XS-pure.gguf',160000,2,256),('large','qwen3.8-27b-IQ4_XS-pure.gguf',192000,1,256),('ultra','qwen3.8-27b-IQ4_XS-pure.gguf',262144,1,128),('uncensored','Qwen3.8-27B-ABLITERATED-Q4_K_M.gguf',80000,1,256)] + return {'profiles':[dict(id=i,file=f,context=c,slots=n,ubatch=u,cache='q4_0',available=(base/f).is_file()) for i,f,c,n,u in specs]} + def fit(self,profile,context,slots): + if type(context)!=int or not 512<=context<=2097152 or type(slots)!=int or not 1<=slots<=16:raise ValueError('Kontext oder Slots außerhalb des erlaubten Bereichs.') + ref=next((p for p in self.references()['profiles'] if p['id']==profile),None) + if not ref or not ref['available']:raise ValueError('Referenzmodell nicht lesend eingebunden.') + with self.lock: + build=next((b for b in self.state['builds'] if b['id']==self.state['active']),None) + if not build or not build.get('fit_tool'):raise ValueError('Zuerst einen CUDA-Build mit Fit-Werkzeug erstellen und auswählen.') + if build['backend']!='CUDA':raise ValueError('Für GPU-Einpassung einen CUDA-Build auswählen.') + if self.busy:raise ValueError('Bitte Ende des Builds abwarten.') + if getattr(self,'fitting',False):raise ValueError('Eine Einpassung läuft bereits.') + self.fitting=True + try: + binary=self.root/build['id']/'build/bin/llama-fit-params' + model=Path(os.environ.get('DECK_REFERENCE_MODELS','/reference-models'))/ref['file'] + args=[str(binary),'--model',str(model),'--ctx-size',str(context),'--parallel',str(slots),'--cache-type-k','q4_0','--cache-type-v','q4_0','--flash-attn','on','--batch-size','2048','--ubatch-size',str(ref['ubatch'])] + snapshot=self.prerequisites()['gpus'] + usable=[g for g in snapshot if g['free_mib']>=max(1024,g['total_mib']*.10)] + if not usable:raise ValueError('GPUs derzeit belegt. Keine sichere Auto-Prognose; produktive Dienste bleiben unverändert.') + margins=[max(512,round(g['total_mib']*.05)) for g in usable] + args+=['--fit-target',','.join(map(str,margins))] + env=dict(os.environ,CUDA_VISIBLE_DEVICES=','.join(g['uuid'] for g in usable)) + result=subprocess.run(args,capture_output=True,text=True,timeout=120,env=env) + text=result.stdout.strip() + fitted={};memory=[] + if result.returncode==0: + values=shlex.split(text) + for flag,key in [('-c','context'),('-ngl','gpu_layers'),('-ts','tensor_split')]: + if flag in values and values.index(flag)+1{ const key='athena-deck-studio-v1'; - const seed={profiles:[{id:'alltag',name:'qwen-alltag',model:'qwen-iq4',context:76800,slots:1,gpu:'both',split:'85,15',cache:'q8_0',batch:2048,ubatch:128,threads:8,unified:true,flash:true,vision:false},{id:'parallel',name:'qwen-parallel',model:'qwen-iq4',context:160000,slots:2,gpu:'both',split:'85,15',cache:'q8_0',batch:2048,ubatch:128,threads:8,unified:true,flash:true,vision:false},{id:'lang',name:'qwen-langkontext',model:'qwen-iq4',context:262144,slots:1,gpu:'both',split:'80,20',cache:'q8_0',batch:2048,ubatch:128,threads:8,unified:true,flash:true,vision:false}],runtime:{backend:'CUDA',revision:'',architectures:'auto',custom:'',jobs:4,toolkit:'auto',reserve:2048}}; + const seed={profiles:[{id:'alltag',name:'qwen-alltag',model:'qwen-iq4',context:76800,slots:1,gpu:'both',split:'85,15',cache:'q8_0',batch:2048,ubatch:128,threads:8,unified:true,flash:true,vision:false},{id:'parallel',name:'qwen-parallel',model:'qwen-iq4',context:160000,slots:2,gpu:'both',split:'85,15',cache:'q8_0',batch:2048,ubatch:128,threads:8,unified:true,flash:true,vision:false},{id:'lang',name:'qwen-langkontext',model:'qwen-iq4',context:262144,slots:1,gpu:'both',split:'80,20',cache:'q8_0',batch:2048,ubatch:128,threads:8,unified:true,flash:true,vision:false}],runtime:{backend:'CUDA',revision:'',architectures:'auto',custom:'',jobs:4,toolkit:'auto'}}; let state=structuredClone(seed);try{const saved=JSON.parse(localStorage.getItem(key));if(saved?.profiles&&Array.isArray(saved.profiles)&&saved.runtime)state=saved;}catch{} let section='discover',category='',editing=null; const models=[{id:'qwen-iq4',kind:'chat',name:'Qwen 27B',variant:'IQ4_XS · GGUF',source:'Hugging Face',description:'Sprachmodell · Beispiel für mehrere Profile mit einer Modelldatei.'},{id:'qwen-q4',kind:'chat',name:'Qwen 27B',variant:'Q4_K_M · GGUF',source:'Hugging Face',description:'Alternative Quantisierung · eigener Bibliothekseintrag.'},{id:'image-example',kind:'image',name:'Bildmodell',variant:'Modellpaket · Beispiel',source:'Modellkatalog',description:'Textencoder, Diffusionsmodell und VAE als zusammengehöriges Paket.'},{id:'tts-example',kind:'audio',name:'Sprachausgabe',variant:'TTS · Beispiel',source:'Modellkatalog',description:'Sprachmodell und Stimmen mit passender Audio-Laufzeit.'},{id:'asr-example',kind:'audio',name:'Spracherkennung',variant:'ASR · Beispiel',source:'Modellkatalog',description:'Audio in Text umwandeln · CPU-/GPU-Eignung später prüfen.'},{id:'video-example',kind:'video',name:'Videomodell',variant:'Modellpaket · Beispiel',source:'Modellkatalog',description:'Auflösung, Bildanzahl und Zusatzkomponenten gemeinsam planen.'}]; @@ -12,7 +12,7 @@ const Studio=(()=>{ const check=v=>v?'checked':''; function save(){try{localStorage.setItem(key,JSON.stringify(state));return true;}catch{notice('Speichern im Browser nicht möglich. Entwurf bleibt nur für diese Ansicht erhalten.');return false;}} function notice(message){const node=document.querySelector('#studio-notice');if(node)node.textContent=message;} - const banner=()=>'
GUI-VORSCHAUKatalog und Datei-Downloads sind live. Profile und Laufzeiten bleiben Entwürfe; keine Modellstarts.
'; + const banner=()=>'
GUI-VORSCHAUKatalog und Datei-Downloads sind live. Profile bleiben Entwürfe; Builds sind unter Einstellungen aktiv. Keine Modellstarts.
'; function header(title,description){return `
ATHENA / MODELLVERWALTUNG

${title}

${description}

${banner()}`;} function render(page,force=false){ if(!force&&document.querySelector('#studio')?.dataset.page===page)return; @@ -41,8 +41,9 @@ const Studio=(()=>{ if(id)document.querySelector('#delete-profile').onclick=()=>{state.profiles=state.profiles.filter(q=>q.id!==id);save();render(category,true);notice('Lokalen Profilentwurf entfernt. Modelldateien wurden nicht verändert.');}; document.querySelector('#profile-editor').scrollIntoView({behavior:'smooth',block:'start'}); } - function runtime(){const r=state.runtime;return header('llama.cpp-Laufzeit','Installation, GPU-Unterstützung und Versionen an einem Ort vorbereiten.')+`
INSTALLIERTE VERSION

Nicht angebunden

Kein Build erkannt oder ausgeführt.

UPDATE-STATUS

Noch nicht geprüft

Keine Live-Abfrage von Releases.

ZIELSYSTEM

Athena

Geplant: RTX 5080 + RTX 3060

Installation & Build

LOKALER ENTWURF
${field('Gewünschtes Release / Commit','revision',r.revision,'text','placeholder="Noch keine Version ausgewählt" maxlength="80"')}${field('Explizite Architekturziele (optional)','custom',r.custom,'text','placeholder="Später anhand beider GPUs prüfen" maxlength="80"')}${field('Parallele Build-Jobs','jobs',r.jobs,'number','required min="1" max="64"')}${field('Geplante VRAM-Reserve je GPU (MiB)','reserve',r.reserve,'number','required min="256" max="16384" step="256"')}

Bei verschiedenen GPUs muss der CUDA-Build die Architektur beider Karten unterstützen. Die konkreten Build-Ziele und die passende Toolkit-Version werden später auf Athena geprüft. Diese Vorschau installiert keine Treiber oder Host-Pakete.

Updates & Änderungen

Vorgesehen: installierte Version mit verfügbaren Releases vergleichen, Fixes ansehen und einen neuen Build getrennt vorbereiten.

Geplanter Build-Ablauf

  1. Voraussetzungen prüfenCompiler, CUDA, beide GPUs und freier Speicher.
  2. Version beziehenAusgewähltes Release oder festen Commit herunterladen.
  3. Separat bauen & prüfenNeue Binärdatei prüfen; laufende Version beibehalten.
  4. Gezielt aktivierenNach laufenden Anfragen wechseln; vorigen Build behalten.

Kein Build aktiv · keine Warteschlange · keine Wiederherstellungsversion vorhanden.

`;} + function runtime(){return RuntimeUI.html();} function bind(page){ + if(page==='runtime'){RuntimeUI.bind();return;} if(section==='discover'||section==='library')CatalogUI.bind(page,section==='library'); document.querySelectorAll('[data-section]').forEach(b=>b.onclick=()=>{section=b.dataset.section;render(page,true);}); document.querySelectorAll('[data-detail]').forEach(b=>b.onclick=()=>detail(b.dataset.detail)); diff --git a/test_runtime.py b/test_runtime.py new file mode 100644 index 0000000..5386315 --- /dev/null +++ b/test_runtime.py @@ -0,0 +1,83 @@ +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch +from runtime import Runtime + +class RuntimeTests(unittest.TestCase): + def test_untrusted_build_arguments(self): + with tempfile.TemporaryDirectory() as d: + r=Runtime(d) + for ref in ('main; reboot','--upload-pack=x','../main','https://evil/repo'): + with self.assertRaises(ValueError):r.start(ref) + with self.assertRaises(ValueError):r.start('b9000',jobs=32) + def test_missing_tools_does_not_spawn(self): + with tempfile.TemporaryDirectory() as d: + r=Runtime(d) + with patch.object(r,'prerequisites',return_value={'cuda_ready':False}),patch('runtime.subprocess.Popen') as spawn: + with self.assertRaises(ValueError):r.start('b9000') + spawn.assert_not_called() + def test_selection_rollback_persistence(self): + with tempfile.TemporaryDirectory() as d: + r=Runtime(d);r.state['builds']=[{'id':'one'},{'id':'two'}] + with self.assertRaises(ValueError):r.activate('../unknown') + r.activate('one');r.activate('two');r.rollback() + self.assertEqual(Runtime(d).status()['active'],'one') + def test_restart_marks_interrupted(self): + with tempfile.TemporaryDirectory() as d: + r=Runtime(d);r.state['job']={'state':'running'};r.save() + self.assertEqual(Runtime(d).status()['job']['state'],'interrupted') + def test_cancel_before_command(self): + with tempfile.TemporaryDirectory() as d: + r=Runtime(d);r.stop() + with patch('runtime.subprocess.Popen') as spawn: + with self.assertRaises(InterruptedError):r.run(['git'],Path(d),'test') + spawn.assert_not_called() + def test_fit_requires_selected_build_and_bounded_context(self): + with tempfile.TemporaryDirectory() as d: + r=Runtime(d) + for context,slots in [(0,1),(160000,99),(True,1)]: + with self.assertRaises(ValueError):r.fit('medium',context,slots) + with patch.object(r,'references',return_value={'profiles':[dict(id='medium',available=True)]}): + with self.assertRaises(ValueError):r.fit('medium',160000,2) + def test_official_prereleases_are_not_hidden(self): + row=dict(tag_name='b11229',name='build',published_at='date',body='notes',html_url='https://github.com/ggml-org/llama.cpp/releases',prerelease=True,draft=False) + with tempfile.TemporaryDirectory() as d,patch('runtime.github',return_value=[row]): + self.assertTrue(Runtime(d).releases()['releases'][0]['prerelease']) + def test_fit_excludes_busy_gpu_and_preserves_context(self): + with tempfile.TemporaryDirectory() as d: + r=Runtime(d);r.state.update(active='build',builds=[dict(id='build',backend='CUDA',fit_tool=True)]) + gpus=[dict(uuid='GPU-free',name='3060',total_mib=12000,free_mib=2600),dict(uuid='GPU-busy',name='5080',total_mib=16000,free_mib=173)] + ref=dict(id='medium',available=True,file='model.gguf',ubatch=256) + result=type('Result',(),dict(returncode=0,stdout='-c 160000 -ngl 4',stderr=''))() + with patch.object(r,'references',return_value={'profiles':[ref]}),patch.object(r,'prerequisites',return_value={'gpus':gpus}),patch('runtime.subprocess.run',return_value=result) as run: + value=r.fit('medium',160000,2) + self.assertTrue(value['success']);self.assertFalse(value['model_loaded']) + self.assertEqual(value['excluded_gpus'],['5080']) + self.assertEqual(run.call_args.kwargs['env']['CUDA_VISIBLE_DEVICES'],'GPU-free') + args=run.call_args.args[0];self.assertEqual(args[args.index('--ctx-size')+1],'160000');self.assertEqual(args[args.index('--parallel')+1],'2') + def test_fit_source_adapter_is_explicit_and_idempotent(self): + from runtime import prepare_fit_source + with tempfile.TemporaryDirectory() as d: + path=Path(d)/'tools/fit-params/fit-params.cpp';path.parent.mkdir(parents=True) + path.write_text(' llama_backend_init();\n') + prepare_fit_source(d);first=path.read_text();prepare_fit_source(d) + self.assertEqual(first,path.read_text());self.assertIn('params.kv_unified = true',first) + path.write_text('unknown upstream layout') + with self.assertRaises(ValueError):prepare_fit_source(d) + def test_cancels_only_owned_real_process(self): + import sys,threading,time + with tempfile.TemporaryDirectory() as d: + r=Runtime(d);r.state['job']={'state':'running'};errors=[] + def work(): + try:r.run([sys.executable,'-c','import time; time.sleep(60)'],Path(d),'test',70) + except InterruptedError:pass + except Exception as exc:errors.append(exc) + t=threading.Thread(target=work);t.start() + for _ in range(100): + if r.process is not None:break + time.sleep(.01) + child=r.process + self.assertIsNotNone(child) + r.stop();t.join(3) + self.assertFalse(t.is_alive());self.assertIsNotNone(child.poll());self.assertEqual(errors,[])