ui/gpu: explicit "Split — <card> first" options; auto = single GPU; confine non-split models

The per-model engine selector was confusing: "Auto" implied single-card (correct)
but the only split choice was a generic "All GPUs". Replace with hardware-named,
ordered choices:
  - "Auto (single GPU — by capability / free VRAM)"  → one card (unchanged)
  - "<engine> (single card)"                         → pin to that card
  - "Split — <engine> first"                         → pool across all GPUs with
    that engine's card leading (main_gpu / larger share). Generated per engine, so
    on a 3090+Radeon box you get "Split — nvidia first" and "Split — radeon first".
Saving a split option sets gpu_split + pins engine=<lead>; the weight-ratio field
(blank = auto by free VRAM) shows only for a split choice.

Make per-model split actually function:
- engine_supervisor: expose every GPU to engines (allow_cross) when the global
  toggle OR ANY model entry has gpu_split — otherwise the lead engine couldn't see
  the foreign card.
- vulkan.py: when an engine sees BOTH backends' cards but THIS model isn't a split,
  confine it to its own backend (zero the foreign devices in tensor_split) so it
  can't accidentally spread — while same-backend multi-card split still works. Split
  models pool across all visible devices (lead card = device 0 = main_gpu).
Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
parent 2846a94b
...@@ -564,10 +564,9 @@ window.__DEFAULT_WHISPER_SERVER_PATH__ = {{ default_whisper_server_path|tojson } ...@@ -564,10 +564,9 @@ window.__DEFAULT_WHISPER_SERVER_PATH__ = {{ default_whisper_server_path|tojson }
<div class="form-row" id="cfg-engine-row" style="margin-top:.75rem;display:none"> <div class="form-row" id="cfg-engine-row" style="margin-top:.75rem;display:none">
<label class="form-label">Engine / card</label> <label class="form-label">Engine / card</label>
<select id="cfg-engine" class="form-input" onchange="_updateGpuSplitVisibility()"> <select id="cfg-engine" class="form-input" onchange="_updateGpuSplitVisibility()">
<option value="">Default (auto — by capability)</option> <option value="">Auto (single GPU — by capability / free VRAM)</option>
<option value="__all__">All GPUs (split across every backend)</option>
</select> </select>
<span class="form-hint" style="font-size:11px">Pin this model to a specific engine/card, or <b>All GPUs</b> to split one model across every card (e.g. NVIDIA 3090 + Radeon) for more total VRAM. Only shown when multiple engines are running.</span> <span class="form-hint" style="font-size:11px"><b>Auto</b> runs the model on ONE card (picked by capability / most free VRAM). Pin it to a specific engine, or pick a <b>Split — &lt;card&gt; first</b> option to spread one model across all GPUs (more total VRAM) with that card leading (it holds the output/KV anchor and the larger share). Only shown when multiple engines are running.</span>
<label style="display:flex;align-items:center;gap:.4rem;margin-top:.4rem;font-size:12px"> <label style="display:flex;align-items:center;gap:.4rem;margin-top:.4rem;font-size:12px">
<input type="checkbox" id="cfg-engine-fallback" style="width:auto;margin:0"> <input type="checkbox" id="cfg-engine-fallback" style="width:auto;margin:0">
Fall back to another engine if the pinned one is unavailable Fall back to another engine if the pinned one is unavailable
...@@ -577,7 +576,7 @@ window.__DEFAULT_WHISPER_SERVER_PATH__ = {{ default_whisper_server_path|tojson } ...@@ -577,7 +576,7 @@ window.__DEFAULT_WHISPER_SERVER_PATH__ = {{ default_whisper_server_path|tojson }
<div class="form-row" id="cfg-gpu-split-row" style="margin-top:.75rem;display:none;border:1px solid var(--border,#333);border-radius:8px;padding:.6rem"> <div class="form-row" id="cfg-gpu-split-row" style="margin-top:.75rem;display:none;border:1px solid var(--border,#333);border-radius:8px;padding:.6rem">
<label class="form-label" style="font-weight:600">Weight distribution <span class="muted" style="font-weight:400">— layers per GPU</span></label> <label class="form-label" style="font-weight:600">Weight distribution <span class="muted" style="font-weight:400">— layers per GPU</span></label>
<input type="text" id="cfg-tensor-split" class="form-input" placeholder="auto (by free VRAM)"> <input type="text" id="cfg-tensor-split" class="form-input" placeholder="auto (by free VRAM)">
<span class="form-hint" style="font-size:11px">Comma-separated ratio in llama.cpp device order (CUDA cards first, then Vulkan), e.g. <b>0.8,0.2</b> = 80% of layers on the first GPU (3090), 20% on the second (RX 580). <b>Blank = automatic</b>, split proportionally to each card's free VRAM. The slower card bottlenecks each token, so bias toward the faster GPU.</span> <span class="form-hint" style="font-size:11px">Comma-separated ratio for the chosen "first" card then the rest, e.g. <b>0.8,0.2</b> = 80% of layers on the lead card, 20% on the other. <b>Blank = automatic</b>, split proportionally to each card's free VRAM. The slower card bottlenecks each token, so the lead/faster GPU usually gets the larger share.</span>
</div> </div>
<div class="form-row" id="cfg-ds4-row" style="margin-top:.75rem;display:none;border:1px solid var(--border,#333);border-radius:8px;padding:.6rem"> <div class="form-row" id="cfg-ds4-row" style="margin-top:.75rem;display:none;border:1px solid var(--border,#333);border-radius:8px;padding:.6rem">
<label class="form-label" style="font-weight:600">ds4 (DeepSeek V4) streaming <span class="muted" style="font-weight:400">— per-model overrides</span></label> <label class="form-label" style="font-weight:600">ds4 (DeepSeek V4) streaming <span class="muted" style="font-weight:400">— per-model overrides</span></label>
...@@ -3272,9 +3271,9 @@ function openCfgModal(idx, cfgIdx){ ...@@ -3272,9 +3271,9 @@ function openCfgModal(idx, cfgIdx){
document.getElementById('cfg-offload-strategy').value = s.offload_strategy || 'auto'; document.getElementById('cfg-offload-strategy').value = s.offload_strategy || 'auto';
document.getElementById('cfg-offload-dir').value = s.offload_dir || _defaultOffloadDir; document.getElementById('cfg-offload-dir').value = s.offload_dir || _defaultOffloadDir;
_applyBackendCfgVisibility(m); _applyBackendCfgVisibility(m);
// "All GPUs" cross-split (gpu_split) takes precedence over an engine pin in the // gpu_split → "Split — <lead engine> first" (lead = the pinned engine, or the
// selector; otherwise show the pinned engine. Weight ratio populates the field. // first engine if none); otherwise show the single-card pin. Ratio fills the field.
_populateEnginePin(s.gpu_split ? '__all__' : (s.engine || '')); _populateEnginePin(s.gpu_split ? ('__split__:' + ((s.engine || '').trim() || (_engineNames[0] || ''))) : (s.engine || ''));
document.getElementById('cfg-engine-fallback').checked = !!s.engine_fallback; document.getElementById('cfg-engine-fallback').checked = !!s.engine_fallback;
document.getElementById('cfg-tensor-split').value = s.tensor_split || ''; document.getElementById('cfg-tensor-split').value = s.tensor_split || '';
_populateDs4(m, s); _populateDs4(m, s);
...@@ -3574,26 +3573,36 @@ async function removeThisConfig(){ ...@@ -3574,26 +3573,36 @@ async function removeThisConfig(){
} catch(e) { showAlert('Error: ' + e.message); } } catch(e) { showAlert('Error: ' + e.message); }
} }
// Show the weight-distribution row only when "All GPUs" (cross-GPU split) is chosen. // Show the weight-distribution row only when a "Split <engine> first" option
// (value "__split__:<engine>") is chosen.
function _updateGpuSplitVisibility(){ function _updateGpuSplitVisibility(){
const sel = document.getElementById('cfg-engine'); const sel = document.getElementById('cfg-engine');
const row = document.getElementById('cfg-gpu-split-row'); const row = document.getElementById('cfg-gpu-split-row');
if(!sel || !row) return; if(!sel || !row) return;
row.style.display = (sel.value === '__all__') ? '' : 'none'; row.style.display = (sel.value || '').startsWith('__split__:') ? '' : 'none';
} }
// Engine-pin field: populate the datalist from declared engines and only show the // Engine-pin field: populate from the live engine names. Per engine we add two
// row when more than one engine is configured (single-engine setups don't need it). // options: a single-card pin (the name itself) and a "Split <name> first"
// The special "__all__" option (added in the HTML) means split across all GPUs. // (value "__split__:<name>") that pools the model across all GPUs with that
// engine's card leading. Only shown when >1 engine is configured.
async function _populateEnginePin(desired){ async function _populateEnginePin(desired){
const row = document.getElementById('cfg-engine-row'); const row = document.getElementById('cfg-engine-row');
const sel = document.getElementById('cfg-engine'); const sel = document.getElementById('cfg-engine');
try { try {
if (!_engineNames || !_engineNames.length) await _loadEngineInfo(); if (!_engineNames || !_engineNames.length) await _loadEngineInfo();
const want = (desired !== undefined) ? desired : sel.value; const want = (desired !== undefined) ? desired : sel.value;
sel.querySelectorAll('option[value]:not([value=""]):not([value="__all__"])').forEach(o => o.remove()); // Clear everything except the static "" (Auto) option.
const allOpt = sel.querySelector('option[value="__all__"]'); sel.querySelectorAll('option[value]:not([value=""])').forEach(o => o.remove());
_engineNames.forEach(n => { const o=document.createElement('option'); o.value=n; o.textContent=n; sel.insertBefore(o, allOpt); }); _engineNames.forEach(n => {
const o=document.createElement('option'); o.value=n; o.textContent=n + ' (single card)'; sel.appendChild(o);
});
if (_engineNames.length > 1) {
_engineNames.forEach(n => {
const o=document.createElement('option'); o.value='__split__:'+n;
o.textContent='Split — ' + n + ' first'; sel.appendChild(o);
});
}
sel.value = want || ''; // set AFTER options exist so the selection sticks sel.value = want || ''; // set AFTER options exist so the selection sticks
row.style.display = _engineNames.length > 1 ? '' : 'none'; row.style.display = _engineNames.length > 1 ? '' : 'none';
} catch(e) { row.style.display = 'none'; } } catch(e) { row.style.display = 'none'; }
...@@ -3678,12 +3687,13 @@ async function saveModelConfig(){ ...@@ -3678,12 +3687,13 @@ async function saveModelConfig(){
no_ram: document.getElementById('cfg-noram').checked, no_ram: document.getElementById('cfg-noram').checked,
offload_strategy: document.getElementById('cfg-offload-strategy').value, offload_strategy: document.getElementById('cfg-offload-strategy').value,
offload_dir: document.getElementById('cfg-offload-dir').value.trim() || './offload', offload_dir: document.getElementById('cfg-offload-dir').value.trim() || './offload',
// "All GPUs" → gpu_split (no single-engine pin); any real engine name → pin. // "Split <engine> first" → gpu_split + pin to <engine> as the lead card.
engine: (document.getElementById('cfg-engine').value.trim() === '__all__' // A plain engine name → single-card pin. "" → auto single GPU.
? null : document.getElementById('cfg-engine').value.trim() || null), engine: (function(){ const v=document.getElementById('cfg-engine').value.trim();
return v.startsWith('__split__:') ? (v.slice(10) || null) : (v || null); })(),
engine_fallback: document.getElementById('cfg-engine-fallback').checked, engine_fallback: document.getElementById('cfg-engine-fallback').checked,
gpu_split: document.getElementById('cfg-engine').value.trim() === '__all__', gpu_split: document.getElementById('cfg-engine').value.trim().startsWith('__split__:'),
tensor_split: (document.getElementById('cfg-engine').value.trim() === '__all__' tensor_split: (document.getElementById('cfg-engine').value.trim().startsWith('__split__:')
? (document.getElementById('cfg-tensor-split').value.trim() || null) : null), ? (document.getElementById('cfg-tensor-split').value.trim() || null) : null),
ds4: _collectDs4(), ds4: _collectDs4(),
system_prompt: document.getElementById('cfg-sysprompt').value.trim() || null, system_prompt: document.getElementById('cfg-sysprompt').value.trim() || null,
......
...@@ -1116,10 +1116,22 @@ class VulkanBackend(ModelBackend): ...@@ -1116,10 +1116,22 @@ class VulkanBackend(ModelBackend):
_ts = kwargs.get('tensor_split', _raw_cfg.get('tensor_split')) _ts = kwargs.get('tensor_split', _raw_cfg.get('tensor_split'))
if _ts is None: if _ts is None:
_ts = kwargs.get('_global_tensor_split') _ts = kwargs.get('_global_tensor_split')
# llama.cpp device order: all visible CUDA devices first, then Vulkan. Count
# each so we can tell own-backend from foreign when both are exposed (the
# engine was spawned with cross-GPU visibility for some split model).
_n_cuda = 0
try:
import torch as _t
if _t.cuda.is_available():
_n_cuda = _t.cuda.device_count()
except Exception:
_n_cuda = 0
_vk_env = os.environ.get('GGML_VK_VISIBLE_DEVICES', '')
_n_vk = len([x for x in _vk_env.split(',') if x.strip() != '']) if _vk_env else 0
_own = (os.environ.get('CODERAI_ENGINE_BACKEND') or '').lower()
if _gpu_split: if _gpu_split:
_parsed = [] _parsed = []
if _ts: if _ts:
# Explicit ratio.
for _p in str(_ts).replace(' ', '').split(','): for _p in str(_ts).replace(' ', '').split(','):
if _p == '': if _p == '':
continue continue
...@@ -1138,9 +1150,22 @@ class VulkanBackend(ModelBackend): ...@@ -1138,9 +1150,22 @@ class VulkanBackend(ModelBackend):
print(f" gpu split : auto tensor_split={_parsed} (by free VRAM " print(f" gpu split : auto tensor_split={_parsed} (by free VRAM "
f"{[round(f,1) for f in _free_dev]} GB)") f"{[round(f,1) for f in _free_dev]} GB)")
else: else:
print(f" gpu split : tensor_split={_parsed} (multi-GPU pool, CUDA-first order)") print(f" gpu split : tensor_split={_parsed} (multi-GPU pool)")
if _parsed and _llama_accepts('tensor_split'): if _parsed and _llama_accepts('tensor_split'):
llama_kwargs['tensor_split'] = _parsed llama_kwargs['tensor_split'] = _parsed
elif _n_cuda > 0 and _n_vk > 0 and _llama_accepts('tensor_split'):
# NOT a split model, but the engine can see BOTH backends' cards (cross
# visibility was enabled for some other split model). Confine THIS model
# to its own backend so it doesn't accidentally spread onto the foreign
# card — while still letting multiple SAME-backend cards share. Zero the
# foreign-backend devices in the per-device ratio.
if _own.startswith('nvidia') or _own in ('cuda',):
_conf = [1.0] * _n_cuda + [0.0] * _n_vk # CUDA only
else:
_conf = [0.0] * _n_cuda + [1.0] * _n_vk # Vulkan only
llama_kwargs['tensor_split'] = _conf
print(f" gpu split : confined to own backend ({_own or 'vulkan'}); "
f"tensor_split={_conf} (foreign card excluded)")
# Multimodal projector (mmproj): pairs a CLIP/vision projector GGUF with # Multimodal projector (mmproj): pairs a CLIP/vision projector GGUF with
# this text model so it can accept images — the llama.cpp `--mmproj` # this text model so it can accept images — the llama.cpp `--mmproj`
......
...@@ -193,7 +193,26 @@ class EngineSupervisor: ...@@ -193,7 +193,26 @@ class EngineSupervisor:
specs = getattr(srv, "engine_specs", None) specs = getattr(srv, "engine_specs", None)
engines = [] engines = []
# Expose every GPU to the engines (allow_cross) when cross-backend pooling is
# wanted: either the global default (offload.gpu_split) OR any individual model
# opts into a split ("Split — <engine> first" → per-model gpu_split). Without
# this the lead engine wouldn't even see the foreign card, so a per-model split
# couldn't use it. Non-split models are kept on their own backend by the GGUF
# loader (it zeroes the foreign devices) so this doesn't resurrect the
# "everything spreads onto the Radeon" bug.
_cross = bool(getattr(getattr(self.config, "offload", None), "gpu_split", False)) _cross = bool(getattr(getattr(self.config, "offload", None), "gpu_split", False))
if not _cross and self.models_path:
try:
import json as _json
with open(self.models_path) as _mf:
_md = _json.load(_mf)
for _lst in _md.values():
if isinstance(_lst, list) and any(
isinstance(_e, dict) and _e.get("gpu_split") for _e in _lst):
_cross = True
break
except Exception:
pass
if specs: if specs:
from codai.frontproxy.gpu_detect import vendor_env from codai.frontproxy.gpu_detect import vendor_env
for idx, spec in enumerate(specs): for idx, spec in enumerate(specs):
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment