diff --git a/README.md b/README.md index c3f0f7a..10a2c46 100644 --- a/README.md +++ b/README.md @@ -228,3 +228,5 @@ Backup und Wiederherstellung unter **Einstellungen → Backup & Restore**: [Umfa Die optionale LadyPoly-WebUI läuft als markierter Anwendungscontainer unter Weitere Dienste und nutzt Decks Musikworker statt einer eigenen GPU-Laufzeit. Installation, Verbindung und Einschränkungen: [LadyPoly](deploy/ladypoly/README.md). + +Sprachmodell-GPUs und Vision-Projektor werden unabhängig zugeordnet. Der Modell-Split verteilt ausschließlich LLM-Gewichte; eine zusätzliche Projektor-GPU wird nur für mmproj sichtbar gemacht, nicht für Modell-Offload. Die Speicherprüfung berücksichtigt beide Geräte einschließlich Projektorbedarf. diff --git a/inference.py b/inference.py index d280b05..c3649ef 100644 --- a/inference.py +++ b/inference.py @@ -158,6 +158,7 @@ class LlamaWorker: if params['split_mode']=='none':selected=selected[:1] if not selected or any(g['processes'] or g['free_mib']<2048 for g in selected):raise InferenceError('Benötigte GPU ist durch einen anderen Dienst belegt. Deck stoppt keine fremden Prozesse.') mem={line.split(':')[0]:int(line.split()[1])*1024 for line in Path('/proc/meminfo').read_text().splitlines() if line.startswith('MemAvailable:')} + model_devices=list(selected) projector=None;vision_args=[];vision_bytes=0;vision_device=params.get('vision_device','cpu') if params.get('vision_projector'): projector=self.catalog.entry(params['vision_projector']) @@ -166,7 +167,11 @@ class LlamaWorker: vision_args=['--mmproj',str((self.catalog.root/projector['id']/'model.gguf').resolve())] if vision_device=='cpu':vision_args+=['--no-mmproj-offload'] else: - if vision_device not in [g['uuid'] for g in selected]:raise InferenceError('Projektor-GPU ist nicht ausgewählt.') + if vision_device not in [g['uuid'] for g in selected]: + target=next((g for g in devices if g['uuid']==vision_device),None) + if target is None:raise InferenceError('Projektor-GPU ist nicht verfügbar.') + if target['processes'] or target['free_mib']<2048:raise InferenceError('Projektor-GPU ist durch einen anderen Dienst belegt.') + selected.append(target) vision_args+=['--mmproj-device','CUDA'+str([g['uuid'] for g in selected].index(vision_device)),'--mmproj-offload'] staging=entry['size']+4*GIB+vision_bytes if mem.get('MemAvailable',0){ 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

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.

`; + return `

Sprachmodell · 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; @@ -135,7 +135,7 @@ 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)+`

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.':''}

`; + 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. Das Projektor-Gerät wird unabhängig von den Sprachmodell-GPUs gewählt. Der Sprachmodell-Split verteilt ausschließlich die Sprachmodell-Gewichte. 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); diff --git a/profiles.py b/profiles.py index 504e327..9a97de6 100644 --- a/profiles.py +++ b/profiles.py @@ -38,7 +38,7 @@ def chat_parameters(params): 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.') + if params['vision_device']!='cpu' and (not isinstance(params['vision_device'],str) or not re.fullmatch(r'GPU-[0-9a-fA-F-]{36}',params['vision_device'])):raise ValueError('Projektor-Gerät muss CPU oder eine gültige GPU-UUID sein.') 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.') diff --git a/test_vision.py b/test_vision.py index ae44b34..a5fea19 100644 --- a/test_vision.py +++ b/test_vision.py @@ -17,8 +17,10 @@ class CapabilitiesTests(unittest.TestCase): 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): + def test_projector_device_validates_uuid_independently(self): with self.assertRaises(ValueError):chat_parameters({'vision_device':'GPU-missing'}) + uuid='GPU-4834d9d7-5b61-3004-1fb3-4ae49d482d4b' + self.assertEqual(chat_parameters({'vision_device':uuid})['vision_device'],uuid) class ProjectorLaunchTests(WorkerTests): def test_cpu_and_gpu_projector_flags_and_reserve(self): @@ -32,4 +34,16 @@ class ProjectorLaunchTests(WorkerTests): if device=='cpu':self.assertIn('--no-mmproj-offload',args) else:self.assertEqual(args[args.index('--mmproj-device')+1],'CUDA1');self.assertEqual(self.worker.memory_plan['gpus'][1]['required_mib'],7072) self.worker.process=None + def test_projector_only_gpu_excluded_from_model_devices(self): + self.profile['parameters'].update(gpu_devices=['gpu-first'],split_mode='none',tensor_split=[],vision_projector='p',vision_device='gpu-second',gpu_offload='full') + self.worker.catalog.entry=lambda ident:dict(id='p',file='mmproj.gguf',size=1024**3,role='vision_projector') if ident=='p' else self.entry + process=Mock();process.poll.return_value=None;http=Mock();http.getresponse.return_value.status=200 + 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 9000 1000 1000\nHost 512 0 256\n'),patch('inference.subprocess.Popen',return_value=process) as launch,patch('inference.http.client.HTTPConnection',return_value=http),patch('inference.threading.Thread'): + self.worker.ensure(self.profile) + args=launch.call_args.args[0] + self.assertEqual(args[args.index('--device')+1],'CUDA0') + self.assertEqual(args[args.index('--mmproj-device')+1],'CUDA1') + self.assertEqual(self.worker.memory_plan['gpus'][1]['required_mib'],3072) + self.assertEqual(launch.call_args.kwargs['env']['CUDA_VISIBLE_DEVICES'],'gpu-first,gpu-second') + self.worker.process=None if __name__=='__main__':unittest.main()