#!/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()