From cbcd5a013158c86aac057ef5e2758379598f5495 Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:52:36 +0200 Subject: [PATCH] Allow readable profile names and configurable KV cache --- auto-test-ui.js | 6 +++--- auto_test.py | 8 +++++--- inference.py | 2 +- profiles-ui.js | 6 +++--- profiles.py | 5 +++-- test_auto_test.py | 9 +++++++++ test_model_management.py | 10 ++++++++-- 7 files changed, 32 insertions(+), 14 deletions(-) diff --git a/auto-test-ui.js b/auto-test-ui.js index fa49f18..3d02e35 100644 --- a/auto-test-ui.js +++ b/auto-test-ui.js @@ -1,11 +1,11 @@ window.AutoTestUI=(()=>{ const e=x=>String(x??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); async function api(path,data){const r=await fetch('/api/v1/'+path,data===undefined?{}:{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 `

Automatische Einpassung & Leistungstest

Priorität: RTX 5080 allein → möglichst kleiner Anteil auf RTX 3060 → erst zuletzt CPU-Auslagerung. Nach den Grundverteilungen aller gewählten Kontextstufen wird die 5080 für passende Dual-GPU-Kandidaten in Ein-Layer-Schritten weiter gefüllt. Synthetische Tests belegen die Deck-Laufzeit exklusiv; API-Anfragen warten. Fremde GPU-Dienste werden nicht beendet.

Ein Slot, Batch 2048, Microbatch 128 / 64 / 256. Nur Kontextstufen im gewählten Bereich; pro Kandidat werden bis nahe an das volle Kontextbudget Eingabetoken geprüft und 64 Token erzeugt. Alle Grundverteilungen 5080 allein sowie 98:2, 95:5, 90:10, 85:15, 75:25 und 50:50 werden je Stufe vor der Layer-Feinsuche geprüft. Höchstens 300 Kandidaten oder zwei Stunden; bei Abbruch wird die Stufe als unvollständig markiert. Kein absichtliches Übergehen der Speicherreserve.

Dies ist eine begrenzte Suche, kein Beweis des absoluten Optimums oder der Stabilität bei vollem Kontext und paralleler Last. Erfolgreiche Profile werden nur auf Wunsch gespeichert, nicht am Endpunkt freigegeben.

`;} + function html(){return `

Automatische Einpassung & Leistungstest

Priorität: RTX 5080 allein → möglichst kleiner Anteil auf RTX 3060 → erst zuletzt CPU-Auslagerung. Nach den Grundverteilungen aller gewählten Kontextstufen wird die 5080 für passende Dual-GPU-Kandidaten in Ein-Layer-Schritten weiter gefüllt. Synthetische Tests belegen die Deck-Laufzeit exklusiv; API-Anfragen warten. Fremde GPU-Dienste werden nicht beendet.

Ein Slot, Batch 2048, Microbatch 128 / 64 / 256. Nur Kontextstufen im gewählten Bereich; pro Kandidat werden bis nahe an das volle Kontextbudget Eingabetoken geprüft und 64 Token erzeugt. Alle Grundverteilungen 5080 allein sowie 98:2, 95:5, 90:10, 85:15, 75:25 und 50:50 werden je Stufe vor der Layer-Feinsuche geprüft. Höchstens 300 Kandidaten oder zwei Stunden; bei Abbruch wird die Stufe als unvollständig markiert. Kein absichtliches Übergehen der Speicherreserve.

Dies ist eine begrenzte Suche, kein Beweis des absoluten Optimums oder der Stabilität bei vollem Kontext und paralleler Last. Erfolgreiche Profile werden nur auf Wunsch gespeichert, nicht am Endpunkt freigegeben.

`;} function bind(){const root=document.querySelector('#auto-test'),el=x=>root.querySelector('#'+x);let job=null,last='';const msg=x=>{if(root.isConnected)el('auto-message').textContent=x;}; api('catalog').then(s=>{if(root.isConnected)el('auto-model').innerHTML=s.entries.filter(x=>x.kind==='chat'&&x.profile_eligible&&x.file.endsWith('.gguf')).map(x=>``).join('');}).catch(x=>msg(x.message)); - async function refresh(){try{const s=await api('auto-tests');if(!root.isConnected)return;job=s.job;const running=job?.state==='running';el('auto-start').disabled=running;el('auto-stop').disabled=!running;el('auto-phase').textContent=job?`${job.state} · ${job.phase} · ${job.attempts}/${job.max_candidates||96} Kandidaten · vollständig geprüfte Stufen: ${(job.completed_contexts||[]).map(x=>x.toLocaleString('de-DE')).join(', ')||'keine'}${job.error?' · '+job.error:''}${job.fine_search_note?' · '+job.fine_search_note:''}`:'Noch kein Auto-Test.';const rows=job?.results||[],signature=JSON.stringify(rows);if(last!==signature){last=signature;const best=rows.reduce((a,r,i)=>r.success&&(!a||r.timings.predicted_per_second>a.speed)?{i,speed:r.timings.predicted_per_second}:a,null);el('auto-results').innerHTML=rows.map((r,i)=>`

${r.context.toLocaleString('de-DE')} Kontext · ${e(r.tier)} ${best?.i===i?'· schnellster gemessener Kandidat':''}

Verteilung ${e(r.parameters.tensor_split.map(v=>Number(v.toFixed(3))).join(':')||'nur 5080')} · Microbatch ${r.parameters.ubatch}${r.primary_gpu_layers?` · geplant ${r.primary_gpu_layers} GPU-Layer auf der 5080 (inkl. Ausgabe/MTP gemäß GGUF)`:""}

${r.success?`

Prefill ${Number(r.timings.prompt_per_second).toFixed(1)} Token/s · Ausgabe ${Number(r.timings.predicted_per_second).toFixed(1)} Token/s · ${Number(r.timings.prompt_n||0).toLocaleString('de-DE')} / ${r.context.toLocaleString('de-DE')} Eingabe-Token geprüft (${(100*(r.tested_context_fraction||0)).toFixed(1)} %)

GPU-Layerlimit: ${r.memory_plan.gpu_layers===999?'alle':r.memory_plan.gpu_layers}

`:`

${r.failure_stage==='prediction'?'Speicherprognose abgelehnt · kein Modellstart':r.failure_stage==='model_start'?'Modellstart fehlgeschlagen · Ursache nicht als GPU-OOM bewiesen':'Messung fehlgeschlagen · Ursache nicht als GPU-OOM bewiesen'}: ${e(r.error)}

`}
`).join('');root.querySelectorAll('[data-save]').forEach(b=>b.onclick=async()=>{try{await api('auto-tests/save',{job_id:job.id,index:Number(b.dataset.save),name:el('auto-name').value});msg('Profil gespeichert.');}catch(x){msg(x.message);}});}}catch(x){msg(x.message);}finally{if(root.isConnected)setTimeout(refresh,2000);}} - el('auto-form').onsubmit=async ev=>{ev.preventDefault();try{await api('auto-tests/start',{model_id:el('auto-model').value,start_context:Number(el('auto-start-context').value),max_context:Number(el('auto-context').value),mtp:el('auto-mtp').checked});msg('Auto-Test gestartet.');}catch(x){msg(x.message);}}; + async function refresh(){try{const s=await api('auto-tests');if(!root.isConnected)return;job=s.job;const running=job?.state==='running';el('auto-start').disabled=running;el('auto-stop').disabled=!running;el('auto-phase').textContent=job?`${job.state} · ${job.phase} · ${job.attempts}/${job.max_candidates||96} Kandidaten · vollständig geprüfte Stufen: ${(job.completed_contexts||[]).map(x=>x.toLocaleString('de-DE')).join(', ')||'keine'}${job.error?' · '+job.error:''}${job.fine_search_note?' · '+job.fine_search_note:''}`:'Noch kein Auto-Test.';const rows=job?.results||[],signature=JSON.stringify(rows);if(last!==signature){last=signature;const best=rows.reduce((a,r,i)=>r.success&&(!a||r.timings.predicted_per_second>a.speed)?{i,speed:r.timings.predicted_per_second}:a,null);el('auto-results').innerHTML=rows.map((r,i)=>`

${r.context.toLocaleString('de-DE')} Kontext · ${e(r.tier)} ${best?.i===i?'· schnellster gemessener Kandidat':''}

Verteilung ${e(r.parameters.tensor_split.map(v=>Number(v.toFixed(3))).join(':')||'nur 5080')} · Microbatch ${r.parameters.ubatch} · KV ${e(r.parameters.cache_type_k||"q4_0")}/${e(r.parameters.cache_type_v||"q4_0")}${r.primary_gpu_layers?` · geplant ${r.primary_gpu_layers} GPU-Layer auf der 5080 (inkl. Ausgabe/MTP gemäß GGUF)`:""}

${r.success?`

Prefill ${Number(r.timings.prompt_per_second).toFixed(1)} Token/s · Ausgabe ${Number(r.timings.predicted_per_second).toFixed(1)} Token/s · ${Number(r.timings.prompt_n||0).toLocaleString('de-DE')} / ${r.context.toLocaleString('de-DE')} Eingabe-Token geprüft (${(100*(r.tested_context_fraction||0)).toFixed(1)} %)

GPU-Layerlimit: ${r.memory_plan.gpu_layers===999?'alle':r.memory_plan.gpu_layers}

`:`

${r.failure_stage==='prediction'?'Speicherprognose abgelehnt · kein Modellstart':r.failure_stage==='model_start'?'Modellstart fehlgeschlagen · Ursache nicht als GPU-OOM bewiesen':'Messung fehlgeschlagen · Ursache nicht als GPU-OOM bewiesen'}: ${e(r.error)}

`}
`).join('');root.querySelectorAll('[data-save]').forEach(b=>b.onclick=async()=>{try{await api('auto-tests/save',{job_id:job.id,index:Number(b.dataset.save),name:el('auto-name').value});msg('Profil gespeichert.');}catch(x){msg(x.message);}});}}catch(x){msg(x.message);}finally{if(root.isConnected)setTimeout(refresh,2000);}} + el('auto-form').onsubmit=async ev=>{ev.preventDefault();try{await api('auto-tests/start',{model_id:el('auto-model').value,start_context:Number(el('auto-start-context').value),max_context:Number(el('auto-context').value),mtp:el('auto-mtp').checked,cache_type_k:el('auto-cache-k').value,cache_type_v:el('auto-cache-v').value});msg('Auto-Test gestartet.');}catch(x){msg(x.message);}}; el('auto-stop').onclick=()=>api('auto-tests/cancel',{}).then(()=>msg('Abbruch angefordert.')).catch(x=>msg(x.message));refresh(); }return {html,bind}; })(); diff --git a/auto_test.py b/auto_test.py index 2a911e8..9a5f522 100644 --- a/auto_test.py +++ b/auto_test.py @@ -73,16 +73,18 @@ class AutoTests: def update(self,**kw): with self.lock:self.job.update(kw);self.persist() def start(self,data): - if set(data) not in ({'model_id','max_context','mtp'},{'model_id','start_context','max_context','mtp'}):raise ValueError('Modell, Start- und Endkontext sowie MTP auswählen.') + fields=set(data);base={'model_id','max_context','mtp'} + if fields-base-{'start_context','cache_type_k','cache_type_v'} or not base<=fields or (('cache_type_k' in fields)!=('cache_type_v' in fields)):raise ValueError('Modell, Start- und Endkontext, MTP und beide KV-Cache-Typen auswählen.') start_context=data.get('start_context',2048) if type(start_context)!=int or start_context not in CONTEXT_STEPS or type(data['max_context'])!=int or data['max_context'] not in CONTEXT_STEPS or start_context>data['max_context'] or type(data['mtp'])!=bool:raise ValueError('Start- und Endkontext müssen gültige Stufen in aufsteigender Reihenfolge sein.') + if data.get('cache_type_k','q4_0') not in ('f16','q8_0','q4_0') or data.get('cache_type_v','q4_0') not in ('f16','q8_0','q4_0'):raise ValueError('KV-Cache: nur f16, q8_0 oder q4_0 unterstützt.') model=self.worker.catalog.entry(data['model_id']) if model['kind']!='chat' or not model['file'].endswith('.gguf') or not model['profile_eligible']:raise ValueError('Ein heruntergeladenes Chat-GGUF auswählen.') devices=probe();primary=next((g for g in devices if '5080' in g['name']),None);secondary=next((g for g in devices if '3060' in g['name']),None) if not primary or not secondary:raise ValueError('Dieser Auto-Test benötigt RTX 5080 und RTX 3060. Keine stillschweigende andere GPU-Zuordnung.') with self.lock: if self.thread and self.thread.is_alive():raise ValueError('Ein Auto-Test läuft bereits.') - self.cancel.clear();self.job=dict(id=uuid.uuid4().hex,state='running',phase='Wartet auf exklusive Modellreservierung',model_id=model['id'],model_file=model['file'],start_context=start_context,max_context=data['max_context'],mtp=data['mtp'],results=[],attempts=0,max_candidates=MAX_CANDIDATES,completed_contexts=[],started_at=time.time(),error=None);self.persist() + self.cancel.clear();self.job=dict(id=uuid.uuid4().hex,state='running',phase='Wartet auf exklusive Modellreservierung',model_id=model['id'],model_file=model['file'],start_context=start_context,max_context=data['max_context'],mtp=data['mtp'],cache_type_k=data.get('cache_type_k','q4_0'),cache_type_v=data.get('cache_type_v','q4_0'),results=[],attempts=0,max_candidates=MAX_CANDIDATES,completed_contexts=[],started_at=time.time(),error=None);self.persist() self.thread=threading.Thread(target=self.run,args=(primary['uuid'],secondary['uuid']),daemon=True);self.thread.start() return self.status() def stop(self): @@ -120,7 +122,7 @@ class AutoTests: if self.cancel.is_set():raise InterruptedError() if self.job['attempts']>=self.job.get('max_candidates',MAX_CANDIDATES):raise TestBudget('Kandidatenlimit erreicht') if time.time()-self.job['started_at']>MAX_SECONDS:raise TestBudget('Zeitlimit erreicht') - params={**{k:v[2] for k,v in SCHEMAS['chat'].items()},**CHAT_GPU_DEFAULTS,'context':context,'slots':1,'batch':2048,'ubatch':micro,'gpu_devices':devices,'split_mode':split,'tensor_split':ratio,'gpu_offload':offload,'mtp':self.job['mtp']} + params={**{k:v[2] for k,v in SCHEMAS['chat'].items()},**CHAT_GPU_DEFAULTS,'context':context,'slots':1,'batch':2048,'ubatch':micro,'gpu_devices':devices,'split_mode':split,'tensor_split':ratio,'gpu_offload':offload,'mtp':self.job['mtp'],'cache_type_k':self.job.get('cache_type_k','q4_0'),'cache_type_v':self.job.get('cache_type_v','q4_0')} p=dict(id='auto-'+self.job['id'],revision=self.job['attempts']+1,name='deck-auto-test',kind='chat',model_id=self.job['model_id'],parameters=params) self.update(attempts=p['revision'],phase=f'{context} Kontext · {tier} · Microbatch {micro}') row=dict(context=context,tier=tier,parameters=params,success=False,primary_gpu_layers=layer_count) diff --git a/inference.py b/inference.py index c89bf54..963633b 100644 --- a/inference.py +++ b/inference.py @@ -177,7 +177,7 @@ class LlamaWorker: env.update(CUDA_VISIBLE_DEVICES=','.join(g['uuid'] for g in selected),HF_HUB_OFFLINE='1',OMP_NUM_THREADS=str(params['threads'])) reserve_mode=params.get('gpu_reserve_mode','auto') margins=[0 if reserve_mode=='none' else params.get('gpu_reserve_mib',{}).get(g['uuid'],512) if reserve_mode=='manual' else max(512,round(g['total_mib']*.025)) for g in selected] - common=['--model',str(model),'--ctx-size',str(params['context']),'--parallel',str(params['slots']),'--batch-size',str(params['batch']),'--ubatch-size',str(params['ubatch']),'--cache-type-k','q4_0','--cache-type-v','q4_0','--flash-attn','on','--split-mode',params['split_mode'],'--fit-target',','.join(map(str,margins))] + common=['--model',str(model),'--ctx-size',str(params['context']),'--parallel',str(params['slots']),'--batch-size',str(params['batch']),'--ubatch-size',str(params['ubatch']),'--cache-type-k',params.get('cache_type_k','q4_0'),'--cache-type-v',params.get('cache_type_v','q4_0'),'--flash-attn','on','--split-mode',params['split_mode'],'--fit-target',','.join(map(str,margins))] if params['tensor_split']:common+=['--tensor-split',','.join(map(str,params['tensor_split']))] tool=str(directory/'build/bin/llama-fit-params') mtp_fit=['--deck-mtp'] if mtp else [] diff --git a/profiles-ui.js b/profiles-ui.js index ac7eb15..65b1709 100644 --- a/profiles-ui.js +++ b/profiles-ui.js @@ -1,6 +1,6 @@ window.ProfilesUI=(()=>{ const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); - const labels={video_device:'Videopipeline',text_encoder_device:'Textencoder-Gerät',gpu_reserve_mode:'GPU-Reservemodus',gpu_reserve_mib:'GPU-Reserve (MiB)',vision_projector:'Vision-Projektor',vision_device:'Projektor-Gerät',gpu_offload:'GPU-Ausführung',mtp:'MTP',mtp_tokens:'MTP: maximale Draft-Token',mtp_min_p:'MTP: Mindestwahrscheinlichkeit',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 labels={video_device:'Videopipeline',text_encoder_device:'Textencoder-Gerät',gpu_reserve_mode:'GPU-Reservemodus',gpu_reserve_mib:'GPU-Reserve (MiB)',vision_projector:'Vision-Projektor',vision_device:'Projektor-Gerät',cache_type_k:'KV-Cache K',cache_type_v:'KV-Cache V',gpu_offload:'GPU-Ausführung',mtp:'MTP',mtp_tokens:'MTP: maximale Draft-Token',mtp_min_p:'MTP: Mindestwahrscheinlichkeit',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){ @@ -133,13 +133,13 @@ window.ProfilesUI=(()=>{ 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]]))}; if(!p&&kind==='image'&&models.find(m=>m.id===data.model_id)?.repo==='RunningHubAI/rh-flux.2-klein-9b-fp16-unet-2067980602644717569'){data.parameters.steps=4;data.parameters.guidance=1;} - el('profile-editor').innerHTML=`

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

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

Vision-Projektor

Passende mmproj.gguf unter Entdecken herunterladen – auch aus einem anderen Repository als das quantisierte Modell. Modell und Projektor müssen zusammenpassen; der Dateiname allein garantiert keine Kompatibilität. GPU-Ziel muss oben ausgewählt sein. GPU-Projektoren erhalten eine zusätzliche konservative Speicherreserve.

Nur für GGUF-Modelle mit MTP-Gewichten und kompatible Builds. Zusätzlicher Speicherbedarf; Geschwindigkeit hängt vom Modell ab. Referenz Medium: 2 Draft-Token, Mindestwahrscheinlichkeit 0,05, Draft-KV f16.

`:''}

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==='video'?videoDevices(data):''}${kind==='chat'?gpuEditor(data.parameters)+`

KV-Cache

${["k","v"].map(axis=>``).join("")}

q4_0 spart VRAM; q8_0 und f16 brauchen mehr Speicher. K und V werden getrennt eingestellt. MTP-Draft-KV bleibt separat f16.

Vision-Projektor

Passende mmproj.gguf unter Entdecken herunterladen – auch aus einem anderen Repository als das quantisierte Modell. Modell und Projektor müssen zusammenpassen; der Dateiname allein garantiert keine Kompatibilität. GPU-Ziel muss oben ausgewählt sein. GPU-Projektoren erhalten eine zusätzliche konservative Speicherreserve.

Nur für GGUF-Modelle mit MTP-Gewichten und kompatible Builds. Zusätzlicher Speicherbedarf; Geschwindigkeit hängt vom Modell ab. Referenz Medium: 2 Draft-Token, Mindestwahrscheinlichkeit 0,05, Draft-KV f16.

`:''}

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'));} if(kind==='chat'){bindProjectorDiscovery();const f=el('profile-form');const reserveFields=()=>{for(const n of ['reserve_first','reserve_second'])f.elements[n].disabled=f.elements.gpu_reserve_mode.value!=='manual';};f.elements.gpu_reserve_mode.onchange=reserveFields;reserveFields();} if(kind==='video')bindVideoDevices(data); 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:not([type=button])');button.disabled=true; - try{const parameters=Object.fromEntries(Object.keys(schema).map(k=>[k,Number(values.get(k))]));if(kind==='video'){parameters.video_device=values.get('video_device');parameters.text_encoder_device=values.get('text_encoder_device');}if(kind==='chat'){parameters.vision_projector=values.get('vision_projector')||null;parameters.vision_device=values.get('vision_device');parameters.mtp=values.has('mtp');parameters.gpu_offload=values.get('gpu_offload');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.gpu_reserve_mode=values.get('gpu_reserve_mode');parameters.gpu_reserve_mib=parameters.gpu_reserve_mode==='manual'?Object.fromEntries([['gpu_first','reserve_first'],['gpu_second','reserve_second']].filter(([g])=>values.get(g)).map(([g,r])=>[values.get(g),Number(values.get(r))])):{};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.');} + try{const parameters=Object.fromEntries(Object.keys(schema).map(k=>[k,Number(values.get(k))]));if(kind==='video'){parameters.video_device=values.get('video_device');parameters.text_encoder_device=values.get('text_encoder_device');}if(kind==='chat'){parameters.vision_projector=values.get('vision_projector')||null;parameters.vision_device=values.get('vision_device');parameters.mtp=values.has('mtp');parameters.cache_type_k=values.get('cache_type_k');parameters.cache_type_v=values.get('cache_type_v');parameters.gpu_offload=values.get('gpu_offload');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.gpu_reserve_mode=values.get('gpu_reserve_mode');parameters.gpu_reserve_mib=parameters.gpu_reserve_mode==='manual'?Object.fromEntries([['gpu_first','reserve_first'],['gpu_second','reserve_second']].filter(([g])=>values.get(g)).map(([g,r])=>[values.get(g),Number(values.get(r))])):{};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 c50bed8..4d13a86 100644 --- a/profiles.py +++ b/profiles.py @@ -24,7 +24,7 @@ def video_parameters(params): raise ValueError('Video-Gerät muss eine GPU-UUID sein; CPU wird im Disk-Streaming-Adapter noch nicht unterstützt.') return params -CHAT_GPU_DEFAULTS={'gpu_devices':[], 'split_mode':'none', 'tensor_split':[], 'mtp':False,'gpu_offload':'auto','gpu_reserve_mode':'auto','gpu_reserve_mib':{},'vision_projector':None,'vision_device':'cpu'} +CHAT_GPU_DEFAULTS={'gpu_devices':[], 'split_mode':'none', 'tensor_split':[], 'mtp':False,'gpu_offload':'auto','gpu_reserve_mode':'auto','gpu_reserve_mib':{},'vision_projector':None,'vision_device':'cpu','cache_type_k':'q4_0','cache_type_v':'q4_0'} CHAT_SAMPLING={'temperature':.8,'top_p':.95,'top_k':40,'mtp_tokens':2,'mtp_min_p':.05} def chat_parameters(params): @@ -34,6 +34,7 @@ def chat_parameters(params): if not isinstance(reserve,dict) or any(k not in params['gpu_devices'] or type(v) is not int or not 0<=v<=32768 for k,v in reserve.items()):raise ValueError('GPU-Reserve muss je ausgewählter GPU zwischen 0 und 32768 MiB liegen.') if params['gpu_reserve_mode']=='manual' and (not params['gpu_devices'] or set(reserve)!=set(params['gpu_devices'])):raise ValueError('Manuelle Reserve erfordert einen Wert für jede ausgewählte GPU.') if params['gpu_offload'] not in ('auto','full'):raise ValueError('Ungültiger GPU-Auslagerungsmodus.') + if params['cache_type_k'] not in ('f16','q8_0','q4_0') or params['cache_type_v'] not in ('f16','q8_0','q4_0'):raise ValueError('KV-Cache: nur f16, q8_0 oder q4_0 unterstützt.') if type(params['mtp']) is not bool:raise ValueError('MTP muss an oder aus sein.') if params['vision_projector'] is not None and (not isinstance(params['vision_projector'],str) or not re.fullmatch(r'[a-f0-9]{64}',params['vision_projector'])):raise ValueError('Ungültige Projektor-ID.') if params['vision_device']!='cpu' and params['vision_device'] not in params['gpu_devices']:raise ValueError('Projektor-GPU muss in der GPU-Auswahl enthalten sein.') @@ -124,7 +125,7 @@ class Profiles: if set(data)!={'id','revision','name','kind','model_id','parameters'}:raise ValueError('Ungültige Profilfelder.') kind=data['kind'];name=data['name'];params=data['parameters'] if kind=='video':raise ValueError('Videoprofile werden nicht mehr in Deck verwaltet. Originale LTX-Oberfläche verwenden.') - if not isinstance(kind,str) or kind not in SCHEMAS or not isinstance(name,str) or not re.fullmatch('[a-zA-Z0-9][a-zA-Z0-9_-]{1,63}',name):raise ValueError('API-Name: 2–64 Buchstaben, Ziffern, Bindestrich oder Unterstrich.') + if not isinstance(kind,str) or kind not in SCHEMAS or not isinstance(name,str) or not (2<=len(name)<=64 and name[0].isalnum() and name[-1].isalnum() and re.fullmatch(r'[\w .-]+',name)):raise ValueError('API-Name: 2–64 Zeichen; Buchstaben, Ziffern, Leerzeichen, Punkt, Bindestrich oder Unterstrich. Anfang und Ende müssen Buchstabe oder Ziffer sein.') 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.') diff --git a/test_auto_test.py b/test_auto_test.py index f98f5a8..d99f6ce 100644 --- a/test_auto_test.py +++ b/test_auto_test.py @@ -22,6 +22,15 @@ class AutoTestsTests(unittest.TestCase): self.assertIn((75,25),tested_splits);self.assertIn((50,50),tested_splits) self.auto.save(dict(job_id=job['id'],index=0,name='saved'));self.profiles.save.assert_called_once() self.assertEqual(AutoTests(self.auto.path,self.profiles,self.worker,self.scheduler).job['state'],'complete') + def test_kv_cache_choice_is_used_and_saved(self): + self.auto.request=Mock(return_value={});self.auto.benchmark=Mock(return_value={'predicted_per_second':40}) + with patch('auto_test.probe',return_value=self.gpus): + self.auto.start(dict(model_id='m',start_context=4096,max_context=4096,mtp=False,cache_type_k='q8_0',cache_type_v='f16')) + self.auto.thread.join(3) + row=next(r for r in self.auto.job['results'] if r['success']) + self.assertEqual((row['parameters']['cache_type_k'],row['parameters']['cache_type_v']),('q8_0','f16')) + self.auto.save(dict(job_id=self.auto.job['id'],index=self.auto.job['results'].index(row),name='KV Test')) + self.assertEqual(self.profiles.save.call_args.args[0]['parameters']['cache_type_v'],'f16') def test_secondary_before_cpu(self): def plan(p,cancel): if p['parameters']['gpu_offload']=='full':raise InferenceError('does not fit') diff --git a/test_model_management.py b/test_model_management.py index fb95382..11bb0e4 100644 --- a/test_model_management.py +++ b/test_model_management.py @@ -39,7 +39,7 @@ class ManagementTests(unittest.TestCase): 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'],dict(params,mtp=False,mtp_tokens=2,mtp_min_p=.05,gpu_offload='auto',gpu_reserve_mode='auto',gpu_reserve_mib={},vision_projector=None,vision_device='cpu')) + self.assertEqual(restarted.status()['profiles'][0]['parameters'],dict(params,mtp=False,mtp_tokens=2,mtp_min_p=.05,gpu_offload='auto',gpu_reserve_mode='auto',gpu_reserve_mib={},vision_projector=None,vision_device='cpu',cache_type_k='q4_0',cache_type_v='q4_0')) for change in [dict(gpu_offload='unknown'),dict(mtp='yes'),dict(mtp_tokens=0),dict(mtp_tokens=True),dict(mtp_min_p=1.1),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))) @@ -49,8 +49,14 @@ class ManagementTests(unittest.TestCase): change=self.request();change.update(id=saved['id'],revision=saved['revision']);change['parameters']['width']=512;other.save(change) with self.assertRaises(ValueError):other.save(change) self.assertEqual(other.status()['profiles'][0]['parameters']['width'],512) + def test_readable_api_profile_name(self): + row=self.profiles.save(dict(self.request(),name='Qwen3.8 27B MEDIUM')) + self.assertEqual(row['name'],'Qwen3.8 27B MEDIUM') + self.assertEqual(Profiles(self.root/'profiles.json',self.catalog).status()['profiles'][0]['name'],row['name']) + for bad in (' Qwen','Qwen ','Qwen/Medium','Qwen\nMedium'): + with self.subTest(bad=bad),self.assertRaises(ValueError):self.profiles.save(dict(self.request(),name=bad)) def test_profile_rejects_invalid_model_kind_parameters(self): - for change in [dict(model_id='../secrets'),dict(kind='chat'),dict(name='bad name')]: + for change in [dict(model_id='../secrets'),dict(kind='chat'),dict(name='bad/name')]: with self.subTest(change=change),self.assertRaises(ValueError):self.profiles.save(dict(self.request(),**change)) for key,value in [('width',1000),('steps',True),('guidance',float('nan')),('seed',-2)]: req=self.request();req['parameters'][key]=value