Skip to content

Commit 96a80e0

Browse files
Create production_scheduler.py
1 parent 0d5ec10 commit 96a80e0

1 file changed

Lines changed: 155 additions & 0 deletions

File tree

prototype/production_scheduler.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
# prototype/production_scheduler.py (ENHANCED)
2+
from typing import Dict, List, Optional
3+
import time
4+
import requests
5+
from dataclasses import dataclass, field
6+
import threading
7+
8+
@dataclass
9+
class WorkerHealthMetrics:
10+
"""Real-time worker health metrics."""
11+
gpu_utilization: float = 0.0
12+
memory_free_gb: float = 0.0
13+
cpu_utilization: float = 0.0
14+
inference_queue_depth: int = 0
15+
p50_latency_ms: float = 0.0
16+
p99_latency_ms: float = 0.0
17+
is_healthy: bool = True
18+
last_error: Optional[str] = None
19+
20+
def __post_init__(self):
21+
# Validate ranges
22+
if not (0 <= self.gpu_utilization <= 1):
23+
raise ValueError("GPU utilization must be 0-1")
24+
25+
class ProductionScheduler:
26+
"""Production-grade cost-aware scheduler with real-time metrics."""
27+
28+
def __init__(
29+
self,
30+
workers: List[str],
31+
health_endpoint: str = "/metrics",
32+
health_interval: float = 5.0,
33+
circuit_breaker_threshold: int = 5,
34+
circuit_breaker_timeout: int = 30
35+
):
36+
self.workers = [w for w in workers if w.startswith("http")]
37+
self.health_endpoint = health_endpoint
38+
self.health_interval = health_interval
39+
40+
# Worker profiles with real-time metrics
41+
self.worker_metrics: Dict[str, WorkerHealthMetrics] = {}
42+
self._metrics_lock = threading.Lock()
43+
44+
# Circuit breaker state
45+
self.circuit_breakers: Dict[str, CircuitBreaker] = {
46+
w: CircuitBreaker(
47+
failure_threshold=circuit_breaker_threshold,
48+
timeout=circuit_breaker_timeout
49+
) for w in workers
50+
}
51+
52+
# Last health check time
53+
self._last_health_check: Dict[str, float] = {w: time.time() for w in workers}
54+
55+
def update_worker_metrics(self):
56+
"""Poll all workers for real-time metrics."""
57+
for worker in self.workers:
58+
try:
59+
resp = requests.get(f"{worker}{self.health_endpoint}", timeout=5)
60+
if resp.status_code == 200:
61+
metrics_data = resp.json()
62+
worker_url = worker.replace("/metrics", "")
63+
64+
# Parse Prometheus metrics
65+
gpu_util = self._parse_metric(metrics_data, "gpu_utilization", 0.0)
66+
mem_free_gb = self._parse_metric(metrics_data, "memory_free_gb", 0.0)
67+
68+
self.worker_metrics[worker_url] = WorkerHealthMetrics(
69+
gpu_utilization=gpu_util,
70+
memory_free_gb=mem_free_gb,
71+
cpu_utilization=self._parse_metric(metrics_data, "cpu_utilization", 0.0),
72+
inference_queue_depth=self._parse_metric(metrics_data, "inference_queue", 0),
73+
p50_latency_ms=self._parse_metric(metrics_data, "p50_latency_ms", 0.0),
74+
p99_latency_ms=self._parse_metric(metrics_data, "p99_latency_ms", 0.0),
75+
)
76+
77+
self._last_health_check[worker] = time.time()
78+
except Exception as e:
79+
# Mark worker unhealthy
80+
if worker_url not in self.worker_metrics:
81+
self.worker_metrics[worker_url] = WorkerHealthMetrics(is_healthy=False, last_error=str(e))
82+
83+
def _parse_metric(self, metrics_dict: dict, metric_name: str, default) -> float:
84+
"""Parse Prometheus-style metric from JSON."""
85+
key = f"{metric_name}_sum" if "_sum" not in metric_name else metric_name
86+
count_key = f"{metric_name}_count" if "_count" not in metric_name else metric_name
87+
88+
if count_key in metrics_dict and metrics_dict[count_key] > 0:
89+
return metrics_dict[key] / metrics_dict[count_key]
90+
return default
91+
92+
def select_best_worker(
93+
self,
94+
slice_metadata: SliceMetadata,
95+
target_latency_ms: Optional[float] = None
96+
) -> Optional[str]:
97+
"""Select best worker using cost model with real-time metrics."""
98+
99+
# Update all worker metrics if stale
100+
now = time.time()
101+
for worker in self.workers:
102+
if now - self._last_health_check[worker] > self.health_interval * 2:
103+
self.update_worker_metrics()
104+
105+
# Filter healthy workers with available memory
106+
candidates = [
107+
w for w, m in self.worker_metrics.items()
108+
if m.is_healthy and m.memory_free_gb >= slice_metadata.activation_size_bytes / (1024**3)
109+
]
110+
111+
if not candidates:
112+
return None
113+
114+
# Sort by composite cost score
115+
scored_workers = []
116+
for worker_url, metrics in self.worker_metrics.items():
117+
if not metrics.is_healthy:
118+
continue
119+
120+
# Check circuit breaker
121+
if self.circuit_breakers[worker_url].is_open():
122+
continue
123+
124+
# Compute cost score (lower is better)
125+
gpu_penalty = metrics.gpu_utilization * 10 # Penalize high GPU util
126+
latency_penalty = metrics.p99_latency_ms / 100 if target_latency_ms else 0
127+
128+
cost_score = (
129+
metrics.gpu_utilization + # Normalized GPU util
130+
latency_penalty + # Latency penalty
131+
metrics.cpu_utilization * 2 # CPU pressure
132+
)
133+
134+
scored_workers.append((worker_url, cost_score))
135+
136+
if not scored_workers:
137+
return None
138+
139+
# Select worker with lowest cost score
140+
scored_workers.sort(key=lambda x: x[1])
141+
best_worker = scored_workers[0][0]
142+
143+
# Record placement decision for telemetry
144+
self.record_placement_decision(slice_metadata.slice_id, best_worker)
145+
146+
return best_worker
147+
148+
def record_placement_decision(self, slice_id: str, worker_url: str):
149+
"""Record placement for telemetry/observability."""
150+
# Emit to Prometheus/OpenTelemetry
151+
pass
152+
153+
def record_failure(self, worker_url: str):
154+
"""Record request failure to circuit breaker."""
155+
self.circuit_breakers[worker_url].record_failure()

0 commit comments

Comments
 (0)