[model-gateway] add GPU allocator and model pool infrastructure for parallel E2E tests (#16460)
This commit is contained in:
@@ -1,6 +1,25 @@
|
|||||||
|
"""Pytest configuration for E2E tests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
from importlib.util import find_spec
|
from importlib.util import find_spec
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from infra import ModelPool
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)8s] %(name)s: %(message)s",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Only add bindings/python to path if the wheel is not installed (for local development)
|
# Only add bindings/python to path if the wheel is not installed (for local development)
|
||||||
# This ensures CI tests use the installed wheel which contains the Rust extension
|
# This ensures CI tests use the installed wheel which contains the Rust extension
|
||||||
@@ -13,3 +32,160 @@ _wheel_installed = find_spec("sglang_router.sglang_router_rs") is not None
|
|||||||
# Only add bindings/python if wheel is not installed (development mode)
|
# Only add bindings/python if wheel is not installed (development mode)
|
||||||
if not _wheel_installed and str(_SRC) not in sys.path:
|
if not _wheel_installed and str(_SRC) not in sys.path:
|
||||||
sys.path.insert(0, str(_SRC))
|
sys.path.insert(0, str(_SRC))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Custom pytest markers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_configure(config: pytest.Config) -> None:
|
||||||
|
"""Register custom markers."""
|
||||||
|
config.addinivalue_line(
|
||||||
|
"markers",
|
||||||
|
"model(name): mark test to use a specific model from the model pool",
|
||||||
|
)
|
||||||
|
config.addinivalue_line(
|
||||||
|
"markers",
|
||||||
|
"e2e: mark test as an end-to-end test requiring GPU workers",
|
||||||
|
)
|
||||||
|
config.addinivalue_line(
|
||||||
|
"markers",
|
||||||
|
"slow: mark test as slow-running",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Model pool fixtures (session-scoped)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Global model pool instance
|
||||||
|
_model_pool: "ModelPool | None" = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_requested_models(config: pytest.Config) -> list[str]:
|
||||||
|
"""Determine which models are needed based on collected tests.
|
||||||
|
|
||||||
|
This scans all test items for @pytest.mark.model() markers and returns
|
||||||
|
the unique set of models requested.
|
||||||
|
"""
|
||||||
|
models = set()
|
||||||
|
|
||||||
|
# This is called during collection, so we need to iterate items
|
||||||
|
for item in config.pluginmanager.get_plugin("main").session.items:
|
||||||
|
marker = item.get_closest_marker("model")
|
||||||
|
if marker and marker.args:
|
||||||
|
models.add(marker.args[0])
|
||||||
|
|
||||||
|
return list(models)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def model_pool(request: pytest.FixtureRequest) -> "ModelPool":
|
||||||
|
"""Session-scoped fixture providing the model pool.
|
||||||
|
|
||||||
|
The model pool pre-loads all models needed by tests in this session,
|
||||||
|
running them in parallel across available GPUs.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
@pytest.mark.model("llama-8b")
|
||||||
|
def test_chat(model_pool):
|
||||||
|
client = model_pool.get_client("llama-8b")
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
global _model_pool
|
||||||
|
|
||||||
|
# Import here to avoid import errors when infra is not set up
|
||||||
|
from infra import MODEL_SPECS, GPUAllocator, ModelPool
|
||||||
|
|
||||||
|
if _model_pool is not None:
|
||||||
|
return _model_pool
|
||||||
|
|
||||||
|
# Check if we should skip model startup (e.g., for unit tests)
|
||||||
|
if os.environ.get("SKIP_MODEL_POOL", "").lower() in ("1", "true", "yes"):
|
||||||
|
logger.info("SKIP_MODEL_POOL is set, skipping model pool startup")
|
||||||
|
_model_pool = ModelPool(GPUAllocator(gpus=[]))
|
||||||
|
return _model_pool
|
||||||
|
|
||||||
|
# Determine which models to start
|
||||||
|
# For now, start models based on environment or a default set
|
||||||
|
models_env = os.environ.get("E2E_MODELS", "")
|
||||||
|
if models_env:
|
||||||
|
model_ids = [m.strip() for m in models_env.split(",") if m.strip()]
|
||||||
|
else:
|
||||||
|
# Default: start commonly needed models
|
||||||
|
model_ids = ["llama-8b", "qwen-7b"]
|
||||||
|
|
||||||
|
# Filter to available specs
|
||||||
|
model_ids = [m for m in model_ids if m in MODEL_SPECS]
|
||||||
|
|
||||||
|
if not model_ids:
|
||||||
|
logger.warning("No models specified, model pool will be empty")
|
||||||
|
_model_pool = ModelPool(GPUAllocator(gpus=[]))
|
||||||
|
return _model_pool
|
||||||
|
|
||||||
|
logger.info("Starting model pool with models: %s", model_ids)
|
||||||
|
|
||||||
|
# Create and start the pool
|
||||||
|
allocator = GPUAllocator()
|
||||||
|
_model_pool = ModelPool(allocator)
|
||||||
|
|
||||||
|
grpc_mode = os.environ.get("E2E_GRPC_MODE", "").lower() in ("1", "true", "yes")
|
||||||
|
startup_timeout = int(os.environ.get("E2E_STARTUP_TIMEOUT", "300"))
|
||||||
|
|
||||||
|
_model_pool.startup(
|
||||||
|
model_ids=model_ids,
|
||||||
|
grpc_mode=grpc_mode,
|
||||||
|
startup_timeout=startup_timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Register cleanup
|
||||||
|
request.addfinalizer(_model_pool.shutdown)
|
||||||
|
|
||||||
|
return _model_pool
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def model_client(request: pytest.FixtureRequest, model_pool: "ModelPool"):
|
||||||
|
"""Get OpenAI client for the model specified by @pytest.mark.model().
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
@pytest.mark.model("llama-8b")
|
||||||
|
def test_chat(model_client):
|
||||||
|
response = model_client.chat.completions.create(...)
|
||||||
|
"""
|
||||||
|
marker = request.node.get_closest_marker("model")
|
||||||
|
if marker is None:
|
||||||
|
pytest.fail(
|
||||||
|
"Test must be marked with @pytest.mark.model('model-id') to use model_client fixture"
|
||||||
|
)
|
||||||
|
|
||||||
|
model_id = marker.args[0]
|
||||||
|
|
||||||
|
try:
|
||||||
|
return model_pool.get_client(model_id)
|
||||||
|
except KeyError:
|
||||||
|
pytest.skip(f"Model {model_id} not available in model pool")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def model_base_url(request: pytest.FixtureRequest, model_pool: "ModelPool") -> str:
|
||||||
|
"""Get the base URL for the model specified by @pytest.mark.model().
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
@pytest.mark.model("llama-8b")
|
||||||
|
def test_direct_http(model_base_url):
|
||||||
|
response = httpx.get(f"{model_base_url}/health")
|
||||||
|
"""
|
||||||
|
marker = request.node.get_closest_marker("model")
|
||||||
|
if marker is None:
|
||||||
|
pytest.fail(
|
||||||
|
"Test must be marked with @pytest.mark.model('model-id') to use model_base_url fixture"
|
||||||
|
)
|
||||||
|
|
||||||
|
model_id = marker.args[0]
|
||||||
|
|
||||||
|
try:
|
||||||
|
return model_pool.get_base_url(model_id)
|
||||||
|
except KeyError:
|
||||||
|
pytest.skip(f"Model {model_id} not available in model pool")
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Infrastructure for parallel GPU test execution."""
|
||||||
|
|
||||||
|
from .gpu_allocator import (
|
||||||
|
GPUAllocator,
|
||||||
|
GPUInfo,
|
||||||
|
GPUSlot,
|
||||||
|
get_gpu_memory_usage,
|
||||||
|
get_open_port,
|
||||||
|
get_physical_device_indices,
|
||||||
|
nvml_context,
|
||||||
|
wait_for_gpu_memory_to_clear,
|
||||||
|
)
|
||||||
|
from .model_pool import ModelInstance, ModelPool
|
||||||
|
from .model_specs import MODEL_SPECS
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# GPU allocation
|
||||||
|
"GPUAllocator",
|
||||||
|
"GPUInfo",
|
||||||
|
"GPUSlot",
|
||||||
|
# GPU utilities
|
||||||
|
"nvml_context",
|
||||||
|
"get_open_port",
|
||||||
|
"get_physical_device_indices",
|
||||||
|
"get_gpu_memory_usage",
|
||||||
|
"wait_for_gpu_memory_to_clear",
|
||||||
|
# Model management
|
||||||
|
"ModelInstance",
|
||||||
|
"ModelPool",
|
||||||
|
"MODEL_SPECS",
|
||||||
|
]
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
"""GPU detection and slot allocation for parallel test execution."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Try to import nvidia-ml-py for GPU detection
|
||||||
|
try:
|
||||||
|
import pynvml
|
||||||
|
|
||||||
|
NVML_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
NVML_AVAILABLE = False
|
||||||
|
logger.debug("nvidia-ml-py not available, GPU detection will be limited")
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def nvml_context():
|
||||||
|
"""Context manager for NVML initialization/shutdown.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
with nvml_context():
|
||||||
|
handle = pynvml.nvmlDeviceGetHandleByIndex(0)
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
if not NVML_AVAILABLE:
|
||||||
|
yield
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
pynvml.nvmlInit()
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
pynvml.nvmlShutdown()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GPUInfo:
|
||||||
|
"""Information about a single GPU."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
memory_mb: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def memory_gb(self) -> float:
|
||||||
|
return self.memory_mb / 1024
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GPUSlot:
|
||||||
|
"""A slot representing one or more GPUs allocated for a model."""
|
||||||
|
|
||||||
|
gpu_ids: list[int]
|
||||||
|
total_memory_mb: int
|
||||||
|
assigned_model: str | None = None
|
||||||
|
port: int | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_memory_gb(self) -> float:
|
||||||
|
return self.total_memory_mb / 1024
|
||||||
|
|
||||||
|
def cuda_visible_devices(self) -> str:
|
||||||
|
"""Return CUDA_VISIBLE_DEVICES string for this slot."""
|
||||||
|
return ",".join(str(g) for g in self.gpu_ids)
|
||||||
|
|
||||||
|
|
||||||
|
def get_open_port() -> int:
|
||||||
|
"""Get an available port by binding to port 0 and reading the assigned port."""
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
s.bind(("", 0))
|
||||||
|
s.listen(1)
|
||||||
|
port = s.getsockname()[1]
|
||||||
|
return port
|
||||||
|
|
||||||
|
|
||||||
|
def get_physical_device_indices(devices: list[int]) -> list[int]:
|
||||||
|
"""Map logical device indices to physical indices based on CUDA_VISIBLE_DEVICES."""
|
||||||
|
visible_devices = os.environ.get("CUDA_VISIBLE_DEVICES")
|
||||||
|
if visible_devices is None:
|
||||||
|
return devices
|
||||||
|
|
||||||
|
visible_indices = [int(x) for x in visible_devices.split(",")]
|
||||||
|
index_mapping = {i: physical for i, physical in enumerate(visible_indices)}
|
||||||
|
return [index_mapping[i] for i in devices if i in index_mapping]
|
||||||
|
|
||||||
|
|
||||||
|
def get_gpu_memory_usage(device_id: int) -> tuple[float, float]:
|
||||||
|
"""Get GPU memory usage in GB (used, total).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_id: Physical GPU device ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (used_gb, total_gb)
|
||||||
|
"""
|
||||||
|
if not NVML_AVAILABLE:
|
||||||
|
return (0.0, 0.0)
|
||||||
|
|
||||||
|
with nvml_context():
|
||||||
|
handle = pynvml.nvmlDeviceGetHandleByIndex(device_id)
|
||||||
|
mem_info = pynvml.nvmlDeviceGetMemoryInfo(handle)
|
||||||
|
return (mem_info.used / (1024**3), mem_info.total / (1024**3))
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_gpu_memory_to_clear(
|
||||||
|
*,
|
||||||
|
devices: list[int],
|
||||||
|
threshold_bytes: int | None = None,
|
||||||
|
threshold_ratio: float | None = None,
|
||||||
|
timeout_s: float = 120,
|
||||||
|
) -> None:
|
||||||
|
"""Wait for GPU memory to be freed below a threshold.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
devices: List of logical GPU device IDs to check
|
||||||
|
threshold_bytes: Memory threshold in bytes (used <= threshold)
|
||||||
|
threshold_ratio: Memory threshold as ratio (used/total <= ratio)
|
||||||
|
timeout_s: Timeout in seconds
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If memory doesn't clear within timeout
|
||||||
|
"""
|
||||||
|
if not NVML_AVAILABLE:
|
||||||
|
logger.warning("nvidia-ml-py not available, skipping memory wait")
|
||||||
|
return
|
||||||
|
|
||||||
|
if threshold_bytes is None and threshold_ratio is None:
|
||||||
|
raise ValueError("Must specify threshold_bytes or threshold_ratio")
|
||||||
|
|
||||||
|
physical_devices = get_physical_device_indices(devices)
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
with nvml_context():
|
||||||
|
while True:
|
||||||
|
output: dict[int, str] = {}
|
||||||
|
output_raw: dict[int, tuple[float, float]] = {}
|
||||||
|
|
||||||
|
for device in physical_devices:
|
||||||
|
handle = pynvml.nvmlDeviceGetHandleByIndex(device)
|
||||||
|
mem_info = pynvml.nvmlDeviceGetMemoryInfo(handle)
|
||||||
|
gb_used = mem_info.used / (1024**3)
|
||||||
|
gb_total = mem_info.total / (1024**3)
|
||||||
|
output_raw[device] = (gb_used, gb_total)
|
||||||
|
output[device] = f"{gb_used:.02f}/{gb_total:.02f}"
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"GPU memory used/total (GiB): %s",
|
||||||
|
" ".join(f"{k}={v}" for k, v in output.items()),
|
||||||
|
)
|
||||||
|
|
||||||
|
if threshold_bytes is not None:
|
||||||
|
|
||||||
|
def is_free(used: float, total: float) -> bool:
|
||||||
|
return used <= threshold_bytes / (1024**3)
|
||||||
|
|
||||||
|
threshold_desc = f"{threshold_bytes / (1024**3):.1f} GiB"
|
||||||
|
else:
|
||||||
|
|
||||||
|
def is_free(used: float, total: float) -> bool:
|
||||||
|
return used / total <= threshold_ratio # type: ignore[operator]
|
||||||
|
|
||||||
|
threshold_desc = f"{threshold_ratio:.2%}" # type: ignore[str-format]
|
||||||
|
|
||||||
|
dur_s = time.time() - start_time
|
||||||
|
if all(is_free(used, total) for used, total in output_raw.values()):
|
||||||
|
logger.info(
|
||||||
|
"GPU memory cleared on devices %s (threshold=%s) in %.1fs",
|
||||||
|
devices,
|
||||||
|
threshold_desc,
|
||||||
|
dur_s,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if dur_s >= timeout_s:
|
||||||
|
raise ValueError(
|
||||||
|
f"GPU memory on devices {devices} not freed after {dur_s:.1f}s "
|
||||||
|
f"(threshold={threshold_desc})"
|
||||||
|
)
|
||||||
|
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
|
||||||
|
class GPUAllocator:
|
||||||
|
"""Detects GPUs and assigns them to model slots using bin-packing."""
|
||||||
|
|
||||||
|
def __init__(self, gpus: list[GPUInfo] | None = None):
|
||||||
|
"""Initialize the allocator.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
gpus: Optional list of GPUs. If None, auto-detects via nvidia-ml-py.
|
||||||
|
"""
|
||||||
|
self.gpus = gpus if gpus is not None else self._detect_gpus()
|
||||||
|
self.slots: list[GPUSlot] = []
|
||||||
|
|
||||||
|
def _detect_gpus(self) -> list[GPUInfo]:
|
||||||
|
"""Auto-detect available GPUs via nvidia-ml-py (NVML)."""
|
||||||
|
if not NVML_AVAILABLE:
|
||||||
|
logger.warning("nvidia-ml-py not available - no GPUs detected")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Check for CUDA_VISIBLE_DEVICES restriction
|
||||||
|
visible_devices = os.environ.get("CUDA_VISIBLE_DEVICES")
|
||||||
|
allowed_ids: set[int] | None = None
|
||||||
|
if visible_devices:
|
||||||
|
allowed_ids = set(int(x) for x in visible_devices.split(",") if x.strip())
|
||||||
|
|
||||||
|
try:
|
||||||
|
with nvml_context():
|
||||||
|
device_count = pynvml.nvmlDeviceGetCount()
|
||||||
|
|
||||||
|
gpus = []
|
||||||
|
for idx in range(device_count):
|
||||||
|
# Skip GPUs not in CUDA_VISIBLE_DEVICES if set
|
||||||
|
if allowed_ids is not None and idx not in allowed_ids:
|
||||||
|
continue
|
||||||
|
|
||||||
|
handle = pynvml.nvmlDeviceGetHandleByIndex(idx)
|
||||||
|
name = pynvml.nvmlDeviceGetName(handle)
|
||||||
|
# Handle bytes vs string return type (varies by pynvml version)
|
||||||
|
if isinstance(name, bytes):
|
||||||
|
name = name.decode("utf-8")
|
||||||
|
mem_info = pynvml.nvmlDeviceGetMemoryInfo(handle)
|
||||||
|
# Convert bytes to MB
|
||||||
|
memory_mb = mem_info.total // (1024 * 1024)
|
||||||
|
|
||||||
|
gpus.append(GPUInfo(idx, name, memory_mb))
|
||||||
|
|
||||||
|
logger.info("Detected %d GPUs: %s", len(gpus), [g.name for g in gpus])
|
||||||
|
return gpus
|
||||||
|
|
||||||
|
except pynvml.NVMLError as e:
|
||||||
|
logger.warning("NVML error during GPU detection: %s", e)
|
||||||
|
return []
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to detect GPUs: %s", e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
def allocate_slots(self, model_specs: dict[str, dict]) -> list[GPUSlot]:
|
||||||
|
"""Allocate GPU slots based on model memory requirements.
|
||||||
|
|
||||||
|
Uses a first-fit decreasing bin-packing algorithm:
|
||||||
|
1. Sort models by memory requirement (largest first)
|
||||||
|
2. For each model, find the first GPU(s) that can fit it
|
||||||
|
3. For multi-GPU models, find consecutive GPUs
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_specs: Dict of model_id -> spec dict with 'memory_gb' and 'tp' keys
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of GPUSlots with assigned models
|
||||||
|
"""
|
||||||
|
if not self.gpus:
|
||||||
|
logger.warning("No GPUs available for allocation")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Sort models by memory requirement (largest first for better packing)
|
||||||
|
sorted_models = sorted(
|
||||||
|
model_specs.items(),
|
||||||
|
key=lambda x: x[1].get("memory_gb", 0),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Track which GPUs are used
|
||||||
|
used_gpus: set[int] = set()
|
||||||
|
slots: list[GPUSlot] = []
|
||||||
|
|
||||||
|
for model_id, spec in sorted_models:
|
||||||
|
memory_gb = spec.get("memory_gb", 16)
|
||||||
|
tp_size = spec.get("tp", 1)
|
||||||
|
|
||||||
|
# Find available GPUs
|
||||||
|
available = [g for g in self.gpus if g.id not in used_gpus]
|
||||||
|
|
||||||
|
if tp_size == 1:
|
||||||
|
# Single GPU - find one with enough memory
|
||||||
|
for gpu in available:
|
||||||
|
if gpu.memory_gb >= memory_gb:
|
||||||
|
slot = GPUSlot(
|
||||||
|
gpu_ids=[gpu.id],
|
||||||
|
total_memory_mb=gpu.memory_mb,
|
||||||
|
assigned_model=model_id,
|
||||||
|
port=get_open_port(),
|
||||||
|
)
|
||||||
|
slots.append(slot)
|
||||||
|
used_gpus.add(gpu.id)
|
||||||
|
logger.info(
|
||||||
|
"Allocated GPU %d (%s, %.1fGB) for %s",
|
||||||
|
gpu.id,
|
||||||
|
gpu.name,
|
||||||
|
gpu.memory_gb,
|
||||||
|
model_id,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"No GPU with %.1fGB available for %s", memory_gb, model_id
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Multi-GPU - find consecutive GPUs with enough total memory
|
||||||
|
# Sort available by ID for consecutive allocation
|
||||||
|
available_sorted = sorted(available, key=lambda g: g.id)
|
||||||
|
|
||||||
|
for i in range(len(available_sorted) - tp_size + 1):
|
||||||
|
candidate_gpus = available_sorted[i : i + tp_size]
|
||||||
|
total_mem = sum(g.memory_mb for g in candidate_gpus)
|
||||||
|
|
||||||
|
if total_mem >= memory_gb * 1024:
|
||||||
|
gpu_ids = [g.id for g in candidate_gpus]
|
||||||
|
slot = GPUSlot(
|
||||||
|
gpu_ids=gpu_ids,
|
||||||
|
total_memory_mb=total_mem,
|
||||||
|
assigned_model=model_id,
|
||||||
|
port=get_open_port(),
|
||||||
|
)
|
||||||
|
slots.append(slot)
|
||||||
|
used_gpus.update(gpu_ids)
|
||||||
|
logger.info(
|
||||||
|
"Allocated GPUs %s (%.1fGB total) for %s (tp=%d)",
|
||||||
|
gpu_ids,
|
||||||
|
total_mem / 1024,
|
||||||
|
model_id,
|
||||||
|
tp_size,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"No %d consecutive GPUs with %.1fGB available for %s",
|
||||||
|
tp_size,
|
||||||
|
memory_gb,
|
||||||
|
model_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.slots = slots
|
||||||
|
return slots
|
||||||
|
|
||||||
|
def get_slot_for_model(self, model_id: str) -> GPUSlot | None:
|
||||||
|
"""Get the slot assigned to a specific model."""
|
||||||
|
for slot in self.slots:
|
||||||
|
if slot.assigned_model == model_id:
|
||||||
|
return slot
|
||||||
|
return None
|
||||||
|
|
||||||
|
def summary(self) -> str:
|
||||||
|
"""Return a summary of GPU allocations."""
|
||||||
|
lines = ["GPU Allocation Summary:"]
|
||||||
|
lines.append(f" Total GPUs: {len(self.gpus)}")
|
||||||
|
lines.append(f" Allocated Slots: {len(self.slots)}")
|
||||||
|
for slot in self.slots:
|
||||||
|
lines.append(
|
||||||
|
f" - {slot.assigned_model}: GPUs {slot.gpu_ids} "
|
||||||
|
f"({slot.total_memory_gb:.1f}GB) port={slot.port}"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
"""Model pool for managing pre-loaded models across GPUs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import openai
|
||||||
|
|
||||||
|
from .gpu_allocator import GPUAllocator, GPUSlot
|
||||||
|
from .model_specs import MODEL_SPECS, get_model_spec
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Default timeout for model startup (seconds)
|
||||||
|
DEFAULT_STARTUP_TIMEOUT = 300
|
||||||
|
# Health check interval (seconds)
|
||||||
|
HEALTH_CHECK_INTERVAL = 5
|
||||||
|
# Host for model servers
|
||||||
|
DEFAULT_HOST = "127.0.0.1"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ModelInstance:
|
||||||
|
"""A running model instance."""
|
||||||
|
|
||||||
|
model_id: str
|
||||||
|
model_path: str
|
||||||
|
base_url: str
|
||||||
|
process: subprocess.Popen
|
||||||
|
gpu_slot: GPUSlot
|
||||||
|
grpc_mode: bool = False
|
||||||
|
|
||||||
|
def is_alive(self) -> bool:
|
||||||
|
"""Check if the process is still running."""
|
||||||
|
return self.process.poll() is None
|
||||||
|
|
||||||
|
def health_check(self, timeout: float = 5.0) -> bool:
|
||||||
|
"""Check if the model server is healthy via HTTP."""
|
||||||
|
try:
|
||||||
|
resp = httpx.get(f"{self.base_url}/health", timeout=timeout)
|
||||||
|
return resp.status_code == 200
|
||||||
|
except (httpx.RequestError, httpx.TimeoutException):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def terminate(self, timeout: float = 10.0) -> None:
|
||||||
|
"""Terminate the model server process."""
|
||||||
|
if self.process.poll() is not None:
|
||||||
|
return # Already terminated
|
||||||
|
|
||||||
|
logger.info("Terminating model %s (PID %d)", self.model_id, self.process.pid)
|
||||||
|
|
||||||
|
# Try graceful shutdown first
|
||||||
|
self.process.terminate()
|
||||||
|
try:
|
||||||
|
self.process.wait(timeout=timeout)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.warning("Model %s did not terminate, killing", self.model_id)
|
||||||
|
self.process.kill()
|
||||||
|
self.process.wait()
|
||||||
|
|
||||||
|
|
||||||
|
class ModelPool:
|
||||||
|
"""Manages a pool of pre-loaded models across GPUs."""
|
||||||
|
|
||||||
|
def __init__(self, allocator: GPUAllocator | None = None):
|
||||||
|
"""Initialize the model pool.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
allocator: GPU allocator to use. If None, creates a new one.
|
||||||
|
"""
|
||||||
|
self.allocator = allocator or GPUAllocator()
|
||||||
|
self.instances: dict[str, ModelInstance] = {}
|
||||||
|
self._startup_timeout = DEFAULT_STARTUP_TIMEOUT
|
||||||
|
|
||||||
|
def startup(
|
||||||
|
self,
|
||||||
|
model_ids: list[str] | None = None,
|
||||||
|
grpc_mode: bool = False,
|
||||||
|
startup_timeout: int = DEFAULT_STARTUP_TIMEOUT,
|
||||||
|
) -> None:
|
||||||
|
"""Spin up models in parallel on assigned GPU slots.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_ids: List of model IDs to start. If None, starts all in MODEL_SPECS.
|
||||||
|
grpc_mode: If True, launch workers in gRPC mode.
|
||||||
|
startup_timeout: Timeout in seconds for each model to become healthy.
|
||||||
|
"""
|
||||||
|
self._startup_timeout = startup_timeout
|
||||||
|
|
||||||
|
# Determine which models to start
|
||||||
|
if model_ids is None:
|
||||||
|
model_ids = list(MODEL_SPECS.keys())
|
||||||
|
|
||||||
|
# Filter to models we have specs for
|
||||||
|
specs_to_start = {
|
||||||
|
mid: MODEL_SPECS[mid] for mid in model_ids if mid in MODEL_SPECS
|
||||||
|
}
|
||||||
|
|
||||||
|
if not specs_to_start:
|
||||||
|
logger.warning("No valid model specs to start")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Allocate GPU slots
|
||||||
|
slots = self.allocator.allocate_slots(specs_to_start)
|
||||||
|
|
||||||
|
if not slots:
|
||||||
|
logger.warning("No GPU slots allocated")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(self.allocator.summary())
|
||||||
|
|
||||||
|
# Launch all models in parallel
|
||||||
|
for slot in slots:
|
||||||
|
if slot.assigned_model:
|
||||||
|
self._launch_model(slot, grpc_mode=grpc_mode)
|
||||||
|
|
||||||
|
# Wait for all to be healthy
|
||||||
|
self._wait_all_healthy()
|
||||||
|
|
||||||
|
def _launch_model(self, slot: GPUSlot, grpc_mode: bool = False) -> None:
|
||||||
|
"""Launch a model on the given GPU slot."""
|
||||||
|
model_id = slot.assigned_model
|
||||||
|
if not model_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
spec = get_model_spec(model_id)
|
||||||
|
model_path = spec["model"]
|
||||||
|
tp_size = spec.get("tp", 1)
|
||||||
|
port = slot.port
|
||||||
|
|
||||||
|
# Build environment with CUDA_VISIBLE_DEVICES
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["CUDA_VISIBLE_DEVICES"] = slot.cuda_visible_devices()
|
||||||
|
|
||||||
|
# Build command
|
||||||
|
cmd = [
|
||||||
|
"python3",
|
||||||
|
"-m",
|
||||||
|
"sglang.launch_server",
|
||||||
|
"--model-path",
|
||||||
|
model_path,
|
||||||
|
"--port",
|
||||||
|
str(port),
|
||||||
|
"--tp-size",
|
||||||
|
str(tp_size),
|
||||||
|
"--log-level",
|
||||||
|
"warning",
|
||||||
|
]
|
||||||
|
|
||||||
|
if grpc_mode:
|
||||||
|
cmd.append("--grpc-mode")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Launching %s on GPUs %s port %d: %s",
|
||||||
|
model_id,
|
||||||
|
slot.gpu_ids,
|
||||||
|
port,
|
||||||
|
" ".join(cmd),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start the process
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
env=env,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
# Use process group for clean shutdown
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
base_url = f"http://{DEFAULT_HOST}:{port}"
|
||||||
|
instance = ModelInstance(
|
||||||
|
model_id=model_id,
|
||||||
|
model_path=model_path,
|
||||||
|
base_url=base_url,
|
||||||
|
process=proc,
|
||||||
|
gpu_slot=slot,
|
||||||
|
grpc_mode=grpc_mode,
|
||||||
|
)
|
||||||
|
self.instances[model_id] = instance
|
||||||
|
|
||||||
|
def _wait_all_healthy(self) -> None:
|
||||||
|
"""Wait for all model instances to become healthy."""
|
||||||
|
start_time = time.time()
|
||||||
|
pending = set(self.instances.keys())
|
||||||
|
|
||||||
|
while pending and (time.time() - start_time) < self._startup_timeout:
|
||||||
|
for model_id in list(pending):
|
||||||
|
instance = self.instances[model_id]
|
||||||
|
|
||||||
|
# Check if process died
|
||||||
|
if not instance.is_alive():
|
||||||
|
logger.error(
|
||||||
|
"Model %s (PID %d) died during startup",
|
||||||
|
model_id,
|
||||||
|
instance.process.pid,
|
||||||
|
)
|
||||||
|
# Read stderr for debugging
|
||||||
|
if instance.process.stderr:
|
||||||
|
stderr = instance.process.stderr.read()
|
||||||
|
if stderr:
|
||||||
|
logger.error("Stderr: %s", stderr.decode()[-2000:])
|
||||||
|
pending.discard(model_id)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check health
|
||||||
|
if instance.health_check():
|
||||||
|
logger.info(
|
||||||
|
"Model %s is healthy at %s", model_id, instance.base_url
|
||||||
|
)
|
||||||
|
pending.discard(model_id)
|
||||||
|
|
||||||
|
if pending:
|
||||||
|
time.sleep(HEALTH_CHECK_INTERVAL)
|
||||||
|
|
||||||
|
if pending:
|
||||||
|
logger.error(
|
||||||
|
"Models failed to start within %ds: %s",
|
||||||
|
self._startup_timeout,
|
||||||
|
pending,
|
||||||
|
)
|
||||||
|
# Terminate failed instances
|
||||||
|
for model_id in pending:
|
||||||
|
self.instances[model_id].terminate()
|
||||||
|
del self.instances[model_id]
|
||||||
|
|
||||||
|
def get_client(self, model_id: str) -> "openai.OpenAI":
|
||||||
|
"""Get OpenAI client for a specific model.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_id: The model ID to get a client for.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
OpenAI client configured for this model.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyError: If model is not running.
|
||||||
|
"""
|
||||||
|
import openai
|
||||||
|
|
||||||
|
if model_id not in self.instances:
|
||||||
|
raise KeyError(
|
||||||
|
f"Model {model_id} not running. Available: {list(self.instances.keys())}"
|
||||||
|
)
|
||||||
|
|
||||||
|
instance = self.instances[model_id]
|
||||||
|
return openai.OpenAI(
|
||||||
|
base_url=f"{instance.base_url}/v1",
|
||||||
|
api_key="not-used",
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_base_url(self, model_id: str) -> str:
|
||||||
|
"""Get the base URL for a specific model."""
|
||||||
|
if model_id not in self.instances:
|
||||||
|
raise KeyError(
|
||||||
|
f"Model {model_id} not running. Available: {list(self.instances.keys())}"
|
||||||
|
)
|
||||||
|
return self.instances[model_id].base_url
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
"""Tear down all models."""
|
||||||
|
logger.info("Shutting down model pool (%d instances)", len(self.instances))
|
||||||
|
for instance in self.instances.values():
|
||||||
|
instance.terminate()
|
||||||
|
self.instances.clear()
|
||||||
|
|
||||||
|
def __enter__(self) -> "ModelPool":
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||||
|
self.shutdown()
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""Model specifications for E2E tests.
|
||||||
|
|
||||||
|
Each model spec defines:
|
||||||
|
- model: HuggingFace model path or local path
|
||||||
|
- memory_gb: Estimated GPU memory required
|
||||||
|
- tp: Tensor parallelism size (number of GPUs needed)
|
||||||
|
- features: List of features this model supports (for test filtering)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Environment variable for local model paths (CI uses local copies for speed)
|
||||||
|
ROUTER_LOCAL_MODEL_PATH = os.environ.get("ROUTER_LOCAL_MODEL_PATH", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_model_path(hf_path: str) -> str:
|
||||||
|
"""Resolve model path, preferring local path if available."""
|
||||||
|
if ROUTER_LOCAL_MODEL_PATH:
|
||||||
|
local_path = os.path.join(ROUTER_LOCAL_MODEL_PATH, hf_path)
|
||||||
|
if os.path.exists(local_path):
|
||||||
|
return local_path
|
||||||
|
return hf_path
|
||||||
|
|
||||||
|
|
||||||
|
MODEL_SPECS: dict[str, dict] = {
|
||||||
|
# Primary chat model - used for most tests
|
||||||
|
"llama-8b": {
|
||||||
|
"model": _resolve_model_path("meta-llama/Llama-3.1-8B-Instruct"),
|
||||||
|
"memory_gb": 16,
|
||||||
|
"tp": 1,
|
||||||
|
"features": ["chat", "streaming", "function_calling"],
|
||||||
|
},
|
||||||
|
# Small model for quick tests
|
||||||
|
"llama-1b": {
|
||||||
|
"model": _resolve_model_path("meta-llama/Llama-3.2-1B-Instruct"),
|
||||||
|
"memory_gb": 4,
|
||||||
|
"tp": 1,
|
||||||
|
"features": ["chat", "streaming", "tool_choice"],
|
||||||
|
},
|
||||||
|
# Function calling specialist
|
||||||
|
"qwen-7b": {
|
||||||
|
"model": _resolve_model_path("Qwen/Qwen2.5-7B-Instruct"),
|
||||||
|
"memory_gb": 14,
|
||||||
|
"tp": 1,
|
||||||
|
"features": ["chat", "streaming", "function_calling", "pythonic_tools"],
|
||||||
|
},
|
||||||
|
# Reasoning model
|
||||||
|
"deepseek-7b": {
|
||||||
|
"model": _resolve_model_path("deepseek-ai/DeepSeek-R1-Distill-Qwen-7B"),
|
||||||
|
"memory_gb": 14,
|
||||||
|
"tp": 1,
|
||||||
|
"features": ["chat", "streaming", "reasoning"],
|
||||||
|
},
|
||||||
|
# Thinking/reasoning model (larger)
|
||||||
|
"qwen-30b": {
|
||||||
|
"model": _resolve_model_path("Qwen/Qwen3-30B-A3B"),
|
||||||
|
"memory_gb": 60,
|
||||||
|
"tp": 2,
|
||||||
|
"features": ["chat", "streaming", "thinking", "reasoning"],
|
||||||
|
},
|
||||||
|
# Mistral for function calling
|
||||||
|
"mistral-7b": {
|
||||||
|
"model": _resolve_model_path("mistralai/Mistral-7B-Instruct-v0.3"),
|
||||||
|
"memory_gb": 14,
|
||||||
|
"tp": 1,
|
||||||
|
"features": ["chat", "streaming", "function_calling"],
|
||||||
|
},
|
||||||
|
# Embedding model
|
||||||
|
"embedding": {
|
||||||
|
"model": _resolve_model_path("intfloat/e5-mistral-7b-instruct"),
|
||||||
|
"memory_gb": 14,
|
||||||
|
"tp": 1,
|
||||||
|
"features": ["embedding"],
|
||||||
|
},
|
||||||
|
# GPT-OSS model (Harmony)
|
||||||
|
"gpt-oss": {
|
||||||
|
"model": _resolve_model_path("openai/gpt-oss-20b"),
|
||||||
|
"memory_gb": 40,
|
||||||
|
"tp": 2,
|
||||||
|
"features": ["chat", "streaming", "reasoning", "harmony"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_models_with_feature(feature: str) -> list[str]:
|
||||||
|
"""Get list of model IDs that support a specific feature."""
|
||||||
|
return [
|
||||||
|
model_id
|
||||||
|
for model_id, spec in MODEL_SPECS.items()
|
||||||
|
if feature in spec.get("features", [])
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_model_spec(model_id: str) -> dict:
|
||||||
|
"""Get spec for a specific model, raising KeyError if not found."""
|
||||||
|
if model_id not in MODEL_SPECS:
|
||||||
|
raise KeyError(
|
||||||
|
f"Unknown model: {model_id}. Available: {list(MODEL_SPECS.keys())}"
|
||||||
|
)
|
||||||
|
return MODEL_SPECS[model_id]
|
||||||
|
|
||||||
|
|
||||||
|
# Convenience groupings for test parametrization
|
||||||
|
CHAT_MODELS = get_models_with_feature("chat")
|
||||||
|
EMBEDDING_MODELS = get_models_with_feature("embedding")
|
||||||
|
REASONING_MODELS = get_models_with_feature("reasoning")
|
||||||
|
FUNCTION_CALLING_MODELS = get_models_with_feature("function_calling")
|
||||||
Reference in New Issue
Block a user