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')}
${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.
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'})