9e8f1bbeed
Dashboard / frontend (push) Failing after 0s
Dashboard / api (push) Failing after 0s
Lint PowerShell / powershell-lint (ubuntu-latest) (push) Failing after 1s
Python Lint / Lint Python with Ruff (push) Failing after 1s
ShellCheck / Lint shell scripts (push) Failing after 1s
Matrix Smoke / linux-smoke (push) Failing after 1s
Matrix Smoke / distro: cachyos (push) Failing after 15s
Matrix Smoke / distro: linux-mint-21.3 (push) Failing after 15s
Matrix Smoke / distro: debian-12 (push) Failing after 5m21s
Matrix Smoke / distro: fedora-41 (push) Failing after 4m56s
Matrix Smoke / distro: ubuntu-24.04 (push) Failing after 2m13s
Matrix Smoke / distro: rocky-9 (push) Failing after 10m39s
Matrix Smoke / distro: manjaro (push) Failing after 12m11s
Matrix Smoke / distro: opensuse-tw (push) Failing after 11m53s
Matrix Smoke / distro: archlinux (push) Failing after 20m3s
Matrix Smoke / distro: ubuntu-22.04 (push) Failing after 13m49s
Validate .env Schema / tier-1-env-validation (push) Successful in 52s
Validate .env Schema / tier-2-env-validation (push) Successful in 44s
Validate .env Schema / tier-3-env-validation (push) Successful in 52s
Validate .env Schema / tier-4-env-validation (push) Successful in 51s
Validate Extensions Catalog / Check catalog is up-to-date (push) Failing after 9m47s
Secret Scan / Scan for secrets (push) Failing after 21m4s
Validate Docker Compose / Validate Docker Compose files (push) Has been cancelled
Python Type Check / Type check with mypy (push) Has been cancelled
Validate .env Schema / tier-0-env-validation (push) Has been cancelled
Test Linux / integration-smoke (push) Has been cancelled
Lint PowerShell / powershell-lint (windows-latest) (push) Has been cancelled
Matrix Smoke / macos-smoke (push) Has been cancelled
547 lines
18 KiB
Python
547 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
assign_gpus.py — GPU assignment algorithm for ODS
|
|
|
|
Usage:
|
|
python3 assign_gpus.py --topology topo.json --model-size 70000
|
|
python3 assign_gpus.py --topology topo.json --model-size 70000 --enabled-services llama_server,whisper
|
|
|
|
Output: gpu_assignment JSON to stdout
|
|
Errors: to stderr, exit code 1
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import math
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from itertools import combinations
|
|
from typing import Optional
|
|
|
|
|
|
# Constants
|
|
|
|
HIGH_BW_THRESHOLD = 80 # min rank for NVLink / XGMI
|
|
DEFAULT_SERVICES = ["llama_server", "whisper", "comfyui", "embeddings"]
|
|
NON_LLAMA = ["whisper", "comfyui", "embeddings"]
|
|
|
|
|
|
# Data Models
|
|
|
|
@dataclass
|
|
class GPU:
|
|
index: int
|
|
uuid: str
|
|
name: str
|
|
memory_mb: float
|
|
memory_total_mb: float
|
|
memory_type: str = "discrete" # "discrete" or "unified" (APU)
|
|
|
|
@dataclass
|
|
class Link:
|
|
gpu_a: int
|
|
gpu_b: int
|
|
link_type: str
|
|
link_label: str
|
|
rank: int
|
|
|
|
@dataclass
|
|
class Subset:
|
|
gpus: list
|
|
min_link_rank: int
|
|
total_vram_mb: float
|
|
all_pairs_highbw: bool
|
|
|
|
@dataclass
|
|
class LlamaParallelism:
|
|
mode: str
|
|
tensor_parallel_size: int
|
|
pipeline_parallel_size: int
|
|
gpu_memory_utilization: float
|
|
tensor_split: Optional[list] = None
|
|
|
|
@dataclass
|
|
class ServiceAssignment:
|
|
gpus: list
|
|
parallelism: Optional[LlamaParallelism] = None
|
|
|
|
@dataclass
|
|
class AssignmentResult:
|
|
strategy: str
|
|
services: dict
|
|
|
|
|
|
# Phase 1: Topology Analysis
|
|
|
|
def parse_gpus(topology: dict) -> list:
|
|
gpus = []
|
|
for g in topology["gpus"]:
|
|
total_mb = float(g["memory_gb"]) * 1024
|
|
scheduling_mb = total_mb
|
|
if g.get("memory_free_gb") is not None:
|
|
scheduling_mb = max(float(g["memory_free_gb"]) * 1024, 0.0)
|
|
gpus.append(GPU(
|
|
index=g["index"],
|
|
uuid=g["uuid"],
|
|
name=g["name"],
|
|
memory_mb=scheduling_mb,
|
|
memory_total_mb=total_mb,
|
|
memory_type=g.get("memory_type", "discrete"),
|
|
))
|
|
return gpus
|
|
|
|
|
|
def parse_links(topology: dict) -> list:
|
|
links = []
|
|
for link in topology.get("links", []):
|
|
links.append(Link(
|
|
gpu_a=link["gpu_a"],
|
|
gpu_b=link["gpu_b"],
|
|
link_type=link["link_type"],
|
|
link_label=link["link_label"],
|
|
rank=link["rank"],
|
|
))
|
|
return links
|
|
|
|
|
|
def build_rank_matrix(links: list) -> dict:
|
|
"""
|
|
rank_matrix[(min_idx, max_idx)] = rank
|
|
Pairs not in links default to 0.
|
|
"""
|
|
matrix = {}
|
|
for link in links:
|
|
key = (min(link.gpu_a, link.gpu_b), max(link.gpu_a, link.gpu_b))
|
|
matrix[key] = link.rank
|
|
return matrix
|
|
|
|
|
|
def get_rank(rank_matrix: dict, a: int, b: int) -> int:
|
|
return rank_matrix.get((min(a, b), max(a, b)), 0)
|
|
|
|
|
|
def compute_subset(gpus: list, rank_matrix: dict) -> Subset:
|
|
"""
|
|
Compute a Subset from a list of GPUs.
|
|
Single GPU: min_link_rank=0, all_pairs_highbw=True (no links needed).
|
|
"""
|
|
if len(gpus) == 1:
|
|
return Subset(
|
|
gpus=gpus,
|
|
min_link_rank=0,
|
|
total_vram_mb=gpus[0].memory_mb,
|
|
all_pairs_highbw=True,
|
|
)
|
|
|
|
indices = [g.index for g in gpus]
|
|
ranks = [get_rank(rank_matrix, a, b) for a, b in combinations(indices, 2)]
|
|
min_rank = min(ranks)
|
|
|
|
return Subset(
|
|
gpus=gpus,
|
|
min_link_rank=min_rank,
|
|
total_vram_mb=sum(g.memory_mb for g in gpus),
|
|
all_pairs_highbw=(min_rank >= HIGH_BW_THRESHOLD),
|
|
)
|
|
|
|
|
|
def enumerate_subsets(gpus: list, rank_matrix: dict) -> list:
|
|
"""
|
|
Generate all non-empty subsets of GPUs, ordered by:
|
|
1. min_link_rank DESC (topology quality)
|
|
2. subset size ASC (prefer fewer GPUs, leave more for services)
|
|
3. total_vram DESC (tiebreaker)
|
|
"""
|
|
all_subsets = []
|
|
for size in range(1, len(gpus) + 1):
|
|
for combo in combinations(gpus, size):
|
|
all_subsets.append(compute_subset(list(combo), rank_matrix))
|
|
|
|
return sorted(
|
|
all_subsets,
|
|
key=lambda s: (s.min_link_rank, -len(s.gpus), s.total_vram_mb),
|
|
reverse=True,
|
|
)
|
|
|
|
|
|
# Phase 2: GPU Assignment
|
|
|
|
def find_llama_subset(ordered_subsets: list, model_size_mb: float) -> Subset:
|
|
"""
|
|
Pick the best-ranked subset whose total VRAM covers model_size_mb.
|
|
Returns the first match (best topology, smallest size, most VRAM).
|
|
"""
|
|
for subset in ordered_subsets:
|
|
if subset.total_vram_mb >= model_size_mb and subset_can_host_equal_split(subset, model_size_mb):
|
|
return subset
|
|
return None
|
|
|
|
|
|
def subset_can_host_equal_split(subset: Subset, model_size_mb: float) -> bool:
|
|
"""Conservative fit check for llama.cpp layer/pipeline splits.
|
|
|
|
ODS emits equal tensor-split weights for non-heterogeneous pipeline splits.
|
|
A multi-GPU host with one mostly busy GPU can therefore have enough total
|
|
VRAM but still crash when llama.cpp allocates that GPU's share. Treat free
|
|
VRAM as the scheduling budget when topology provides it, and require every
|
|
selected GPU to be able to carry an equal share of the model file.
|
|
"""
|
|
if not subset.gpus:
|
|
return False
|
|
required_per_gpu = model_size_mb / len(subset.gpus)
|
|
return all(g.memory_mb >= required_per_gpu for g in subset.gpus)
|
|
|
|
|
|
def span_subsets(all_gpus: list, rank_matrix: dict, model_size_mb: float, ordered_subsets: list) -> Subset:
|
|
"""
|
|
No single subset covers model_size_mb.
|
|
Take the best subset, then greedily add GPUs from the remaining pool
|
|
(ordered by memory_mb DESC) until VRAM is covered.
|
|
Recomputes min_link_rank on the combined set.
|
|
"""
|
|
best = ordered_subsets[0]
|
|
accumulated = list(best.gpus)
|
|
used = {g.index for g in accumulated}
|
|
|
|
remaining = sorted(
|
|
[g for g in all_gpus if g.index not in used],
|
|
key=lambda g: g.memory_mb,
|
|
reverse=True,
|
|
)
|
|
|
|
for gpu in remaining:
|
|
accumulated.append(gpu)
|
|
candidate = compute_subset(accumulated, rank_matrix)
|
|
if candidate.total_vram_mb >= model_size_mb and subset_can_host_equal_split(candidate, model_size_mb):
|
|
return candidate
|
|
|
|
raise ValueError(
|
|
f"Model size {model_size_mb:.0f}MB exceeds assignable free VRAM "
|
|
f"({sum(g.memory_mb for g in all_gpus):.0f}MB across all GPUs)."
|
|
)
|
|
|
|
|
|
def assign_services(all_gpus: list, llama_gpus: list, rank_matrix: dict, enabled_services: list, vendor: str = "nvidia") -> tuple:
|
|
"""
|
|
Assign remaining GPUs to non-llama services.
|
|
Returns (service_assignments dict, final_llama_gpus list, strategy str).
|
|
|
|
Rules:
|
|
remaining == 0 → all 3 services share llama's last GPU → colocated
|
|
remaining == 1 → all 3 services share remaining[0] → colocated
|
|
remaining == 2 → whisper → [0], comfyui+embeddings → [1] → colocated
|
|
remaining >= 3 → whisper → [0], comfyui → [1], emb → [2] → dedicated
|
|
remaining[3:] → back to llama
|
|
|
|
AMD APU+dGPU hybrid: if mixed memory types (unified + discrete), prefer APU
|
|
GPUs for auxiliary services (lower bandwidth but sufficient for whisper/embeddings).
|
|
"""
|
|
llama_indices = {g.index for g in llama_gpus}
|
|
remaining = sorted(
|
|
[g for g in all_gpus if g.index not in llama_indices],
|
|
key=lambda g: g.memory_mb,
|
|
reverse=True,
|
|
)
|
|
|
|
# AMD hybrid strategy: sort remaining so APU GPUs come first for auxiliary
|
|
# services (they're better suited for lightweight tasks like whisper/embeddings,
|
|
# freeing discrete GPUs for LLM inference)
|
|
if vendor == "amd" and any(g.memory_type == "unified" for g in remaining):
|
|
remaining = sorted(
|
|
remaining,
|
|
key=lambda g: (0 if g.memory_type == "unified" else 1, -g.memory_mb),
|
|
)
|
|
|
|
active_non_llama = [s for s in NON_LLAMA if s in enabled_services]
|
|
assignments = {}
|
|
final_llama_gpus = list(llama_gpus)
|
|
|
|
if len(remaining) == 0:
|
|
fallback = llama_gpus[-1]
|
|
for s in active_non_llama:
|
|
assignments[s] = ServiceAssignment(gpus=[fallback])
|
|
strategy = "colocated"
|
|
|
|
elif len(remaining) == 1:
|
|
for s in active_non_llama:
|
|
assignments[s] = ServiceAssignment(gpus=[remaining[0]])
|
|
strategy = "colocated"
|
|
|
|
elif len(remaining) == 2:
|
|
if "whisper" in enabled_services:
|
|
assignments["whisper"] = ServiceAssignment(gpus=[remaining[0]])
|
|
if "comfyui" in enabled_services:
|
|
assignments["comfyui"] = ServiceAssignment(gpus=[remaining[1]])
|
|
if "embeddings" in enabled_services:
|
|
assignments["embeddings"] = ServiceAssignment(gpus=[remaining[1]])
|
|
strategy = "colocated"
|
|
|
|
else:
|
|
if "whisper" in enabled_services:
|
|
assignments["whisper"] = ServiceAssignment(gpus=[remaining[0]])
|
|
if "comfyui" in enabled_services:
|
|
assignments["comfyui"] = ServiceAssignment(gpus=[remaining[1]])
|
|
if "embeddings" in enabled_services:
|
|
assignments["embeddings"] = ServiceAssignment(gpus=[remaining[2]])
|
|
# Push extras back to llama so no GPU sits idle
|
|
if len(remaining) > 3:
|
|
final_llama_gpus = final_llama_gpus + remaining[3:]
|
|
strategy = "dedicated"
|
|
|
|
assignments["llama_server"] = ServiceAssignment(gpus=final_llama_gpus)
|
|
return assignments, final_llama_gpus, strategy
|
|
|
|
|
|
# Phase 3: Llama Parallelism
|
|
|
|
def largest_pow2_divisor(n: int) -> int:
|
|
"""
|
|
Find the largest power of 2 p such that:
|
|
- p divides n evenly
|
|
- p <= sqrt(n) (keeps tensor_size <= pipeline_size for balance)
|
|
Minimum return value is 2 (hybrid requires at least 2 tensor groups).
|
|
"""
|
|
p = 1
|
|
while True:
|
|
candidate = p * 2
|
|
if candidate > n or n % candidate != 0:
|
|
break
|
|
if candidate > math.sqrt(n):
|
|
break
|
|
p = candidate
|
|
return max(2, p)
|
|
|
|
|
|
def is_heterogeneous(gpus: list) -> bool:
|
|
vrams = [g.memory_mb for g in gpus]
|
|
return max(vrams) != min(vrams)
|
|
|
|
|
|
def compute_tensor_split(gpus: list) -> list:
|
|
"""Proportional VRAM weights, rounded to 4 decimal places."""
|
|
total = sum(g.memory_mb for g in gpus)
|
|
return [round(g.memory_mb / total, 4) for g in gpus]
|
|
|
|
|
|
def select_parallelism(subset: Subset) -> LlamaParallelism:
|
|
"""
|
|
Select parallelism mode based on GPU count and min_link_rank.
|
|
|
|
Thresholds:
|
|
rank >= 80 → NVLink / XGMI → tensor or hybrid
|
|
rank 11-79 → same-NUMA PCIe → pipeline, or hybrid if rank >= 40 and >= 4 GPUs
|
|
rank <= 10 → cross-NUMA → pipeline only
|
|
"""
|
|
gpus = subset.gpus
|
|
n = len(gpus)
|
|
rank = subset.min_link_rank
|
|
split = compute_tensor_split(gpus) if is_heterogeneous(gpus) else None
|
|
|
|
# Single GPU
|
|
if n == 1:
|
|
return LlamaParallelism(
|
|
mode="none",
|
|
tensor_parallel_size=1,
|
|
pipeline_parallel_size=1,
|
|
gpu_memory_utilization=0.95,
|
|
)
|
|
|
|
# High-bandwidth (NVLink / XGMI)
|
|
if rank >= HIGH_BW_THRESHOLD:
|
|
if n <= 3:
|
|
return LlamaParallelism(
|
|
mode="tensor",
|
|
tensor_parallel_size=n,
|
|
pipeline_parallel_size=1,
|
|
gpu_memory_utilization=0.92,
|
|
tensor_split=split,
|
|
)
|
|
else:
|
|
tp = largest_pow2_divisor(n)
|
|
pp = n // tp
|
|
return LlamaParallelism(
|
|
mode="hybrid",
|
|
tensor_parallel_size=tp,
|
|
pipeline_parallel_size=pp,
|
|
gpu_memory_utilization=0.93,
|
|
tensor_split=split,
|
|
)
|
|
|
|
# Cross-NUMA PCIe
|
|
if rank <= 10:
|
|
return LlamaParallelism(
|
|
mode="pipeline",
|
|
tensor_parallel_size=1,
|
|
pipeline_parallel_size=n,
|
|
gpu_memory_utilization=0.95,
|
|
)
|
|
|
|
# Same-NUMA PCIe (rank 11-79)
|
|
if n <= 3:
|
|
return LlamaParallelism(
|
|
mode="pipeline",
|
|
tensor_parallel_size=1,
|
|
pipeline_parallel_size=n,
|
|
gpu_memory_utilization=0.95,
|
|
)
|
|
else:
|
|
if rank >= 40:
|
|
tp = largest_pow2_divisor(n)
|
|
pp = n // tp
|
|
return LlamaParallelism(
|
|
mode="hybrid",
|
|
tensor_parallel_size=tp,
|
|
pipeline_parallel_size=pp,
|
|
gpu_memory_utilization=0.93,
|
|
tensor_split=split,
|
|
)
|
|
else:
|
|
return LlamaParallelism(
|
|
mode="pipeline",
|
|
tensor_parallel_size=1,
|
|
pipeline_parallel_size=n,
|
|
gpu_memory_utilization=0.95,
|
|
)
|
|
|
|
|
|
# Phase 4: Build Output JSON
|
|
|
|
def build_output(result: AssignmentResult) -> dict:
|
|
services = {}
|
|
|
|
for name, assignment in result.services.items():
|
|
entry = {
|
|
"gpus": [g.uuid for g in assignment.gpus],
|
|
"gpu_indices": [g.index for g in assignment.gpus],
|
|
}
|
|
|
|
if assignment.parallelism:
|
|
p = assignment.parallelism
|
|
para = {
|
|
"mode": p.mode,
|
|
"tensor_parallel_size": p.tensor_parallel_size,
|
|
"pipeline_parallel_size": p.pipeline_parallel_size,
|
|
"gpu_memory_utilization": p.gpu_memory_utilization,
|
|
}
|
|
if p.tensor_split is not None:
|
|
para["tensor_split"] = p.tensor_split
|
|
entry["parallelism"] = para
|
|
|
|
services[name] = entry
|
|
|
|
return {
|
|
"gpu_assignment": {
|
|
"version": "1.0",
|
|
"strategy": result.strategy,
|
|
"services": services,
|
|
}
|
|
}
|
|
|
|
|
|
# Entry Point
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="GPU assignment algorithm for ODS")
|
|
parser.add_argument("--topology", required=True, help="Path to topology JSON file")
|
|
parser.add_argument("--model-size", required=True, type=float, help="Model size in MB")
|
|
parser.add_argument("--enabled-services", default=",".join(DEFAULT_SERVICES),
|
|
help="Comma-separated list of enabled services")
|
|
args = parser.parse_args()
|
|
|
|
# Load topology
|
|
try:
|
|
with open(args.topology) as f:
|
|
topology = json.load(f)
|
|
except FileNotFoundError:
|
|
print(f"ERROR: topology file not found: {args.topology}", file=sys.stderr)
|
|
sys.exit(1)
|
|
except json.JSONDecodeError as e:
|
|
print(f"ERROR: invalid JSON in topology file: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
enabled_services = [s.strip() for s in args.enabled_services.split(",")]
|
|
model_size_mb = args.model_size
|
|
gpu_count = topology.get("gpu_count", 0)
|
|
|
|
if gpu_count == 0:
|
|
print("ERROR: no GPUs found in topology", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Early exit: single GPU
|
|
if gpu_count == 1:
|
|
gpu = parse_gpus(topology)[0]
|
|
if model_size_mb > gpu.memory_mb:
|
|
print(
|
|
f"ERROR: Model size {model_size_mb:.0f}MB exceeds assignable free VRAM "
|
|
f"({gpu.memory_mb:.0f}MB across all GPUs).",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
parallelism = LlamaParallelism(
|
|
mode="none",
|
|
tensor_parallel_size=1,
|
|
pipeline_parallel_size=1,
|
|
gpu_memory_utilization=0.95,
|
|
)
|
|
services = {}
|
|
for s in enabled_services:
|
|
services[s] = ServiceAssignment(gpus=[gpu])
|
|
# llama_server always runs on the single GPU, even when the caller's
|
|
# --enabled-services list omits it. This mirrors the multi-GPU path,
|
|
# where assign_services() assigns llama_server unconditionally; without
|
|
# it, the line below raised KeyError: 'llama_server'.
|
|
services.setdefault("llama_server", ServiceAssignment(gpus=[gpu]))
|
|
services["llama_server"].parallelism = parallelism
|
|
result = AssignmentResult(strategy="single", services=services)
|
|
print(json.dumps(build_output(result), indent=2))
|
|
return
|
|
|
|
# Phase 1: Topology analysis
|
|
gpus = parse_gpus(topology)
|
|
links = parse_links(topology)
|
|
rank_matrix = build_rank_matrix(links)
|
|
ordered = enumerate_subsets(gpus, rank_matrix)
|
|
|
|
# Phase 2: GPU assignment
|
|
try:
|
|
llama_subset = find_llama_subset(ordered, model_size_mb)
|
|
if llama_subset is None:
|
|
llama_subset = span_subsets(gpus, rank_matrix, model_size_mb, ordered)
|
|
except ValueError as e:
|
|
print(f"ERROR: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
vendor = topology.get("vendor", "nvidia")
|
|
|
|
# AMD APU+dGPU hybrid: if topology has mixed memory types, ensure LLM
|
|
# gets discrete GPUs. Re-sort llama subset to prefer discrete over unified.
|
|
if vendor == "amd":
|
|
has_mixed = (
|
|
any(g.memory_type == "unified" for g in gpus)
|
|
and any(g.memory_type == "discrete" for g in gpus)
|
|
)
|
|
if has_mixed:
|
|
discrete_gpus = [g for g in llama_subset.gpus if g.memory_type == "discrete"]
|
|
if discrete_gpus:
|
|
# Rebuild llama subset using only discrete GPUs if they cover model size
|
|
discrete_subset = compute_subset(discrete_gpus, rank_matrix)
|
|
if discrete_subset.total_vram_mb >= model_size_mb:
|
|
llama_subset = discrete_subset
|
|
|
|
service_assignments, final_llama_gpus, strategy = assign_services(
|
|
gpus, llama_subset.gpus, rank_matrix, enabled_services, vendor=vendor
|
|
)
|
|
|
|
# Phase 3: Llama parallelism
|
|
final_subset = compute_subset(final_llama_gpus, rank_matrix)
|
|
parallelism = select_parallelism(final_subset)
|
|
service_assignments["llama_server"].parallelism = parallelism
|
|
|
|
# Phase 4: Emit JSON
|
|
result = AssignmentResult(strategy=strategy, services=service_assignments)
|
|
print(json.dumps(build_output(result), indent=2))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|