StartupMap-India / backend /ml /serving.py
Ram2005's picture
feat: Add ML model serving layer (TorchServe/Triton compatible)
b558108 verified
Raw
History Blame Contribute Delete
10.5 kB
"""
Bharat Tech Atlas — Model Serving Layer
Production-grade model serving with batching, caching, and health monitoring.
Supports:
- Direct Python inference (development)
- ONNX Runtime (optimized CPU inference)
- TorchServe integration (scalable GPU serving)
- NVIDIA Triton integration (multi-model, multi-framework)
Architecture:
Request → Rate Limiter → Model Router → Inference Engine → Response Cache → Response
"""
import logging
import time
import asyncio
from typing import Dict, List, Optional, Any
from dataclasses import dataclass, field
from collections import OrderedDict
from datetime import datetime
logger = logging.getLogger(__name__)
@dataclass
class InferenceRequest:
"""Standardized inference request."""
request_id: str
model_name: str
inputs: Dict[str, Any]
timestamp: float = field(default_factory=time.time)
@dataclass
class InferenceResponse:
"""Standardized inference response."""
request_id: str
model_name: str
outputs: Dict[str, Any]
latency_ms: float
cached: bool = False
class LRUCache:
"""Simple LRU cache for inference results."""
def __init__(self, max_size: int = 1000):
self.cache = OrderedDict()
self.max_size = max_size
self.hits = 0
self.misses = 0
def get(self, key: str) -> Optional[Any]:
if key in self.cache:
self.cache.move_to_end(key)
self.hits += 1
return self.cache[key]
self.misses += 1
return None
def put(self, key: str, value: Any):
if key in self.cache:
self.cache.move_to_end(key)
self.cache[key] = value
if len(self.cache) > self.max_size:
self.cache.popitem(last=False)
@property
def hit_rate(self) -> float:
total = self.hits + self.misses
return self.hits / total if total > 0 else 0.0
class ModelServer:
"""
Production model serving infrastructure.
Handles:
- Model lifecycle (load, warm-up, inference, unload)
- Request batching for GPU throughput
- Response caching (LRU with TTL)
- Health monitoring and metrics
- Graceful degradation on model failure
Production deployment options:
1. Standalone FastAPI (current, good for <100 RPS)
2. TorchServe (PyTorch models, auto-scaling, GPU batching)
3. NVIDIA Triton (multi-model, dynamic batching, ensemble)
4. HuggingFace Inference Endpoints (managed, zero-config)
"""
def __init__(self, config: Optional[Dict] = None):
self.config = config or {}
self._models: Dict[str, Any] = {}
self._cache = LRUCache(max_size=self.config.get("cache_size", 1000))
self._metrics = {
"total_requests": 0,
"total_errors": 0,
"avg_latency_ms": 0.0,
"models_loaded": 0,
}
self._request_queue: asyncio.Queue = asyncio.Queue()
self._batch_size = self.config.get("batch_size", 8)
self._max_wait_ms = self.config.get("max_batch_wait_ms", 50)
async def initialize(self):
"""Initialize model server — load all configured models."""
logger.info("Initializing Model Server...")
# Load sector classifier
from .classifier import StartupSectorClassifier
classifier = StartupSectorClassifier(
model_name=self.config.get("classifier_model", "facebook/bart-large-mnli"),
use_onnx=self.config.get("use_onnx", False),
)
classifier.load_model()
self._models["sector_classifier"] = classifier
# Load growth predictor
from .predictor import GrowthPredictor
predictor = GrowthPredictor(
model_path=self.config.get("predictor_model_path")
)
predictor.load_model()
self._models["growth_predictor"] = predictor
self._metrics["models_loaded"] = len(self._models)
logger.info(f"Model Server ready: {len(self._models)} models loaded")
async def predict(self, request: InferenceRequest) -> InferenceResponse:
"""
Handle a single inference request.
Checks cache first, then routes to appropriate model.
"""
self._metrics["total_requests"] += 1
start_time = time.time()
# Check cache
cache_key = f"{request.model_name}:{hash(str(request.inputs))}"
cached_result = self._cache.get(cache_key)
if cached_result:
return InferenceResponse(
request_id=request.request_id,
model_name=request.model_name,
outputs=cached_result,
latency_ms=round((time.time() - start_time) * 1000, 2),
cached=True,
)
# Route to model
try:
model = self._models.get(request.model_name)
if not model:
raise ValueError(f"Model not found: {request.model_name}")
outputs = self._run_inference(model, request)
# Cache result
self._cache.put(cache_key, outputs)
latency_ms = round((time.time() - start_time) * 1000, 2)
self._update_latency(latency_ms)
return InferenceResponse(
request_id=request.request_id,
model_name=request.model_name,
outputs=outputs,
latency_ms=latency_ms,
)
except Exception as e:
self._metrics["total_errors"] += 1
logger.error(f"Inference failed for {request.model_name}: {e}")
return InferenceResponse(
request_id=request.request_id,
model_name=request.model_name,
outputs={"error": str(e)},
latency_ms=round((time.time() - start_time) * 1000, 2),
)
def _run_inference(self, model: Any, request: InferenceRequest) -> Dict:
"""Execute inference on the model."""
if request.model_name == "sector_classifier":
description = request.inputs.get("description", "")
result = model.classify(description)
return {
"sector": result.sector,
"confidence": result.confidence,
"top_sectors": result.top_sectors,
"model_version": result.model_version,
}
elif request.model_name == "growth_predictor":
entity = request.inputs.get("entity", {})
result = model.predict(entity)
return {
"growth_score": result.growth_score,
"growth_label": result.growth_label,
"factors": result.factors,
"confidence": result.confidence,
}
else:
raise ValueError(f"Unknown model: {request.model_name}")
def _update_latency(self, new_latency: float):
"""Update rolling average latency."""
total = self._metrics["total_requests"]
current_avg = self._metrics["avg_latency_ms"]
self._metrics["avg_latency_ms"] = round(
(current_avg * (total - 1) + new_latency) / total, 2
)
def get_health(self) -> Dict:
"""Get model server health status."""
return {
"status": "healthy" if self._models else "degraded",
"models_loaded": list(self._models.keys()),
"metrics": self._metrics,
"cache_hit_rate": round(self._cache.hit_rate, 3),
"timestamp": datetime.utcnow().isoformat(),
}
async def shutdown(self):
"""Graceful shutdown — flush caches, unload models."""
logger.info("Shutting down Model Server...")
self._models.clear()
logger.info("Model Server shut down")
class TorchServeAdapter:
"""
Adapter for deploying models via TorchServe.
TorchServe provides:
- Dynamic batching (groups requests for GPU efficiency)
- Model versioning (A/B testing)
- Auto-scaling (workers scale with load)
- RESTful management API
Deployment:
torch-model-archiver --model-name sector_classifier \\
--version 1.0 \\
--model-file model.py \\
--serialized-file model.pt \\
--handler handler.py
torchserve --start --model-store model_store \\
--models sector_classifier=sector_classifier.mar
"""
def __init__(self, endpoint: str = "http://localhost:8080"):
self.endpoint = endpoint
async def predict(self, model_name: str, data: Dict) -> Dict:
"""Send prediction request to TorchServe."""
# Production:
# async with aiohttp.ClientSession() as session:
# url = f"{self.endpoint}/predictions/{model_name}"
# async with session.post(url, json=data) as resp:
# return await resp.json()
logger.info(f"TorchServe prediction: {model_name}")
return {}
async def get_models(self) -> List[Dict]:
"""List registered models on TorchServe."""
# GET {endpoint}/models
return []
class TritonAdapter:
"""
Adapter for NVIDIA Triton Inference Server.
Triton provides:
- Multi-framework support (PyTorch, TensorFlow, ONNX, TensorRT)
- Dynamic batching across multiple models
- Model ensemble pipelines
- GPU memory management
- Prometheus metrics
Config (config.pbtxt):
name: "sector_classifier"
platform: "onnxruntime_onnx"
max_batch_size: 32
input [{ name: "input_ids" data_type: TYPE_INT64 dims: [-1] }]
output [{ name: "logits" data_type: TYPE_FP32 dims: [-1] }]
dynamic_batching { preferred_batch_size: [8, 16] max_queue_delay_microseconds: 50000 }
"""
def __init__(self, url: str = "localhost:8001"):
self.url = url
async def predict(self, model_name: str, inputs: Dict) -> Dict:
"""Send gRPC inference request to Triton."""
# Production:
# import tritonclient.grpc as grpcclient
# client = grpcclient.InferenceServerClient(url=self.url)
# input_tensor = grpcclient.InferInput("input_ids", shape, "INT64")
# input_tensor.set_data_from_numpy(input_data)
# result = client.infer(model_name, [input_tensor])
logger.info(f"Triton prediction: {model_name}")
return {}
async def health_check(self) -> bool:
"""Check if Triton server is healthy."""
# client.is_server_ready()
return True