diff --git a/STUDIO.md b/STUDIO.md index c4af152..0e1eb7e 100644 --- a/STUDIO.md +++ b/STUDIO.md @@ -132,3 +132,26 @@ Validierung: 63 Tests; reale isolierte Generierungen 512 × 512 / 2 Schritte und 1024 × 1024 / 25 Schritte. Letztere dauerte ca. 99 Sekunden, PNG visuell geprüft, GPU danach wieder frei. Nutzerprofile unverändert. Die einfache Profil-/Prompt-/ Ergebnisanordnung orientiert sich an LocalAIs ImageGen-Seite, ohne deren Code zu kopieren. + + +### Chatprofile: GPU- und Sampling-Einstellungen + +Der Profileditor speichert Kontextbudget, Slots, Batch/Microbatch, Temperature, +Top-p und Top-k sowie geordnete GPU-UUIDs, Split-Modus (`none`, `layer`, `row`) +und relative Verteilungsgewichte. Zwei GPU-Auswahlen zeigen die echten Kartennamen; +die Reihenfolge definiert die vorgesehenen logischen CUDA0/CUDA1-Geräte. UUIDs +verhindern Verwechslungen mit wechselnden nvidia-smi-Indizes. CPU-Threads stehen +unter „Erweitert“. Die Auswahl startet keinen Worker; die Chat-Ausführung ist +weiterhin nicht angebunden. Die GPU-Verteilung ist keine Speicherzusage. + +Medium aus der Referenztabelle: Kontext 160000 insgesamt für zwei Slots, +RTX 5080 zuerst, RTX 3060 danach, Layer-Split 85,15, Batch 2048, Microbatch 256, +Temperature 1.0, Top-p 0.95, Top-k 20. Fast: 76800, ein Slot, nur RTX 5080, +keine Aufteilung, Batch 2048, Microbatch 64, Temperature 0.2, Top-p 0.8, Top-k 20. + +API `profiles/save`: zusätzliche Chat-Parameter `gpu_devices` (geordnete UUID-Liste), +`split_mode`, `tensor_split` (Zahlenliste), `temperature`, `top_p`, `top_k`. +Leere Geräte-/Gewichtelisten bedeuten automatische Auswahl/Verteilung. +Alte Profile bleiben lesbar; fehlende neue Felder erhalten bei Anzeige/Speichern +Standardwerte (automatische Geräte, keine Aufteilung, Temperature 0.8, Top-p 0.95, +Top-k 40). Bestehende Dateien werden beim Lesen nicht umgeschrieben. diff --git a/profiles-ui.js b/profiles-ui.js index d43a08c..dc3de7f 100644 --- a/profiles-ui.js +++ b/profiles-ui.js @@ -1,13 +1,13 @@ window.ProfilesUI=(()=>{ const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); - const labels={context:'Gesamtes Kontextbudget (Token)',slots:'Parallele Slots',threads:'CPU-Threads',batch:'Batch-Größe',ubatch:'Microbatch-Größe',width:'Breite (Pixel)',height:'Höhe (Pixel)',steps:'Schritte',seed:'Seed (−1 = zufällig)',guidance:'Guidance / CFG',speed:'Sprechgeschwindigkeit',frames:'Bildanzahl',fps:'Bilder pro Sekunde'}; + const labels={temperature:'Temperature',top_p:'Top-p',top_k:'Top-k',gpu_devices:'GPUs (in Reihenfolge)',split_mode:'GPU-Split',tensor_split:'GPU-Verteilung',context:'Gesamtes Kontextbudget (Token)',slots:'Parallele Slots',threads:'CPU-Threads',batch:'Batch-Größe',ubatch:'Microbatch-Größe',width:'Breite (Pixel)',height:'Höhe (Pixel)',steps:'Schritte',seed:'Seed (−1 = zufällig)',guidance:'Guidance / CFG',speed:'Sprechgeschwindigkeit',frames:'Bildanzahl',fps:'Bilder pro Sekunde'}; const html=()=>'

Deine Profile

Auf Athena gespeichert. Eine Modelldatei kann mehrere Profile mit unterschiedlichen Parametern haben.

'; async function api(path,data){const r=await fetch('/api/v1/'+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 bind(kind,modelId){ - const root=document.querySelector('#live-profiles'),el=id=>root.querySelector('#'+id);let rows=[],models=[],schema={},panelSequence=0; + const root=document.querySelector('#live-profiles'),el=id=>root.querySelector('#'+id);let rows=[],models=[],schema={},gpus=[],panelSequence=0; const message=t=>{if(root.isConnected)el('profile-message').textContent=t;}; - async function load(){try{const [p,c]=await Promise.all([api('profiles'),api('catalog')]);if(!root.isConnected)return;rows=p.profiles.filter(x=>x.kind===kind);models=c.entries.filter(x=>x.kind===kind&&x.profile_eligible===true);schema=p.schemas[kind];el('profile-new').disabled=!models.length; - el('profile-list').innerHTML=rows.map(p=>`
GESPEICHERT AUF ATHENA

${e(p.name)}

${e(p.model?.file||'Modelldatei nicht verfügbar')}

${Object.entries(p.parameters).map(([k,v])=>`${e(labels[k])}: ${e(v)}`).join(' · ')}

Was fehlt zur Ausführung?${p.blockers.map(t=>`

${e(t)}

`).join('')}
${kind==='image'?``:''}
`).join('')||`

Noch keine Profile

${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}

`; + async function load(){try{const [p,c,h]=await Promise.all([api('profiles'),api('catalog'),kind==='chat'?api('hardware').catch(()=>({gpus:[]})):Promise.resolve({gpus:[]})]);gpus=h.gpus||[];if(!root.isConnected)return;rows=p.profiles.filter(x=>x.kind===kind);models=c.entries.filter(x=>x.kind===kind&&x.profile_eligible===true);schema=p.schemas[kind];el('profile-new').disabled=!models.length; + el('profile-list').innerHTML=rows.map(p=>`
GESPEICHERT AUF ATHENA

${e(p.name)}

${e(p.model?.file||'Modelldatei nicht verfügbar')}

${Object.entries(p.parameters).map(([k,v])=>`${e(labels[k]||k)}: ${e(k==='gpu_devices'?(v.length?v.map((id,i)=>`CUDA${i}: ${gpus.find(g=>g.uuid===id)?.name||id}`).join(', '):'Automatisch'):Array.isArray(v)?(v.join(', ')||'Automatisch'):v)}`).join(' · ')}

Was fehlt zur Ausführung?${p.blockers.map(t=>`

${e(t)}

`).join('')}
${kind==='image'?``:''}
`).join('')||`

Noch keine Profile

${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}

`; root.querySelectorAll('[data-delete]').forEach(b=>b.onclick=async()=>{const p=rows.find(p=>p.id===b.dataset.delete);if(!confirm(`Profil „${p.name}“ löschen? Modelldateien und Komponenten bleiben in der Bibliothek.`))return;b.disabled=true;try{await api('profiles/delete',{id:p.id,revision:p.revision});panelSequence++;el('profile-editor').replaceChildren();await load();message('Profil gelöscht. Modelldateien und Komponenten bleiben erhalten.');}catch(error){message(error.message);b.disabled=false;}}); root.querySelectorAll('[data-components]').forEach(b=>b.onclick=()=>components(rows.find(p=>p.id===b.dataset.components))); root.querySelectorAll('[data-edit]').forEach(b=>b.onclick=()=>editor(rows.find(p=>p.id===b.dataset.edit))); @@ -25,13 +25,19 @@ window.ProfilesUI=(()=>{ root.querySelectorAll('[data-component-download]').forEach(b=>b.onclick=async()=>{const [role,index]=b.dataset.componentDownload.split(':'),f=data.requirements.find(r=>r.role===role).candidates[Number(index)];b.disabled=true;try{await api('catalog/download',{repo:f.repo,filename:f.name,revision:f.revision,kind:'image'});if(el('components-message'))el('components-message').textContent='Download gestartet. Fortschritt und Abbruch findest du unter Downloads. Nach Abschluss hier die Bibliothek aktualisieren.';}catch(error){if(el('components-message'))el('components-message').textContent=error.message;b.disabled=false;}}); }catch(error){message(error.message);el('profile-editor').innerHTML='

Komponenten konnten nicht geladen werden. Öffne die Ansicht erneut.

';} } + function gpuEditor(params){ + const selected=params.gpu_devices||[],available=[...gpus];for(const uuid of selected)if(!available.some(g=>g.uuid===uuid))available.push({uuid,name:'Derzeit nicht verfügbar'}); + const options=(value,empty)=>``+available.map(g=>``).join(''); + return `

GPU-Auswahl und Verteilung

Die Reihenfolge legt die logischen CUDA-Geräte des Profils fest. Für Medium: RTX 5080 zuerst, RTX 3060 danach, Layer-Split und 85,15. Leere Verteilung bedeutet automatisch. Dies ist keine Zusage, dass Modell und Kontext in den verfügbaren Speicher passen.

`; + } function editor(p=null,id=null){ panelSequence++; if(!models.length)return;const data=p||{id:null,revision:0,name:'',model_id:id||models[0].id,parameters:Object.fromEntries(Object.entries(schema).map(([k,v])=>[k,v[2]]))}; - el('profile-editor').innerHTML=`

${data.id?'Profil bearbeiten':'Profil anlegen'}

${Object.entries(schema).map(([k,[min,max,defaultValue]])=>``).join('')}

Speichern startet kein Modell. Die Ausführung benötigt eine passende Laufzeit und gegebenenfalls zusätzliche Modelldateien. ${kind==='chat'?'Das Kontextbudget gilt insgesamt für alle Slots.':''}

`; + el('profile-editor').innerHTML=`

${data.id?'Profil bearbeiten':'Profil anlegen'}

${Object.entries(schema).map(([k,[min,max,defaultValue]])=>``).join('')}
${kind==='chat'?gpuEditor(data.parameters):''}

Speichern startet kein Modell. Die Ausführung benötigt eine passende Laufzeit und gegebenenfalls zusätzliche Modelldateien. ${kind==='chat'?'Das Kontextbudget gilt insgesamt für alle Slots.':''}

`; + if(kind==='chat'){const threads=el('profile-form').querySelector('[name=threads]').closest('label');const advanced=document.createElement('details');advanced.innerHTML='Erweitert: CPU-Threads

CPU-Threads steuern CPU-Arbeit und Offloading, nicht die Anzahl der CUDA-Rechenkerne.

';advanced.append(threads);el('profile-form').insertBefore(advanced,el('profile-form').querySelector('.note'));} el('profile-close').onclick=()=>el('profile-editor').replaceChildren(); el('profile-form').onsubmit=async event=>{event.preventDefault();const form=event.target,values=new FormData(form),button=form.querySelector('button');button.disabled=true; - try{await api('profiles/save',{id:data.id,revision:data.revision,name:values.get('name'),kind,model_id:values.get('model_id'),parameters:Object.fromEntries(Object.keys(schema).map(k=>[k,Number(values.get(k))]))});if(!root.isConnected)return;el('profile-editor').replaceChildren();await load();message('Profil auf Athena gespeichert. Kein Modell gestartet.');} + try{const parameters=Object.fromEntries(Object.keys(schema).map(k=>[k,Number(values.get(k))]));if(kind==='chat'){if(values.get('gpu_second')&&!values.get('gpu_first'))throw Error('Bitte zuerst die erste GPU auswählen.');if((data.parameters.gpu_devices||[]).length>2)throw Error('Dieses Profil enthält mehr als zwei GPUs; der Editor unterstützt derzeit zwei.');parameters.gpu_devices=[values.get('gpu_first'),values.get('gpu_second')].filter(Boolean);parameters.split_mode=values.get('split_mode');const raw=String(values.get('tensor_split')).trim();parameters.tensor_split=raw?raw.split(',').map(x=>x.trim()?Number(x):NaN):[];}await api('profiles/save',{id:data.id,revision:data.revision,name:values.get('name'),kind,model_id:values.get('model_id'),parameters});if(!root.isConnected)return;el('profile-editor').replaceChildren();await load();message('Profil auf Athena gespeichert. Kein Modell gestartet.');} catch(error){if(root.isConnected)el('profile-error').textContent=error.message;}finally{button.disabled=false;} }; el('profile-editor').scrollIntoView({behavior:'smooth',block:'start'}); diff --git a/profiles.py b/profiles.py index 813feb3..48696ec 100644 --- a/profiles.py +++ b/profiles.py @@ -7,11 +7,24 @@ import uuid from pathlib import Path SCHEMAS={ - 'chat':{'context':(512,2097152,8192),'slots':(1,16,1),'threads':(1,256,6),'batch':(1,8192,512),'ubatch':(1,8192,128)}, + 'chat':{'context':(512,2097152,8192),'slots':(1,16,1),'threads':(1,256,6),'batch':(1,8192,512),'ubatch':(1,8192,128),'temperature':(0,5,.8),'top_p':(0,1,.95),'top_k':(0,1000,40)}, 'image':{'width':(256,2048,1024),'height':(256,2048,1024),'steps':(1,100,25),'seed':(-1,2147483647,-1),'guidance':(0,30,1)}, 'audio':{'speed':(.25,4,1)}, 'video':{'width':(256,1920,768),'height':(256,1088,512),'frames':(1,241,33),'fps':(1,60,24),'steps':(1,100,20),'seed':(-1,2147483647,-1)} } +CHAT_GPU_DEFAULTS={'gpu_devices':[], 'split_mode':'none', 'tensor_split':[]} +CHAT_SAMPLING={'temperature':.8,'top_p':.95,'top_k':40} + +def chat_parameters(params): + params={**CHAT_GPU_DEFAULTS,**CHAT_SAMPLING,**params} + devices=params['gpu_devices'];split=params['tensor_split'] + if not isinstance(devices,list) or len(devices)>16 or any(not isinstance(v,str) or not re.fullmatch(r'GPU-[0-9a-fA-F-]{36}',v) for v in devices) or len(set(devices))!=len(devices):raise ValueError('GPUs müssen als eindeutige, geordnete GPU-UUIDs angegeben werden.') + if params['split_mode'] not in ('none','layer','row'):raise ValueError('Ungültiger GPU-Split-Modus.') + if not isinstance(split,list) or any(isinstance(v,bool) or not isinstance(v,(int,float)) or not 01 and params['split_mode']=='none':raise ValueError('Bei mehreren GPUs Layer- oder Row-Split auswählen.') + return params + # Explicit model-card recipe. This is a source recommendation, not a runtime test. QWEN_REPO='abenzerps/Qwen-Image-2.1-Uncensored-GGUF' QWEN_COMPONENTS={ @@ -27,6 +40,7 @@ class Profiles: with self.lock: rows=json.loads(json.dumps(self.rows)) for p in rows: + if p['kind']=='chat':p['parameters']={**CHAT_GPU_DEFAULTS,**CHAT_SAMPLING,**p['parameters']} try: p['model']=self.catalog.entry(p['model_id']) p['blockers']=['Für dieses Profil ist noch kein ausführbarer Worker angebunden.'] @@ -47,10 +61,11 @@ class Profiles: model=self.catalog.entry(data['model_id']) if model['repo']==QWEN_REPO and any(model['file'] in r['files'] for r in QWEN_COMPONENTS.values()):raise ValueError('Textencoder und VAE werden über Komponenten zugeordnet, nicht als Hauptmodell.') if model['kind']!=kind or not model['profile_eligible']:raise ValueError('Eine Gewichtsdatei dieses Bereichs auswählen, keine Konfiguration.') - if not isinstance(params,dict) or set(params)!=set(SCHEMAS[kind]):raise ValueError('Unvollständige oder unbekannte Profilparameter.') + if kind=='chat' and isinstance(params,dict):params=chat_parameters(params) + if not isinstance(params,dict) or set(params)!=(set(SCHEMAS[kind]) | (set(CHAT_GPU_DEFAULTS) if kind=='chat' else set())):raise ValueError('Unvollständige oder unbekannte Profilparameter.') for key,(lo,hi,_) in SCHEMAS[kind].items(): value=params[key] - floating=key in ('guidance','speed') + floating=key in ('guidance','speed','temperature','top_p') if isinstance(value,bool) or not isinstance(value,(float,int) if floating else int) or not lo<=value<=hi:raise ValueError('Ungültiger Parameter: '+key) if kind in ('image','video') and (params['width']%64 or params['height']%64):raise ValueError('Breite und Höhe müssen durch 64 teilbar sein.') if kind=='chat' and params['ubatch']>params['batch']:raise ValueError('Microbatch darf nicht größer als Batch sein.') diff --git a/test_model_management.py b/test_model_management.py index 15dfb7c..c9a6206 100644 --- a/test_model_management.py +++ b/test_model_management.py @@ -15,6 +15,21 @@ class ManagementTests(unittest.TestCase): (target/'entry.json').write_text(json.dumps(self.entry));self.catalog=Catalog(self.root/'models');self.profiles=Profiles(self.root/'profiles.json',self.catalog) def tearDown(self):self.tmp.cleanup() def request(self):return dict(id=None,revision=0,name='image-test',kind='image',model_id=self.model_id,parameters=dict(width=1024,height=1024,steps=25,seed=-1,guidance=1)) + def test_chat_medium_and_legacy_parameters(self): + self.entry['kind']='chat';(self.root/'models'/self.model_id/'entry.json').write_text(json.dumps(self.entry)) + params=dict(context=160000,slots=2,threads=6,batch=2048,ubatch=256) + req=dict(self.request(),kind='chat',name='qwen-medium',parameters=params) + saved=self.profiles.save(req) + self.assertEqual(saved['parameters']['gpu_devices'],[]) + devices=['GPU-8ad38c6c-5a01-9d8e-1dfa-ed662ad78fbe','GPU-4834d9d7-5b61-3004-1fb3-4ae49d482d4b'] + params.update(gpu_devices=devices,split_mode='layer',tensor_split=[85,15],temperature=1.0,top_p=.95,top_k=20) + req.update(id=saved['id'],revision=1) + self.profiles.save(req) + restarted=Profiles(self.root/'profiles.json',self.catalog) + self.assertEqual(restarted.status()['profiles'][0]['parameters'],params) + for change in [dict(gpu_devices=devices*2),dict(tensor_split=[85]),dict(split_mode='none'),dict(top_p=1.1),dict(temperature=float('nan')),dict(top_k=True),dict(tensor_split=[0,100]),dict(gpu_devices=['CUDA0','CUDA1'])]: + with self.subTest(change=change),self.assertRaises(ValueError):self.profiles.save(dict(req,revision=2,parameters=dict(params,**change))) + def test_profiles_persist_and_update_conflicts(self): saved=self.profiles.save(self.request());other=Profiles(self.root/'profiles.json',self.catalog) row=other.status()['profiles'][0];self.assertEqual(row['parameters']['width'],1024);self.assertFalse(row['runnable']);self.assertIn('Textencoder',row['blockers'][0])