From 18a5183719254e5fcc0fe2b5c387a68f961f847e Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:29:24 +0200 Subject: [PATCH] Make per-profile GPU reserve configurable without changing existing defaults --- STUDIO.md | 6 ++++++ inference.py | 7 ++++--- profiles-ui.js | 10 +++++----- profiles.py | 6 +++++- test_inference.py | 10 ++++++++++ test_model_management.py | 2 +- test_vision.py | 5 +++++ 7 files changed, 36 insertions(+), 10 deletions(-) diff --git a/STUDIO.md b/STUDIO.md index 9410b08..9176e98 100644 --- a/STUDIO.md +++ b/STUDIO.md @@ -195,3 +195,9 @@ Eine Modellfamilie oder ein vollständiger Hugging-Face-Repository-Link sucht un ### Offizielle llama.cpp-Versionen Die Update-Abfrage zeigt das aktuelle reguläre Release (`releases/latest`) zuerst und ergänzt die jüngsten Nightlies. Reguläre Tags `vX.Y.Z`, Nightly-Tags `bNNNN` und vollständige Commit-IDs sind zulässig. Reguläre Releases werden mit `LLAMA_BUILD_IS_DEV=OFF`, Nightlies mit `ON` gebaut. Ein reguläres Release ist keine automatische Kompatibilitätszusage für Decks Fit-/MTP-Adapter; Standardwechsel bleiben explizit. Versionsschema: https://github.com/ggml-org/ggml/discussions/1579 + +### GPU-Reserve pro LLM-Profil + +Unter GPU-Auswahl: Automatisch (maximaler Wert aus 512 MiB und 2,5 Prozent je GPU), Manuell (0–32768 MiB je ausgewählter GPU) oder Keine zusätzliche Reserve. Manuelle Werte werden per GPU-UUID gespeichert. Alte Profile verwenden weiter Automatisch. Die Reserve beeinflusst Fit-Ziel und Speicherentscheidung; Modell/KV/MTP sowie die konservative Projektor-Schätzung (Dateigröße plus 2 GiB Arbeitsbedarf) bleiben enthalten. Host-RAM-Grenzen bleiben bestehen. Ohne Reserve ist ein erfolgreicher Modellstart nicht garantiert. + +Für einen Versuch ohne CPU-Offloading zusätzlich „Vollständig auf GPU · sonst Start abbrechen“ wählen. Dann gibt es bei negativer Prognose keinen automatischen Layer-Rückfall. „Automatisch“ unter GPU-Ausführung erlaubt ihn weiterhin ausdrücklich. Der Testchat zeigt Layerlimit und Reserve je GPU. Gespeicherte Profile werden durch dieses Update nicht umgestellt. diff --git a/inference.py b/inference.py index aebacb8..33fb748 100644 --- a/inference.py +++ b/inference.py @@ -161,11 +161,12 @@ class LlamaWorker: # Never inherit LLAMA_ARG_* or user HF credentials into the model server. env={k:v for k,v in os.environ.items() if not k.startswith(('LLAMA_','HF_','DECK_'))} env.update(CUDA_VISIBLE_DEVICES=','.join(g['uuid'] for g in selected),HF_HUB_OFFLINE='1',OMP_NUM_THREADS=str(params['threads'])) - 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(str(max(512,round(g['total_mib']*.025))) for g in selected)] + 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))] 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 [] - margins=[max(512,round(g['total_mib']*.025)) for g in selected] def estimate(layers,extra=()): if cancel():raise InferenceError('Modellstart abgebrochen.') output=self._fit_command([tool]+mtp_fit+common+['--gpu-layers',str(layers),'--fit-print','on']+list(extra),env,generation,cancel) @@ -180,7 +181,7 @@ class LlamaWorker: gpu_fits=all(rows['CUDA'+str(i)]+margins[i]<=g['free_mib'] for i,g in enumerate(selected)) need=max(staging,rows['Host']*1024**2+4*GIB) host_fits=mem.get('MemAvailable',0)>=need+4*GIB and (headroom is None or headroom>=need) - with self.lock:self.memory_plan=dict(profile_name=profile['name'],estimated=True,gpu_layers=layers,full_gpu=layers==999,context=params['context'],slots=params['slots'],fits=gpu_fits and host_fits,host_required_mib=round(need/1024**2),host_available_mib=round(min(mem.get('MemAvailable',0)-4*GIB,headroom if headroom is not None else mem.get('MemAvailable',0))/1024**2),gpus=[dict(name=g.get('name',g['uuid']),uuid=g['uuid'],free_mib=g['free_mib'],required_mib=rows['CUDA'+str(i)],reserve_mib=margins[i]) for i,g in enumerate(selected)]) + with self.lock:self.memory_plan=dict(profile_name=profile['name'],estimated=True,reserve_mode=reserve_mode,gpu_layers=layers,full_gpu=layers==999,context=params['context'],slots=params['slots'],fits=gpu_fits and host_fits,host_required_mib=round(need/1024**2),host_available_mib=round(min(mem.get('MemAvailable',0)-4*GIB,headroom if headroom is not None else mem.get('MemAvailable',0))/1024**2),gpus=[dict(name=g.get('name',g['uuid']),uuid=g['uuid'],free_mib=g['free_mib'],required_mib=rows['CUDA'+str(i)],reserve_mib=margins[i]) for i,g in enumerate(selected)]) return gpu_fits and host_fits,rows extra=[] if params.get('gpu_offload')=='full': diff --git a/profiles-ui.js b/profiles-ui.js index f315785..b1b78d1 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={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={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 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={},enabled=[],gpus=[],projectors=[],panelSequence=0; const message=t=>{if(root.isConnected)el('profile-message').textContent=t;}; async function load(){try{const [p,c,h,ep]=await Promise.all([api('profiles'),api('catalog'),kind==='chat'?api('hardware').catch(()=>({gpus:[]})):Promise.resolve({gpus:[]}),api('endpoint')]);projectors=c.entries.filter(x=>x.role==='vision_projector'&&x.file.endsWith('.gguf'));enabled=ep.profiles.filter(x=>x.enabled).map(x=>x.id);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(' · ')}

${p.runnable?'Laufzeit bereit · lädt bei Anfrage':'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.'}

`; + 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_reserve_mode'?({auto:'Automatisch',manual:'Manuell',none:'Keine zusätzliche Reserve'}[v]||v):k==='gpu_reserve_mib'?(Object.entries(v).map(([id,n])=>`${gpus.find(g=>g.uuid===id)?.name||id}: ${n} MiB`).join(', ')||'gemäß Reservemodus'):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(' · ')}

${p.runnable?'Laufzeit bereit · lädt bei Anfrage':'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.'}

`; el('profile-list').insertAdjacentHTML('afterbegin',`

${kind==='image'?'Höchstens ein Bildprofil ist am API-Endpunkt freigegeben. Ein neuer Haken ersetzt die bisherige Auswahl.':'Mehrere Sprachmodellprofile können gleichzeitig am API-Endpunkt freigegeben sein.'} Freigegeben bedeutet nicht bereits in den GPU-Speicher geladen.

`); root.querySelectorAll('[data-publish]').forEach(b=>b.onchange=async()=>{const checked=b.checked;root.querySelectorAll('[data-publish]').forEach(x=>x.disabled=true);try{await api('endpoint/profile',{id:b.dataset.publish,enabled:checked});await load();message('Endpunkt-Freigabe gespeichert. Geladen wird erst bei einer API-Anfrage.');}catch(error){await load();message(error.message);}}); 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;}}); @@ -30,7 +30,7 @@ window.ProfilesUI=(()=>{ 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.

`; + return `

GPU-Auswahl und Verteilung

Manuelle Reserven gelten für die jeweils ausgewählte GPU. Ohne Reserve entfällt nur der zusätzliche GPU-Puffer; Modell, KV-Cache und die konservative Projektor-Prognose bleiben berücksichtigt. OOM bleibt möglich. Für einen Abbruch statt CPU-Auslagerung „Vollständig auf GPU“ wählen. 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 bindProjectorDiscovery(){ const form=el('profile-form'),box=el('projector-discovery'),select=form.elements.vision_projector; @@ -89,10 +89,10 @@ window.ProfilesUI=(()=>{ 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('')}
${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.':''}

`; 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(); + 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();} 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==='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.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==='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.');} 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 b760ebe..9dea0fe 100644 --- a/profiles.py +++ b/profiles.py @@ -12,11 +12,15 @@ SCHEMAS={ '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':[], 'mtp':False,'gpu_offload':'auto','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'} CHAT_SAMPLING={'temperature':.8,'top_p':.95,'top_k':40,'mtp_tokens':2,'mtp_min_p':.05} def chat_parameters(params): params={**CHAT_GPU_DEFAULTS,**CHAT_SAMPLING,**params} + if params['gpu_reserve_mode'] not in ('auto','manual','none'):raise ValueError('Ungültiger GPU-Reservemodus.') + reserve=params['gpu_reserve_mib'] + 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 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.') diff --git a/test_inference.py b/test_inference.py index d2fc7f8..0f77f98 100644 --- a/test_inference.py +++ b/test_inference.py @@ -25,6 +25,16 @@ class WorkerTests(unittest.TestCase): self.worker.ensure(self.profile) args=launch.call_args.args[0];env=launch.call_args.kwargs['env'] self.assertEqual(env['CUDA_VISIBLE_DEVICES'],'gpu-first,gpu-second');self.assertEqual(args[args.index('--tensor-split')+1],'85,15');self.assertIn('--kv-unified',args);self.assertIn('--fit-print',fit.call_args.args[0]);self.assertEqual(self.worker.status()['state'],'ready') + def test_reserve_modes_control_full_gpu_boundary(self): + for mode,reserve,passes in [('auto',{},False),('none',{},True),('manual',{'gpu-first':50,'gpu-second':200},True),('manual',{'gpu-first':200,'gpu-second':200},False)]: + self.profile['parameters'].update(gpu_offload='full',gpu_reserve_mode=mode,gpu_reserve_mib=reserve) + with self.patches(),patch('inference.probe',return_value=self.gpus),patch('inference.cgroup_headroom',return_value=32*1024**3),patch.object(self.worker,'_fit_command',return_value='CUDA0 14900 0 0\nCUDA1 4000 0 0\nHost 512 0 256\n') as fit,patch('inference.subprocess.Popen') as launch: + if passes:self.worker.plan(self.profile) + else: + with self.assertRaises(InferenceError):self.worker.plan(self.profile) + launch.assert_not_called() + self.assertEqual(self.worker.memory_plan['gpus'][0]['reserve_mib'],0 if mode=='none' else 512 if mode=='auto' else reserve['gpu-first']) + self.assertEqual(fit.call_count,1) def test_mtp_requires_adapter_before_loading(self): self.profile['parameters']['mtp']=True with patch('inference.subprocess.Popen') as launch: diff --git a/test_model_management.py b/test_model_management.py index 099c345..0112281 100644 --- a/test_model_management.py +++ b/test_model_management.py @@ -26,7 +26,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',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')) 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))) diff --git a/test_vision.py b/test_vision.py index 6d559cd..ae44b34 100644 --- a/test_vision.py +++ b/test_vision.py @@ -12,6 +12,11 @@ class CapabilitiesTests(unittest.TestCase): self.assertEqual(capabilities({'siblings':[{'rfilename':'mmproj-model.gguf'}]})['vision'],'Projektordatei vorhanden') def test_projector_is_not_standalone_or_text_encoder(self): self.assertEqual(file_role('mmproj-qwen3vl.gguf')['role'],'vision_projector');self.assertFalse(file_role('mmproj-qwen3vl.gguf')['profile_eligible']) + def test_reserve_validation(self): + self.assertEqual(chat_parameters({})['gpu_reserve_mode'],'auto') + for value in (-1,32769,True,1.5): + with self.assertRaises(ValueError):chat_parameters({'gpu_devices':['GPU-a'],'gpu_reserve_mode':'manual','gpu_reserve_mib':{'GPU-a':value}}) + with self.assertRaises(ValueError):chat_parameters({'gpu_reserve_mode':'manual'}) def test_projector_device_requires_selected_gpu(self): with self.assertRaises(ValueError):chat_parameters({'vision_device':'GPU-missing'})