studio: smart GPU allocation for GGUF inference

Automatically select the best GPU(s) for a GGUF model based on
file size and available VRAM, instead of relying on hardcoded
-ngl -1 or letting llama-server guess.

Logic:
1. Measure total GGUF file size (including split shards)
2. Query free memory per GPU via nvidia-smi
3. If the model fits in 70% of the most-free GPU's memory,
   pin to that single GPU (CUDA_VISIBLE_DEVICES=X, no --fit)
4. If it needs multiple GPUs, pick the N most-free GPUs
   (CUDA_VISIBLE_DEVICES=X,Y, no --fit)
5. If it's too large for all GPUs combined, omit
   CUDA_VISIBLE_DEVICES and use --fit on to let llama-server
   handle partial offloading

The 70% threshold accounts for KV cache and compute buffers
that sit on top of the model weights.

Removed the -ngl parameter (was hardcoded to -1). llama-server's
default of "auto" handles layer offloading correctly, especially
with --fit on for oversized models.

Tested on 8x B200:
  - 1B model (0.75 GB):  picks 1 GPU, no --fit
  - 27B model (17 GB):   picks 1 GPU, no --fit
  - 405B model (230 GB): picks 2 GPUs, no --fit
  - 2TB model:           all GPUs, --fit on
This commit is contained in:
Daniel Han 2026-03-14 08:37:58 +00:00
commit 12183e0656

View file

@ -165,6 +165,102 @@ class LlamaCppBackend:
return None
# ── GPU allocation ────────────────────────────────────────────
@staticmethod
def _get_gguf_size_bytes(model_path: str) -> int:
"""Get total GGUF size in bytes, including split shards."""
import re
main = Path(model_path)
total = main.stat().st_size
# Check for split shards (e.g., model-00001-of-00003.gguf)
shard_pat = re.compile(r"^(.*)-(\d{5})-of-(\d{5})\.gguf$")
m = shard_pat.match(main.name)
if m:
prefix, _, num_total = m.group(1), m.group(2), m.group(3)
sibling_pat = re.compile(
r"^" + re.escape(prefix) + r"-\d{5}-of-"
+ re.escape(num_total) + r"\.gguf$"
)
for sibling in main.parent.iterdir():
if sibling != main and sibling_pat.match(sibling.name):
total += sibling.stat().st_size
return total
@staticmethod
def _get_gpu_free_memory() -> list[tuple[int, int]]:
"""Query free memory per GPU via nvidia-smi.
Returns list of (gpu_index, free_mib) sorted by index.
Returns empty list if nvidia-smi is not available.
"""
try:
result = subprocess.run(
[
"nvidia-smi",
"--query-gpu=index,memory.free",
"--format=csv,noheader,nounits",
],
capture_output = True,
text = True,
timeout = 10,
)
if result.returncode != 0:
return []
gpus = []
for line in result.stdout.strip().splitlines():
parts = line.split(",")
if len(parts) == 2:
idx = int(parts[0].strip())
free_mib = int(parts[1].strip())
gpus.append((idx, free_mib))
return gpus
except Exception:
return []
@staticmethod
def _select_gpus(
model_size_bytes: int,
gpus: list[tuple[int, int]],
) -> tuple[Optional[list[int]], bool]:
"""Pick GPU(s) for a model based on file size and free memory.
Uses GGUF file size as a rough proxy for VRAM usage (actual usage
is higher due to KV cache and compute buffers, but 70% threshold
accounts for that).
Returns (gpu_indices, use_fit):
- ([1], False) model fits on 1 GPU at 70% of free
- ([1, 2], False) model needs 2 GPUs
- (None, True) model too large, let --fit handle it
"""
if not gpus:
return None, True
model_size_mib = model_size_bytes / (1024 * 1024)
# Sort GPUs by free memory descending
ranked = sorted(gpus, key = lambda g: g[1], reverse = True)
# Try fitting on 1 GPU (70% of free memory threshold)
if ranked[0][1] * 0.70 >= model_size_mib:
return [ranked[0][0]], False
# Try fitting on N GPUs (accumulate free memory from most-free)
cumulative = 0
selected = []
for idx, free_mib in ranked:
selected.append(idx)
cumulative += free_mib * 0.70
if cumulative >= model_size_mib:
return sorted(selected), False
# Model is too large even for all GPUs, let --fit handle it
return None, True
# ── Port allocation ───────────────────────────────────────────
@staticmethod
@ -211,7 +307,6 @@ class LlamaCppBackend:
model_identifier: str,
is_vision: bool = False,
n_ctx: int = 4096,
n_gpu_layers: int = -1,
n_threads: Optional[int] = None,
) -> bool:
"""
@ -342,6 +437,19 @@ class LlamaCppBackend:
else:
raise ValueError("Either gguf_path or hf_repo must be provided")
# Select GPU(s) based on model size and free memory
try:
model_size = self._get_gguf_size_bytes(model_path)
gpus = self._get_gpu_free_memory()
gpu_indices, use_fit = self._select_gpus(model_size, gpus)
logger.info(
f"GGUF size: {model_size / (1024**3):.1f} GB, "
f"GPUs free: {gpus}, selected: {gpu_indices}, fit: {use_fit}"
)
except Exception as e:
logger.warning(f"GPU selection failed ({e}), using --fit on")
gpu_indices, use_fit = None, True
cmd = [
binary,
"-m",
@ -350,16 +458,15 @@ class LlamaCppBackend:
str(self._port),
"-c",
str(n_ctx),
"-ngl",
str(n_gpu_layers),
"--parallel",
"1", # Single-user studio, saves VRAM
"--flash-attn",
"on", # Force flash attention for speed
"--fit",
"on", # Auto-fit to available device memory
]
if use_fit:
cmd.extend(["--fit", "on"])
if n_threads is not None:
cmd.extend(["--threads", str(n_threads)])
@ -401,6 +508,10 @@ class LlamaCppBackend:
f"{binary_dir}:{existing_ld}" if existing_ld else binary_dir
)
# Pin to selected GPU(s) via CUDA_VISIBLE_DEVICES
if gpu_indices is not None:
env["CUDA_VISIBLE_DEVICES"] = ",".join(str(i) for i in gpu_indices)
self._stdout_lines = []
self._process = subprocess.Popen(
cmd,