LLM Serving & Inference: Complete Learning Repository
A comprehensive, hands-on guide to learning LLM serving and inference from fundamentals to production deployment.
🎯 What You’ll Learn
This repository teaches you everything about serving Large Language Models in production:
- How LLM inference works (tokenization, attention, generation)
- Basic serving (FastAPI, model loading, endpoints)
- Containerization (Docker for ML workloads)
- Kubernetes deployment (production infrastructure)
- Performance optimization (vLLM, batching, GPU utilization)
- Scaling (autoscaling, load balancing)
- Production practices (monitoring, versioning, drift detection)
📁 Repository Structure
mlops_serving/
├── LEARNING_PATH.md # Start here! Complete learning guide
├── docs/ # Detailed concept explanations
│ ├── llm_inference_fundamentals.md
│ ├── serving_architectures.md
│ └── optimization_techniques.md
├── 01_basic_serving/ # FastAPI + HuggingFace serving
├── 02_docker/ # Containerization
├── 03_kubernetes/ # K8s deployment
├── 04_load_testing/ # Performance testing
├── 05_vllm_serving/ # High-performance serving
├── 06_autoscaling/ # K8s autoscaling
├── 07_canary_deployments/ # Gradual rollouts
├── 08_monitoring/ # Prometheus + Grafana
├── 09_model_versioning/ # Version management
├── 10_drift_detection/ # Evidently integration
└── 11_triton/ # Multi-model serving
🚀 Quick Start
👉 START HERE: Read HOW_TO_START.md for a complete step-by-step guide!
Quick Commands
# 1. Set up environment
cd 01_basic_serving
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
pip install -r requirements.txt
# 2. Start the server
python app.py
# 3. Test it (in another terminal)
python test_api.py
# OR visit http://localhost:8000/docs for interactive API docs
📚 Learning Approach
This is a hands-on, topic-based learning path:
- Each topic is self-contained with code, docs, and exercises
- Build incrementally - each topic builds on previous concepts
- Learn by doing - run code, modify it, break it, fix it
- Focus on understanding - not just copying code
🎓 Prerequisites
- Python 3.9+
- Basic Python knowledge
- Understanding of REST APIs
- Docker (for containerization topics)
- Kubernetes cluster (minikube/kind for K8s topics)
- GPU (optional, helpful for vLLM)
📖 Topics Covered
Core Concepts
- LLM architecture and inference pipeline
- Tokenization and vocabulary
- Attention mechanisms and KV caching
- Memory and computation requirements
Serving Basics
- Model loading and initialization
- API endpoint design
- Request/response handling
- Error handling and validation
Infrastructure
- Docker containerization
- Kubernetes deployment
- Health checks and probes
- Resource management
Performance
- Load testing and benchmarking
- Latency vs throughput
- vLLM optimization
- GPU utilization
Production
- Autoscaling strategies
- Canary deployments
- Monitoring and observability
- Model versioning
- Drift detection
🔧 Technologies
- FastAPI: Web framework
- HuggingFace Transformers: Model loading
- vLLM: High-performance inference
- Docker: Containerization
- Kubernetes: Orchestration
- Locust: Load testing
- Prometheus: Metrics
- Grafana: Visualization
- Evidently: Drift detection
- Triton: Multi-model serving
📝 How to Use This Repository
- Start with LEARNING_PATH.md - Understand the structure
- Read the fundamentals - docs/llm_inference_fundamentals.md
- Work through topics in order - Each numbered topic builds on the last
- Experiment and modify - Don’t just run code, change it and learn
- Read the code comments - They explain the “why” not just the “what”
- Check solutions - See EXERCISE_SOLUTIONS.md for exercise answers
- Prepare for interviews - Review INTERVIEW_QA.md for common questions
🎯 Learning Goals
By completing this repository, you’ll be able to:
- Understand how LLM inference works internally
- Build production-ready serving APIs
- Deploy models to Kubernetes
- Optimize for performance and cost
- Monitor and debug serving systems
- Handle model updates and drift
🤝 Contributing
This is a learning repository. Feel free to:
- Add more examples
- Improve documentation
- Fix bugs
- Share your learnings
📄 License
MIT License - Feel free to use this for learning and teaching.
📖 Getting Started
- New to this? → Read
HOW_TO_START.mdfor step-by-step instructions - Want overview? → Read
LEARNING_PATH.mdto see all topics - Ready to code? → Start with
01_basic_serving/ - Need help? → Check
EXERCISE_SOLUTIONS.mdfor exercise answers - Interview prep? → Review
INTERVIEW_QA.mdfor common questions
Ready to start? Open HOW_TO_START.md and begin your journey! 🚀
📚 Additional Resources
- Exercise Solutions:
EXERCISE_SOLUTIONS.md- Answers to all exercises - Interview Q&A:
INTERVIEW_QA.md- 23+ interview questions with detailed answers - Quick Reference:
QUICK_REFERENCE.md- Quick commands and tips - Complete Topics:
COMPLETE_TOPICS.md- Overview of all 12 topics - Industry Boilerplate: Each topic README includes industry-standard code and use cases
🏭 Industry Focus
Each topic now includes:
- Why We Need This: Business and technical justification
- Industry Use Cases: Real-world applications (5+ examples per topic)
- Boilerplate Code: Production-ready, industry-standard implementations
- Company Examples: How major companies use these technologies
Topics updated with industry content:
- ✅ Topic 1: Basic Serving (Customer support, content generation)
- ✅ Topic 2: Docker (Multi-cloud, CI/CD, edge deployment)
- ✅ Topic 3: Kubernetes (Large-scale platforms, enterprise ML)
- ✅ Topic 5: vLLM (High-throughput APIs, cost optimization)
- ✅ Topic 8: Monitoring (Production observability, incident response)
👋 Welcome! Start Here
This repository teaches you everything about LLM serving and inference through hands-on, practical examples.
🎯 What You’ll Learn
You’ll learn how to:
- Serve LLMs in production
- Optimize for performance
- Deploy to Kubernetes
- Monitor and debug systems
- Handle model updates safely
- Detect and handle drift
📚 How This Repository is Organized
Learning Structure
- Topics are numbered (01, 02, 03…) - work through them in order
- Each topic is self-contained - has its own code, docs, and examples
- Builds incrementally - each topic builds on previous concepts
Key Files
| File | Purpose |
|---|---|
HOW_TO_START.md | 👉 START HERE - Step-by-step guide to begin learning |
LEARNING_PATH.md | Overview of all topics and learning approach |
README.md | Repository overview and quick reference |
docs/ | Detailed concept explanations |
01_basic_serving/ | Your first serving example |
02_docker/ | Containerization |
03_kubernetes/ | K8s deployment |
| … | More topics as you progress |
🚀 Your First Steps
1. Read the Start Guide
cat HOW_TO_START.md
This has everything you need to begin.
2. Understand the Fundamentals
cat docs/llm_inference_fundamentals.md
Learn how LLM inference works internally.
3. Run Your First Server
cd 01_basic_serving
pip install -r requirements.txt
python app.py
4. Test It
# In another terminal
python test_api.py
📖 Learning Topics
- LLM Inference Fundamentals - How LLMs work
- Basic Serving - FastAPI + HuggingFace
- Containerization - Docker
- Kubernetes - K8s deployment
- Load Testing - Performance measurement
- vLLM Serving - High-performance serving
- Autoscaling - Scale automatically
- Canary Deployments - Safe rollouts
- Monitoring - Metrics and dashboards
- Model Versioning - Version management
- Drift Detection - Detect issues
- Triton - Multi-model serving
💡 Learning Approach
- Read the documentation
- Study the code
- Run the examples
- Modify and experiment
- Move to the next topic
✅ Prerequisites
- Python 3.9+
- Basic Python knowledge
- Understanding of REST APIs
- (Optional) Docker and Kubernetes
Don’t have Docker/K8s? No problem - you can learn the basics without them!
🎓 Ready to Start?
👉 Open HOW_TO_START.md and follow the step-by-step guide!
Questions? Check the README.md in each topic directory for detailed explanations.
Stuck? Read error messages carefully, check the docs, and experiment with simpler examples first.
Let’s learn! 🚀
How to Start Learning LLM Serving & Inference
🎯 Your Learning Journey Starts Here
This guide will walk you through exactly how to start learning LLM serving and inference, step by step.
📋 Prerequisites Check
Before you start, make sure you have:
- Python 3.9 or higher (
python --version) - pip installed
- Basic understanding of Python
- Basic understanding of REST APIs
- (Optional) Docker installed
- (Optional) Kubernetes cluster (minikube/kind)
Don’t worry if you don’t have Docker/K8s yet - you can learn the basics without them!
🚀 Step-by-Step Learning Path
Step 1: Understand the Fundamentals (30-60 minutes)
Read this first: docs/llm_inference_fundamentals.md
This explains:
- How LLMs work internally
- What happens during inference
- Tokenization, attention, generation
- Memory and computation requirements
Why this matters: You need to understand what’s happening under the hood before you can serve models effectively.
Action: Open the file and read through it. Don’t worry if you don’t understand everything - you’ll learn more as you build.
Step 2: Set Up Your Environment (10 minutes)
# Navigate to the project
cd /Users/faisal/Projects/mlops_serving
# Create a virtual environment (recommended)
python -m venv venv
# Activate it
source venv/bin/activate # On Mac/Linux
# OR
venv\Scripts\activate # On Windows
# Install dependencies for basic serving
cd 01_basic_serving
pip install -r requirements.txt
What this does: Sets up an isolated Python environment with all the libraries you need.
Step 3: Run Your First LLM Server (5 minutes)
# Make sure you're in 01_basic_serving directory
cd 01_basic_serving
# Start the server
python app.py
You should see:
INFO: Started server process
INFO: Waiting for application startup.
🚀 Starting up: Loading LLM model...
Loading model: gpt2 on device: cpu
✅ Model loaded successfully!
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:8000
What’s happening:
- The server is loading a GPT-2 model (small, fast model for learning)
- It’s starting a FastAPI web server
- The model is now ready to serve requests
Step 4: Test the API (5 minutes)
Option A: Use the test script
# In a new terminal (keep server running)
cd 01_basic_serving
python test_api.py
Option B: Use curl
# Health check
curl http://localhost:8000/health
# Generate text
curl -X POST http://localhost:8000/generate \
-H "Content-Type: application/json" \
-d '{
"prompt": "The future of AI is",
"max_length": 50,
"temperature": 0.7
}'
Option C: Use the interactive docs Open http://localhost:8000/docs in your browser - FastAPI provides an interactive API explorer!
Step 5: Understand What Just Happened (15 minutes)
Read: 01_basic_serving/README.md
This explains:
- What each file does
- How the model loading works
- How the API endpoints work
- Key concepts you just used
Then explore the code:
model_loader.py- How models are loaded and usedapp.py- How the API is structured
Try modifying:
- Change the model name (try “distilgpt2” for a smaller model)
- Change temperature (0.1 = more deterministic, 2.0 = more creative)
- Add logging to see what’s happening
Step 6: Learn Each Topic in Order
Now that you’ve run your first server, work through each topic:
- 01_basic_serving ✅ (You just did this!)
- 02_docker - Containerize your app
- 03_kubernetes - Deploy to K8s
- 04_load_testing - Test performance
- 05_vllm_serving - High-performance serving
- 06_autoscaling - Scale automatically
- 07_canary_deployments - Safe rollouts
- 08_monitoring - Track metrics
- 09_model_versioning - Manage versions
- 10_drift_detection - Detect issues
- 11_triton - Multi-model serving
For each topic:
- Read the README.md
- Study the code
- Run the examples
- Modify and experiment
- Move to the next topic
🎓 Learning Tips
1. Don’t Rush
Understanding is more important than speed. Take time to:
- Read error messages carefully
- Experiment with parameters
- Break things and fix them
2. Experiment
After running each example:
- Change parameters
- Modify the code
- See what breaks
- Understand why
3. Use the Documentation
Each topic has:
- README.md explaining concepts
- Code comments explaining “why”
- Examples you can run
4. Ask Questions
As you learn, ask yourself:
- “Why does this work this way?”
- “What happens if I change X?”
- “How does this scale?”
- “What could go wrong?”
🐛 Common Issues & Solutions
Issue: “Model not found” or download errors
Solution: Check your internet connection. Models are downloaded from HuggingFace on first use.
Issue: “Out of memory”
Solution:
- Use a smaller model (distilgpt2 instead of gpt2)
- Reduce max_length
- Close other applications
Issue: “Port already in use”
Solution:
- Stop the previous server (Ctrl+C)
- Or change the port in app.py
Issue: “Import errors”
Solution:
- Make sure virtual environment is activated
- Run
pip install -r requirements.txtagain
📊 What You’ll Learn
By the end of this journey, you’ll understand:
Core Concepts
- ✅ How LLM inference works internally
- ✅ Tokenization and vocabulary
- ✅ Attention mechanisms
- ✅ Memory and computation requirements
Serving Skills
- ✅ Building serving APIs
- ✅ Containerization
- ✅ Kubernetes deployment
- ✅ Performance optimization
Production Skills
- ✅ Monitoring and observability
- ✅ Scaling strategies
- ✅ Safe deployments
- ✅ Drift detection
🎯 Next Steps
- Right now: Complete Steps 1-5 above
- Today: Read through
01_basic_serving/README.mdand understand the code - This week: Work through topics 2-4 (Docker, K8s, Load Testing)
- This month: Complete topics 5-8 (vLLM, Scaling, Monitoring)
- Ongoing: Topics 9-11 (Advanced production topics)
❓ Questions?
If you get stuck:
- Check the README.md in each topic
- Read error messages carefully
- Check the docs/ directory for detailed explanations
- Experiment with simpler examples first
Remember: Learning by doing is the best way. Don’t just read - run the code, modify it, break it, fix it!
Ready? Let’s start! 🚀
Begin with Step 1: Read docs/llm_inference_fundamentals.md
LLM Serving & Inference: Complete Learning Guide
🎯 How to Use This Guide
This guide is organized by learning topics, not time periods. Work through each topic at your own pace. Each topic builds on the previous one, but you can also jump to specific areas you want to learn.
📚 Learning Topics (In Order)
1. LLM Inference Fundamentals
What you’ll learn: How LLMs actually work under the hood
- Tokenization and vocabulary
- Forward pass through transformer layers
- Autoregressive generation
- Attention mechanism and KV caching
- Memory and computation requirements
Start here: docs/llm_inference_fundamentals.md
2. Basic Model Serving
What you’ll learn: Serve a model with a simple API
- Loading models from HuggingFace
- Building FastAPI endpoints
- Request/response handling
- Basic error handling
Practice: 01_basic_serving/
3. Containerization
What you’ll learn: Package your serving application
- Docker basics for ML models
- Multi-stage builds
- Handling large model files
- Environment configuration
Practice: 02_docker/
4. Kubernetes Deployment
What you’ll learn: Deploy to production infrastructure
- K8s manifests for ML workloads
- Health checks and probes
- Resource limits and requests
- ConfigMaps and Secrets
Practice: 03_kubernetes/
5. Load Testing & Performance
What you’ll learn: Measure and understand performance
- Latency vs throughput
- Load testing with Locust
- Performance profiling
- Identifying bottlenecks
Practice: 04_load_testing/
6. High-Performance Serving (vLLM)
What you’ll learn: Optimize for production throughput
- vLLM architecture
- Continuous batching
- PagedAttention
- GPU optimization
Practice: 05_vllm_serving/
7. Autoscaling
What you’ll learn: Scale based on demand
- Horizontal Pod Autoscaling (HPA)
- Request-based scaling
- Concurrency metrics
- Scaling strategies
Practice: 06_autoscaling/
8. Monitoring & Observability
What you’ll learn: Track your serving system
- Prometheus metrics
- Grafana dashboards
- GPU monitoring
- Logging and tracing
Practice: 08_monitoring/
9. Canary Deployments
What you’ll learn: Safely roll out model updates
- Traffic splitting
- A/B testing models
- Gradual rollouts
- Rollback procedures
Practice: 09_canary_deployments/
10. Model Versioning
What you’ll learn: Manage multiple model versions
- Version management strategies
- Model registry
- Rollback procedures
- A/B testing infrastructure
Practice: 10_model_versioning/
11. Drift Detection
What you’ll learn: Detect when models degrade
- Data drift detection
- Concept drift
- Evidently AI integration
- Alerting on anomalies
Practice: 11_drift_detection/
12. Multi-Model Serving (Triton)
What you’ll learn: Serve multiple models efficiently
- NVIDIA Triton Inference Server
- Dynamic batching
- Model ensembles
- Multi-framework support
Practice: 12_triton/
🚀 Quick Start Guide
Step 1: Understand the Basics
Read docs/llm_inference_fundamentals.md to understand how LLM inference works.
Step 2: Set Up Your Environment
# Create virtual environment
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install base dependencies
pip install -r requirements.txt
Step 3: Start with Basic Serving
cd 01_basic_serving
python app.py
# Test it: curl http://localhost:8000/health
Step 4: Progress Through Topics
Work through each numbered topic in order. Each includes:
- README.md: Explanation of concepts
- Code examples: Working implementations
- Exercises: Hands-on practice
🎓 Learning Approach
For Each Topic:
- Read the documentation - Understand the concepts
- Study the code - See how it’s implemented
- Run the examples - Get hands-on experience
- Modify and experiment - Break things, fix them, learn
- Move to next topic - Build on what you learned
Tips:
- Don’t rush: Understanding > Speed
- Experiment: Change parameters, break things, learn why
- Read error messages: They teach you a lot
- Use the docs: Each topic has detailed explanations
📖 Prerequisites
Required:
- Python 3.9+
- Basic Python knowledge
- Understanding of REST APIs
- Docker basics
Helpful but not required:
- Kubernetes experience
- ML/AI background
- GPU access (CPU works for learning)
🔧 Technology Stack
You’ll learn these tools:
- FastAPI: Web framework
- HuggingFace Transformers: Model loading
- vLLM: High-performance inference
- Docker: Containerization
- Kubernetes: Orchestration
- Locust: Load testing
- Prometheus/Grafana: Monitoring
- Evidently: Drift detection
- Triton: Multi-model serving
❓ Common Questions
Q: Do I need a GPU? A: Not for the basics. GPU helps with vLLM and production workloads, but you can learn on CPU.
Q: How long will this take? A: Depends on your pace. Each topic can take a few hours to a few days. Focus on understanding, not speed.
Q: Can I skip topics? A: The basics (1-5) should be done in order. Advanced topics (6-12) can be done based on interest.
Q: What if I get stuck? A: Check the docs, read error messages carefully, experiment with simpler examples first.
🎯 Learning Goals
By the end, you’ll be able to:
- ✅ Serve LLMs in production
- ✅ Optimize for performance
- ✅ Deploy to Kubernetes
- ✅ Monitor and debug serving systems
- ✅ Handle model updates safely
- ✅ Detect and handle model drift
Let’s start learning! 🚀
Quick Reference: vLLM, Latency, and Grafana
🚀 Quick Start Guide
1. vLLM Serving
Install vLLM
# Requires CUDA/GPU
pip install vllm
Start vLLM Server
cd 05_vllm_serving
pip install -r requirements.txt
python vllm_server.py
Or use vLLM’s built-in server
python -m vllm.entrypoints.openai.api_server \
--model gpt2 \
--port 8000
Test vLLM API
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:8000/v1",
api_key="dummy"
)
response = client.chat.completions.create(
model="gpt2",
messages=[{"role": "user", "content": "Hello!"}],
max_tokens=50
)
print(response.choices[0].message.content)
2. Measure Latency
Option A: Using Locust (Interactive)
cd 04_load_testing
pip install -r requirements.txt
# Start your server first
# Then run Locust
locust -f locust_test.py --host=http://localhost:8000
# Open http://localhost:8089 in browser
Option B: Using Python Script (Headless)
cd 04_load_testing
python measure_latency.py --url http://localhost:8000 --requests 100
What You Get
- P50 (Median): 50% of requests faster
- P95: 95% of requests faster
- P99: 99% of requests faster
- Throughput: Requests per second
Compare Endpoints
python measure_latency.py --compare
# Compares basic serving vs vLLM
3. Grafana Monitoring
Start Monitoring Stack
cd 08_monitoring
docker-compose up -d
This starts:
- Prometheus: http://localhost:9090
- Grafana: http://localhost:3000 (admin/admin)
Add Metrics to Your App
from prometheus_exporter import add_prometheus_middleware
app = FastAPI()
add_prometheus_middleware(app) # That's it!
Your app now exposes /metrics endpoint.
Configure Prometheus
Edit prometheus.yml to scrape your app:
scrape_configs:
- job_name: 'llm-serving'
static_configs:
- targets: ['localhost:8000']
Create Grafana Dashboard
- Open Grafana: http://localhost:3000
- Login: admin/admin
- Add data source: Prometheus (http://prometheus:9090)
- Create dashboard with these queries:
Request Rate:
rate(llm_requests_total[5m])
Latency (P95):
histogram_quantile(0.95, rate(llm_request_duration_seconds_bucket[5m]))
Error Rate:
rate(llm_requests_total{status="500"}[5m]) / rate(llm_requests_total[5m])
GPU Utilization:
llm_gpu_utilization_percent
📊 Complete Workflow Example
Step 1: Start vLLM Server
cd 05_vllm_serving
python vllm_server.py
# Server running on http://localhost:8000
Step 2: Measure Latency
cd 04_load_testing
python measure_latency.py --url http://localhost:8000 --requests 100
Output:
P50: 150ms
P95: 450ms
P99: 800ms
Throughput: 25.3 req/s
Step 3: Start Monitoring
cd 08_monitoring
docker-compose up -d
Step 4: View Metrics
- Prometheus: http://localhost:9090
- Grafana: http://localhost:3000
Step 5: Load Test with Monitoring
# Terminal 1: Watch Grafana
open http://localhost:3000
# Terminal 2: Run load test
cd 04_load_testing
locust -f locust_test.py --host=http://localhost:8000 --headless --users 10 --spawn-rate 2
🔍 Key Metrics to Monitor
Application Metrics
llm_requests_total- Total requestsllm_request_duration_seconds- Request latencyllm_active_requests- Currently processingllm_tokens_generated_total- Tokens generated
System Metrics
llm_gpu_utilization_percent- GPU usagellm_gpu_memory_used_bytes- GPU memoryllm_gpu_temperature_celsius- GPU temperature
Important Queries
Requests per second:
rate(llm_requests_total[5m])
95th percentile latency:
histogram_quantile(0.95, rate(llm_request_duration_seconds_bucket[5m]))
Error percentage:
rate(llm_requests_total{status="500"}[5m]) / rate(llm_requests_total[5m]) * 100
GPU memory usage:
llm_gpu_memory_used_bytes / llm_gpu_memory_total_bytes * 100
🎯 Performance Targets
Good Performance
- P50 latency: < 200ms
- P95 latency: < 500ms
- P99 latency: < 1s
- Error rate: < 0.1%
- GPU utilization: 70-90%
Warning Signs
- P99 > 2s: System struggling
- Error rate > 1%: Problems occurring
- GPU utilization < 50%: Underutilized
- GPU utilization > 95%: Overloaded
🐛 Troubleshooting
vLLM won’t start
- Check CUDA:
python -c "import torch; print(torch.cuda.is_available())" - Install correct CUDA version
- Use smaller model for testing
High latency
- Check GPU utilization
- Reduce batch size
- Use smaller model
- Check for bottlenecks (CPU, memory, I/O)
No metrics in Grafana
- Check Prometheus is scraping: http://localhost:9090/targets
- Verify
/metricsendpoint works:curl http://localhost:8000/metrics - Check Prometheus config points to correct host
GPU metrics missing
- Install pynvml:
pip install pynvml - Check GPU is accessible
- Verify NVIDIA drivers installed
📚 Learn More
- vLLM:
05_vllm_serving/README.md - Latency:
04_load_testing/README.md - Monitoring:
08_monitoring/README.md
Quick tip: Start with basic serving, measure latency, then optimize with vLLM, and monitor with Grafana!
Complete Learning Topics Overview
All 12 learning topics are now complete! Here’s what you have:
✅ Completed Topics
1. LLM Inference Fundamentals ✅
- Location:
docs/llm_inference_fundamentals.md - What it covers: Tokenization, attention, generation, memory, computation
- Status: Complete with detailed explanations
2. Basic Model Serving ✅
- Location:
01_basic_serving/ - What it covers: FastAPI, HuggingFace, model loading, endpoints
- Status: Complete with working code
3. Containerization ✅
- Location:
02_docker/ - What it covers: Docker basics, multi-stage builds, GPU support
- Status: Complete with Dockerfiles and examples
4. Kubernetes Deployment ✅
- Location:
03_kubernetes/ - What it covers: K8s manifests, health checks, resources, ConfigMaps
- Status: Complete with deployment YAMLs
5. Load Testing & Performance ✅
- Location:
04_load_testing/ - What it covers: Latency measurement, Locust, performance profiling
- Status: Complete with test scripts
6. High-Performance Serving (vLLM) ✅
- Location:
05_vllm_serving/ - What it covers: vLLM setup, continuous batching, PagedAttention
- Status: Complete with server implementation
7. Autoscaling ✅
- Location:
06_autoscaling/ - What it covers: HPA, CPU/memory scaling, custom metrics
- Status: Complete with HPA configurations
8. Monitoring & Observability ✅
- Location:
08_monitoring/ - What it covers: Prometheus, Grafana, GPU monitoring, metrics
- Status: Complete with full monitoring stack
9. Canary Deployments ✅
- Location:
09_canary_deployments/ - What it covers: Traffic splitting, gradual rollouts, rollback
- Status: Complete with deployment examples
10. Model Versioning ✅
- Location:
10_model_versioning/ - What it covers: Version management, model registry, rollback
- Status: Complete with strategies and examples
11. Drift Detection ✅
- Location:
11_drift_detection/ - What it covers: Data drift, concept drift, Evidently AI
- Status: Complete with detector implementation
12. Multi-Model Serving (Triton) ✅
- Location:
12_triton/ - What it covers: Triton server, dynamic batching, model ensembles
- Status: Complete with configuration examples
📚 Learning Path
Follow this order for best learning experience:
- Start: Read
HOW_TO_START.md - Fundamentals: Read
docs/llm_inference_fundamentals.md - Basic Serving: Work through
01_basic_serving/ - Containerize: Learn Docker in
02_docker/ - Deploy: Deploy to K8s with
03_kubernetes/ - Test: Measure performance with
04_load_testing/ - Optimize: Use vLLM in
05_vllm_serving/ - Scale: Set up autoscaling in
06_autoscaling/ - Monitor: Add monitoring in
08_monitoring/ - Deploy Safely: Learn canary in
09_canary_deployments/ - Version: Manage versions in
10_model_versioning/ - Detect Issues: Add drift detection in
11_drift_detection/ - Advanced: Use Triton in
12_triton/
🎯 Quick Reference
- Quick Start:
HOW_TO_START.md - Learning Path:
LEARNING_PATH.md - Quick Commands:
QUICK_REFERENCE.md - Overview:
README.md
📖 Each Topic Includes
- README.md: Detailed explanations and concepts
- Code examples: Working implementations
- Configuration files: YAMLs, Dockerfiles, etc.
- Exercises: Hands-on practice suggestions
🚀 Ready to Learn!
Everything is set up and ready. Start with HOW_TO_START.md and work through each topic at your own pace.
Happy learning! 🎓
Industry Boilerplate Code & Use Cases
This document provides industry-standard boilerplate code and real-world use cases for each topic.
Quick Reference
- Topic 1: Basic Serving → Customer support, content generation
- Topic 2: Docker → Multi-cloud, CI/CD, edge deployment
- Topic 3: Kubernetes → Large-scale platforms, enterprise ML
- Topic 4: Load Testing → Performance validation, capacity planning
- Topic 5: vLLM → High-throughput production serving
- Topic 6: Autoscaling → Cost optimization, traffic handling
- Topic 7: Canary → Safe deployments, A/B testing
- Topic 8: Monitoring → Production observability, alerting
- Topic 9: Canary (same as 7)
- Topic 10: Versioning → Model management, rollback
- Topic 11: Drift Detection → Model health, quality assurance
- Topic 12: Triton → Multi-model serving, model pipelines
Common Industry Patterns
Pattern 1: API Gateway → Serving Layer
[API Gateway] → [Load Balancer] → [K8s Service] → [LLM Pods]
Used by: OpenAI, Anthropic, HuggingFace
Pattern 2: Model Registry → Serving
[Model Registry] → [CI/CD] → [K8s Deployment] → [Serving Pods]
Used by: MLflow, Weights & Biases, custom platforms
Pattern 3: Monitoring → Alerting → Auto-remediation
[Prometheus] → [Grafana] → [Alertmanager] → [PagerDuty/Slack]
Used by: All production ML systems
See individual topic READMEs for detailed boilerplate code and use cases.
LLM Serving & Inference Interview Q&A
Comprehensive interview questions and answers for LLM serving, inference, and MLOps roles.
Table of Contents
- LLM Inference Fundamentals
- Model Serving
- Performance Optimization
- Kubernetes & Deployment
- Monitoring & Observability
- Production Best Practices
- System Design
LLM Inference Fundamentals
Q1: Explain how LLM inference works step-by-step.
Answer:
- Tokenization: Input text is converted to token IDs using the model’s tokenizer
- Embedding: Token IDs are converted to dense vectors (embeddings)
- Forward Pass:
- Input passes through transformer layers
- Each layer applies self-attention and feed-forward networks
- Attention mechanism computes relationships between tokens
- Output Projection: Final layer projects to vocabulary size
- Sampling: Next token is sampled from probability distribution
- Autoregressive Generation: Process repeats with new token until stop condition
Key Points:
- Inference is autoregressive (one token at a time)
- KV cache stores attention key-values to avoid recomputation
- Each token generation requires a full forward pass
Q2: What is KV caching and why is it important?
Answer: KV (Key-Value) caching stores the attention key and value matrices for previously processed tokens.
How it works:
- First token: Compute full Q, K, V for all tokens
- Subsequent tokens: Only compute Q for new token, reuse cached K, V
Benefits:
- Speed: Avoids recomputing attention for previous tokens
- Memory trade-off: Uses more memory but much faster
- Critical for performance: Without it, each token would recompute all previous tokens
Example:
- Without cache: Generate 100 tokens = 100 forward passes, each processing all 100 tokens
- With cache: Generate 100 tokens = 100 forward passes, but each only processes 1 new token
Q3: What is the difference between training and inference?
Answer:
| Aspect | Training | Inference |
|---|---|---|
| Mode | Training mode (gradients computed) | Evaluation mode (no gradients) |
| Batch | Large batches (32-128) | Small batches or single requests |
| Memory | Stores activations for backprop | Only forward pass needed |
| Speed | Slower (backprop overhead) | Faster (forward only) |
| Optimization | Gradient descent | Sampling/decoding strategies |
| Hardware | Multiple GPUs common | Single GPU often sufficient |
Key Differences:
- Training: Updates weights, needs gradients
- Inference: Uses fixed weights, generates predictions
Q4: Explain attention mechanism in the context of inference.
Answer: Attention determines which tokens to focus on when generating the next token.
Formula:
Attention(Q, K, V) = softmax(QK^T / √d_k) × V
Components:
- Q (Query): “What am I looking for?”
- K (Key): “What information do I have?”
- V (Value): “What is the actual information?”
In Inference:
- Computes relationships between current token and all previous tokens
- Allows model to “attend” to relevant context
- KV cache stores K and V to avoid recomputation
Complexity:
- O(n²) where n is sequence length
- This is why longer sequences are slower and more memory-intensive
Model Serving
Q5: How would you design an LLM serving API?
Answer:
API Design:
POST /v1/completions
{
"prompt": "The future of AI is",
"max_tokens": 100,
"temperature": 0.7,
"top_p": 0.9
}
Key Components:
- Request Validation: Validate inputs (prompt length, parameters)
- Model Loading: Load model once at startup (not per request)
- Tokenization: Convert text to tokens
- Generation: Run inference with parameters
- Response: Return generated text + metadata (latency, tokens)
Considerations:
- Async support: Handle concurrent requests
- Streaming: Support streaming responses
- Error handling: Graceful error responses
- Rate limiting: Prevent abuse
- Health checks: For Kubernetes probes
Q6: What are the differences between HuggingFace Transformers and vLLM?
Answer:
| Feature | HuggingFace | vLLM |
|---|---|---|
| Batching | Static batching | Continuous batching |
| Throughput | 1-5 req/s | 50-200 req/s |
| Memory | Standard | PagedAttention (efficient) |
| GPU Utilization | 20-40% | 80-95% |
| Ease of Use | Very easy | Moderate |
| Flexibility | High | Moderate |
When to use HuggingFace:
- Development and prototyping
- Small-scale deployments
- Need maximum flexibility
When to use vLLM:
- Production high-throughput
- Need maximum GPU utilization
- Many concurrent requests
Q7: How does continuous batching work in vLLM?
Answer: Continuous batching allows adding/removing requests dynamically during batch processing.
Traditional Batching:
- Wait for batch to fill (e.g., 8 requests)
- Process entire batch
- Wait for all to complete
- Start next batch
Continuous Batching:
- Start processing batch
- Add new requests as they arrive
- Remove completed requests
- Continue processing remaining requests
- GPU always busy
Benefits:
- Higher GPU utilization
- Lower latency (no waiting for batch to fill)
- Better throughput
Example:
Time 0: [Req1, Req2, Req3] → Processing
Time 1: [Req1, Req2, Req3, Req4] → Req4 added
Time 2: [Req2, Req3, Req4] → Req1 completed, removed
Performance Optimization
Q8: How would you optimize LLM inference latency?
Answer:
1. Model Optimization:
- Quantization: FP16, INT8, INT4 (trade accuracy for speed)
- Model pruning: Remove unnecessary weights
- Knowledge distillation: Use smaller model
2. Inference Optimization:
- KV caching: Cache attention key-values
- Batching: Process multiple requests together
- Continuous batching: vLLM’s approach
3. Hardware:
- GPU: Use GPU instead of CPU (10-100x faster)
- Tensor cores: Use specialized hardware
- Model parallelism: Split across multiple GPUs
4. System:
- Pre-warming: Load model before first request
- Connection pooling: Reuse connections
- CDN: Cache responses when appropriate
5. Architecture:
- Async processing: Don’t block on I/O
- Request queuing: Handle bursts gracefully
- Load balancing: Distribute requests
Q9: What is PagedAttention and why does it matter?
Answer: PagedAttention is vLLM’s memory management technique for KV cache.
Problem it solves:
- Traditional KV cache: Fixed-size blocks, memory fragmentation
- Wastes memory when sequences have different lengths
- Can’t support very long sequences efficiently
How it works:
- Divide KV cache into fixed-size “pages” (like OS memory pages)
- Allocate pages on-demand as tokens are generated
- Free pages when sequences complete
- Reuse freed pages for new sequences
Benefits:
- Efficient memory: No fragmentation
- Longer sequences: Support sequences up to model max
- Higher throughput: More sequences fit in memory
Analogy: Like virtual memory in operating systems - pages allocated as needed.
Q10: Explain the trade-offs between latency and throughput.
Answer:
Latency: Time for one request (ms) Throughput: Requests processed per second
Trade-offs:
-
Batching:
- Larger batches → Higher throughput, higher latency
- Smaller batches → Lower latency, lower throughput
-
Model Size:
- Larger model → Higher quality, higher latency
- Smaller model → Lower latency, potentially lower quality
-
Quantization:
- Lower precision → Faster, lower memory, potential accuracy loss
- Higher precision → Slower, more memory, better accuracy
-
Hardware:
- More GPUs → Higher throughput, higher cost
- Fewer GPUs → Lower cost, lower throughput
Optimization Strategy:
- Low latency: Small batches, optimized model, fast hardware
- High throughput: Large batches, continuous batching, multiple GPUs
Kubernetes & Deployment
Q11: How would you deploy an LLM model to Kubernetes?
Answer:
1. Containerize:
FROM python:3.9-slim
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["python", "app.py"]
2. Create Deployment:
apiVersion: apps/v1
kind: Deployment
metadata:
name: llm-serving
spec:
replicas: 3
template:
spec:
containers:
- name: llm-serving
image: llm-serving:v1.0
resources:
requests:
memory: "4Gi"
cpu: "2000m"
limits:
memory: "8Gi"
cpu: "4000m"
nvidia.com/gpu: 1
livenessProbe:
httpGet:
path: /health
port: 8000
3. Create Service:
apiVersion: v1
kind: Service
metadata:
name: llm-serving
spec:
selector:
app: llm-serving
ports:
- port: 80
targetPort: 8000
4. Deploy:
kubectl apply -f deployment.yaml
kubectl apply -f service.yaml
Key Considerations:
- Resource limits (GPU, memory)
- Health checks
- Rolling updates
- ConfigMaps for configuration
- Secrets for API keys
Q12: How does Horizontal Pod Autoscaling (HPA) work?
Answer: HPA automatically scales the number of pod replicas based on metrics.
How it works:
- HPA checks metrics every 15 seconds (default)
- Compares current metric value to target
- Calculates desired number of replicas
- Updates deployment replica count
- Kubernetes creates/destroys pods
Example:
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
spec:
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
Metrics:
- CPU/Memory: Built-in resource metrics
- Custom metrics: Requests per second, latency (requires Prometheus Adapter)
Scaling Behavior:
- Scale up: When metric > target (add pods)
- Scale down: When metric < target (remove pods)
- Stabilization window: Prevents flapping
Q13: Explain canary deployments for model updates.
Answer: Canary deployment gradually rolls out new model version to a small percentage of traffic.
Process:
- Deploy canary: Deploy new version alongside stable
- Route traffic: Split traffic (e.g., 90% stable, 10% canary)
- Monitor: Compare metrics (latency, errors, quality)
- Gradual increase: If good, increase canary traffic (25%, 50%, 100%)
- Rollback: If issues, route all traffic back to stable
Implementation:
- Kubernetes: Multiple deployments with different replica counts
- Service Mesh (Istio): Fine-grained traffic control
- Application-level: Route based on headers/parameters
Benefits:
- Risk reduction: Test on small traffic first
- Quick rollback: Revert if issues detected
- A/B testing: Compare model versions
Monitoring:
- Compare latency (P50, P95, P99)
- Error rates
- Business metrics (user satisfaction)
Monitoring & Observability
Q14: What metrics should you monitor for LLM serving?
Answer:
Application Metrics:
- Request rate: Requests per second
- Latency: P50, P95, P99 response times
- Error rate: Failed requests percentage
- Queue size: Pending requests
- Throughput: Tokens per second
Model Metrics:
- Generation time: Time to generate tokens
- Tokens generated: Average tokens per request
- Model version: Which model is running
System Metrics:
- GPU utilization: GPU usage percentage
- GPU memory: Used/total memory
- CPU usage: CPU utilization
- Memory usage: RAM usage
Business Metrics:
- Cost per request: Compute cost
- User satisfaction: Quality metrics
- API usage: Requests by endpoint
Key Dashboards:
- Performance: Latency, throughput, errors
- Resource: GPU, CPU, memory usage
- Model: Version performance comparison
Q15: How would you detect model drift in production?
Answer:
Types of Drift:
- Data drift: Input distribution changes
- Concept drift: Input-output relationship changes
- Prediction drift: Output distribution changes
Detection Methods:
1. Statistical Tests:
- PSI (Population Stability Index): Compare distributions
- Kolmogorov-Smirnov: Test distribution differences
- Chi-square: Test categorical distributions
2. Monitoring Tools:
- Evidently AI: Open-source drift detection
- Prometheus: Custom metrics
- Custom scripts: Compare reference vs current
3. Implementation:
from evidently import Report, DataDriftTable
report = Report(metrics=[DataDriftTable()])
report.run(
reference_data=train_data,
current_data=production_data
)
if report.get_metric(DataDriftTable()).drift_detected:
alert("Data drift detected!")
4. Alerting:
- Set thresholds (e.g., PSI > 0.2)
- Monitor continuously
- Alert on drift detection
- Investigate causes
Actions:
- Retrain model: If drift significant
- Investigate: Understand why drift occurred
- Update baseline: Update reference data if appropriate
Production Best Practices
Q16: What are the key considerations for production LLM serving?
Answer:
1. Performance:
- Latency: P95 < 500ms for most use cases
- Throughput: Handle expected load
- Scalability: Auto-scale based on demand
2. Reliability:
- Health checks: Liveness and readiness probes
- Error handling: Graceful degradation
- Circuit breakers: Prevent cascade failures
- Retries: With exponential backoff
3. Monitoring:
- Metrics: Comprehensive observability
- Logging: Structured logging
- Alerting: Proactive issue detection
- Tracing: Request tracing
4. Security:
- Authentication: API keys, OAuth
- Rate limiting: Prevent abuse
- Input validation: Sanitize inputs
- Secrets management: Secure API keys
5. Cost:
- Resource optimization: Right-size instances
- Auto-scaling: Scale down when not needed
- Model optimization: Use efficient models
6. Model Management:
- Versioning: Track model versions
- A/B testing: Compare model performance
- Rollback: Quick revert capability
- Drift detection: Monitor model degradation
Q17: How would you handle a sudden spike in traffic?
Answer:
Immediate Actions:
- Auto-scaling: HPA should scale up automatically
- Load balancing: Distribute across pods
- Queue management: Queue requests if needed
- Rate limiting: Protect backend from overload
Prevention:
- Capacity planning: Understand max capacity
- Load testing: Test under expected load
- Auto-scaling: Configure HPA properly
- Circuit breakers: Prevent cascade failures
Monitoring:
- Watch pod count
- Monitor latency (P95, P99)
- Check error rates
- GPU utilization
If Overwhelmed:
- Degrade gracefully: Return cached responses
- Rate limit: Reject excess requests
- Scale manually: If auto-scaling insufficient
- Add capacity: More nodes/GPUs
Post-Incident:
- Analyze what happened
- Improve auto-scaling config
- Increase baseline capacity if needed
- Document learnings
System Design
Q18: Design a system to serve LLMs at scale.
Answer:
Architecture:
[Load Balancer]
↓
[API Gateway] (Rate limiting, Auth)
↓
[Kubernetes Cluster]
├── [LLM Serving Pods] (vLLM)
├── [Monitoring] (Prometheus, Grafana)
└── [Model Registry] (S3/GCS)
Components:
1. Load Balancer:
- Distribute traffic
- Health checks
- SSL termination
2. API Gateway:
- Authentication/Authorization
- Rate limiting
- Request routing
- API versioning
3. Serving Layer:
- vLLM servers: High-performance inference
- Auto-scaling: HPA based on metrics
- GPU nodes: Dedicated GPU instances
4. Model Storage:
- Model registry: S3/GCS for model files
- Versioning: Track model versions
- Caching: Cache models on nodes
5. Monitoring:
- Metrics: Prometheus
- Dashboards: Grafana
- Logging: Centralized logging
- Alerting: PagerDuty/Slack
6. Data Pipeline:
- Request logging: Store inputs/outputs
- Drift detection: Monitor data drift
- A/B testing: Compare model versions
Scaling Strategy:
- Horizontal: Add more pods (HPA)
- Vertical: Larger GPUs for bigger models
- Multi-region: Geographic distribution
Key Metrics:
- Latency (P50, P95, P99)
- Throughput (req/s)
- Error rate
- GPU utilization
- Cost per request
Q19: How would you implement model versioning and rollback?
Answer:
Versioning Strategy:
- Semantic versioning: v1.0.0, v1.1.0, v2.0.0
- Model registry: Store models with metadata
- Metadata tracking: Training date, metrics, dataset
Implementation:
1. Model Registry:
models/
v1.0.0/
model.bin
tokenizer.json
metadata.json
v1.1.0/
model.bin
tokenizer.json
metadata.json
2. Deployment:
env:
- name: MODEL_VERSION
value: "v1.0.0"
- name: MODEL_PATH
value: "/models/v1.0.0"
3. Rollback:
# Update to previous version
kubectl set env deployment/llm-serving \
MODEL_VERSION=v0.9.0
# Or use canary deployment
# Route traffic back to stable version
4. API:
GET /api/v1/models/versions
POST /api/v1/models/rollback
{
"target_version": "v1.0.0"
}
Best Practices:
- Test before deploy: Validate new version
- Gradual rollout: Use canary deployment
- Monitor: Track version performance
- Document: Changelog for each version
Q20: Explain how you would optimize costs for LLM serving.
Answer:
Cost Components:
- Compute: GPU/CPU instances
- Storage: Model storage
- Network: Data transfer
- Monitoring: Observability tools
Optimization Strategies:
1. Right-sizing:
- Use appropriate instance types
- Don’t over-provision
- Match workload to instance
2. Auto-scaling:
- Scale down during low traffic
- Scale up only when needed
- Use spot instances for non-critical workloads
3. Model Optimization:
- Quantization: INT8/INT4 (smaller, faster)
- Pruning: Remove unnecessary weights
- Distillation: Use smaller models
4. Caching:
- Cache model weights
- Cache common responses (if applicable)
- Use CDN for static content
5. Batch Processing:
- Batch requests when possible
- Use continuous batching (vLLM)
- Higher GPU utilization = lower cost per request
6. Monitoring:
- Track cost per request
- Identify expensive operations
- Optimize based on data
7. Reserved Instances:
- Commit to usage for discounts
- Use for predictable workloads
Example:
- Before: 10 GPUs, 50% utilization = $5000/month
- After: 5 GPUs, 90% utilization (with batching) = $2500/month
- Savings: 50%
Additional Quick Questions
Q21: What is the difference between batch size and sequence length?
Answer:
- Batch size: Number of requests processed together
- Sequence length: Number of tokens in a single request
Example:
- Batch size = 8: Process 8 requests simultaneously
- Sequence length = 512: Each request has up to 512 tokens
Impact:
- Larger batch size → Higher throughput, more memory
- Longer sequence length → More computation, more memory (KV cache)
Q22: How does quantization affect model performance?
Answer: Quantization reduces model precision to save memory and speed up inference.
Types:
- FP32 → FP16: 2x smaller, 2x faster, minimal accuracy loss
- FP16 → INT8: 2x smaller, 2x faster, small accuracy loss
- INT8 → INT4: 2x smaller, 2x faster, larger accuracy loss
Trade-offs:
- Pros: Faster inference, less memory, lower cost
- Cons: Potential accuracy loss, may need calibration
When to use:
- Production when speed/cost matters
- After validating accuracy is acceptable
- For edge deployment (limited resources)
Q23: What is the difference between model parallelism and data parallelism?
Answer:
Data Parallelism:
- Same model on multiple GPUs
- Different data on each GPU
- Used in training (gradient sync)
- Inference: Not commonly used (each request needs full model)
Model Parallelism:
- Split model across multiple GPUs
- Each GPU holds part of model
- Used for large models that don’t fit on one GPU
- Inference: Common for very large models (70B+)
Example:
- Data parallel: 8 GPUs, each running full GPT-2
- Model parallel: 8 GPUs, each holding 1/8 of GPT-3
Tips for Interviews
- Be specific: Use numbers and examples
- Show trade-offs: Understand pros/cons
- Think system-wide: Consider all components
- Ask clarifying questions: Understand requirements
- Draw diagrams: Visualize architecture
- Discuss monitoring: Always mention observability
- Talk about failures: How to handle edge cases
Resources
- This repository’s documentation
- vLLM documentation
- Kubernetes documentation
- Prometheus/Grafana guides
- Evidently AI documentation
Good luck with your interviews! 🚀
Exercise Solutions
This document provides solutions to exercises in each learning topic.
Topic 1: Basic Model Serving
Exercise 1: Change the Model
Solution:
# In model_loader.py, change:
model = LLMModel(
model_name="distilgpt2", # Smaller, faster model
device="cpu"
)
Exercise 2: Modify Parameters
Solution:
# In app.py, test different temperatures:
# Temperature 0.1 = more deterministic
# Temperature 2.0 = more creative
payload = {
"prompt": "The future of AI is",
"temperature": 0.1, # Try different values
"max_length": 100
}
Exercise 3: Add Logging
Solution:
import logging
from datetime import datetime
logger = logging.getLogger(__name__)
@app.post("/generate")
async def generate_text(request: TextGenerationRequest):
start_time = time.time()
logger.info(f"Request received: prompt='{request.prompt[:50]}...'")
# ... generation code ...
latency = (time.time() - start_time) * 1000
logger.info(f"Request completed: latency={latency:.2f}ms, tokens={num_tokens}")
return response
Exercise 4: Error Handling
Solution:
@app.post("/generate")
async def generate_text(request: TextGenerationRequest):
try:
if model is None:
raise HTTPException(status_code=503, detail="Model not loaded")
if len(request.prompt) > 1000:
raise HTTPException(status_code=400, detail="Prompt too long")
# Generation code...
except HTTPException:
raise
except Exception as e:
logger.error(f"Unexpected error: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
Exercise 5: Batch Requests
Solution:
class BatchGenerationRequest(BaseModel):
prompts: List[str] = Field(..., min_items=1, max_items=10)
@app.post("/generate_batch")
async def generate_batch(request: BatchGenerationRequest):
results = []
for prompt in request.prompts:
generated = model.generate(prompt=prompt, max_length=50)
results.append({"prompt": prompt, "generated": generated})
return {"results": results}
Topic 2: Docker
Exercise 1: Build Basic Image
Solution:
cd 01_basic_serving
docker build -f ../02_docker/Dockerfile.basic -t llm-serving:basic .
docker run -p 8000:8000 llm-serving:basic
Exercise 2: Optimize Image Size
Solution:
# Use multi-stage build
FROM python:3.9-slim as builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt
FROM python:3.9-slim
WORKDIR /app
COPY --from=builder /root/.local /root/.local
COPY . .
ENV PATH=/root/.local/bin:$PATH
CMD ["python", "app.py"]
Exercise 3: Multi-stage Build
Solution: See 02_docker/Dockerfile.optimized
Exercise 4: GPU Support
Solution: See 02_docker/Dockerfile.gpu
Exercise 5: Docker Compose
Solution: See 02_docker/docker-compose.yml
Topic 3: Kubernetes
Exercise 1: Basic Deployment
Solution:
kubectl apply -f 03_kubernetes/deployment.yaml
kubectl get pods -n llm-serving
kubectl logs -f deployment/llm-serving -n llm-serving
Exercise 2: Health Checks
Solution:
# Already in deployment.yaml
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 60
readinessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 30
Exercise 3: Resource Limits
Solution:
resources:
requests:
memory: "2Gi"
cpu: "1000m"
limits:
memory: "4Gi"
cpu: "2000m"
Exercise 4: ConfigMap
Solution:
apiVersion: v1
kind: ConfigMap
metadata:
name: llm-config
data:
model_name: "gpt2"
max_length: "100"
---
env:
- name: MODEL_NAME
valueFrom:
configMapKeyRef:
name: llm-config
key: model_name
Exercise 5: Scaling
Solution:
kubectl scale deployment llm-serving --replicas=3 -n llm-serving
kubectl get pods -n llm-serving
Exercise 6: Rolling Update
Solution:
# Update image
kubectl set image deployment/llm-serving \
llm-serving=llm-serving:v2 -n llm-serving
# Watch rollout
kubectl rollout status deployment/llm-serving -n llm-serving
# Rollback if needed
kubectl rollout undo deployment/llm-serving -n llm-serving
Topic 4: Load Testing
Exercise 1: Baseline Test
Solution:
python measure_latency.py --url http://localhost:8000 --requests 10
Exercise 2: Ramp-up Test
Solution:
# In Locust UI, use "Spawn rate" to gradually increase users
# Or use command line:
locust -f locust_test.py \
--host=http://localhost:8000 \
--headless \
--users 50 \
--spawn-rate 5 \
--run-time 5m
Exercise 3: Compare Models
Solution:
# Test model A
python measure_latency.py --url http://localhost:8000 --requests 100 > model_a.txt
# Change model, restart server
# Test model B
python measure_latency.py --url http://localhost:8001 --requests 100 > model_b.txt
# Compare results
diff model_a.txt model_b.txt
Exercise 4: Find Bottleneck
Solution:
# Add profiling to app.py
import cProfile
import pstats
@app.post("/generate")
async def generate_text(request: TextGenerationRequest):
profiler = cProfile.Profile()
profiler.enable()
# ... generation code ...
profiler.disable()
stats = pstats.Stats(profiler)
stats.sort_stats('cumulative')
stats.print_stats(10) # Top 10 slowest functions
Exercise 5: Stress Test
Solution:
# Keep increasing users until system breaks
locust -f locust_test.py \
--host=http://localhost:8000 \
--headless \
--users 100 \
--spawn-rate 10 \
--run-time 10m
Topic 5: vLLM Serving
Exercise 1: Compare Performance
Solution:
# Test HuggingFace
import time
start = time.time()
# ... HuggingFace generation ...
hf_time = time.time() - start
# Test vLLM
start = time.time()
# ... vLLM generation ...
vllm_time = time.time() - start
print(f"HuggingFace: {hf_time:.2f}s")
print(f"vLLM: {vllm_time:.2f}s")
print(f"Speedup: {hf_time/vllm_time:.2f}x")
Exercise 2: Tune Parameters
Solution:
# Test different GPU memory utilization
for util in [0.7, 0.8, 0.9, 0.95]:
llm = LLM(
model="gpt2",
gpu_memory_utilization=util
)
# Measure throughput
# Find optimal value
Exercise 3: Test Continuous Batching
Solution:
# Send requests at different rates
import asyncio
async def send_requests(rate_per_second=10):
for i in range(100):
# Send request
await asyncio.sleep(1/rate_per_second)
# Monitor GPU utilization
Exercise 4: Monitor Metrics
Solution:
# Add Prometheus metrics to vLLM server
from prometheus_client import Counter, Histogram
REQUEST_COUNT = Counter('vllm_requests_total', 'Total requests')
REQUEST_DURATION = Histogram('vllm_request_duration_seconds', 'Request duration')
@app.post("/complete")
async def complete(request: CompletionRequest):
REQUEST_COUNT.inc()
with REQUEST_DURATION.time():
# ... generation ...
pass
Topic 6: Autoscaling
Exercise 1: Basic HPA
Solution:
kubectl apply -f 06_autoscaling/hpa.yaml
kubectl get hpa -n llm-serving
kubectl describe hpa llm-serving-hpa -n llm-serving
Exercise 2: Custom Metrics
Solution:
# Requires Prometheus Adapter
metrics:
- type: Pods
pods:
metric:
name: http_requests_per_second
target:
type: AverageValue
averageValue: "10"
Exercise 3: Scaling Policies
Solution:
behavior:
scaleUp:
policies:
- type: Percent
value: 50 # Increase by 50%
periodSeconds: 30
scaleDown:
stabilizationWindowSeconds: 300 # Wait 5 min
policies:
- type: Percent
value: 25 # Decrease by 25%
Exercise 4: Load Test
Solution:
# Terminal 1: Watch HPA
kubectl get hpa -w -n llm-serving
# Terminal 2: Generate load
locust -f locust_test.py --host=http://<service-url> --users 50
# Terminal 3: Watch pods
kubectl get pods -w -n llm-serving
Topic 7: Canary Deployments
Exercise 1: Basic Canary
Solution:
kubectl apply -f 07_canary_deployments/canary-deployment.yaml
# Traffic splits 90/10 based on replica count
Exercise 2: Gradual Rollout
Solution:
# Phase 1: 10% (already done)
# Phase 2: 25%
kubectl scale deployment llm-serving-canary --replicas=3 -n llm-serving
kubectl scale deployment llm-serving-stable --replicas=7 -n llm-serving
# Phase 3: 50%
kubectl scale deployment llm-serving-canary --replicas=5 -n llm-serving
kubectl scale deployment llm-serving-stable --replicas=5 -n llm-serving
# Phase 4: 100%
kubectl scale deployment llm-serving-canary --replicas=10 -n llm-serving
kubectl delete deployment llm-serving-stable -n llm-serving
Exercise 3: Monitoring
Solution:
# Compare latency
histogram_quantile(0.95,
rate(llm_request_duration_seconds_bucket{version="canary"}[5m])
) /
histogram_quantile(0.95,
rate(llm_request_duration_seconds_bucket{version="stable"}[5m])
)
Exercise 4: Rollback
Solution:
# Scale canary to 0
kubectl scale deployment llm-serving-canary --replicas=0 -n llm-serving
# Or update service to only route to stable
Topic 8: Monitoring
Exercise 1: Set Up Monitoring
Solution:
cd 08_monitoring
docker-compose up -d
# Access Grafana at http://localhost:3000
Exercise 2: Create Dashboard
Solution:
- Open Grafana
- Add Prometheus data source
- Create new dashboard
- Add panels with queries from
08_monitoring/README.md
Exercise 3: Set Up Alerts
Solution:
# In Prometheus alerts.yml
groups:
- name: llm_alerts
rules:
- alert: HighLatency
expr: histogram_quantile(0.95, rate(llm_request_duration_seconds_bucket[5m])) > 1
for: 5m
annotations:
summary: "P95 latency > 1s"
Exercise 4: GPU Monitoring
Solution:
# Use prometheus_exporter.py
# GPU metrics are automatically collected if pynvml is installed
Topic 9: Canary Deployments
(Same as Topic 7 - see above)
Topic 10: Model Versioning
Exercise 1: Create Registry
Solution:
# models/registry.py
class ModelRegistry:
def __init__(self, base_path="models"):
self.base_path = base_path
def register(self, version, model_path, metadata):
version_dir = f"{self.base_path}/{version}"
os.makedirs(version_dir, exist_ok=True)
# Copy model files
# Save metadata
Exercise 2: Version Model
Solution:
# Tag model version
mkdir -p models/v1.0.0
cp model.bin models/v1.0.0/
cp tokenizer.json models/v1.0.0/
echo '{"version": "v1.0.0", "created_at": "2024-01-15"}' > models/v1.0.0/metadata.json
Exercise 3: Deploy Version
Solution:
# Update deployment
env:
- name: MODEL_VERSION
value: "v1.0.0"
- name: MODEL_PATH
value: "/models/v1.0.0"
Exercise 4: Rollback
Solution:
# Update to previous version
kubectl set env deployment/llm-serving \
MODEL_VERSION=v0.9.0 \
-n llm-serving
Topic 11: Drift Detection
Exercise 1: Set Up Evidently
Solution:
pip install evidently pandas
Exercise 2: Detect Data Drift
Solution:
from drift_detector import DriftDetector
import pandas as pd
# Load reference data
reference = pd.read_csv("train_data.csv")
# Initialize detector
detector = DriftDetector(reference_data=reference)
# Load current data
current = pd.read_csv("production_data.csv")
# Detect drift
result = detector.detect_data_drift(current_data=current)
print(result)
Exercise 3: Create Dashboard
Solution:
from evidently.ui.dashboards import Dashboard
report = detector.generate_report(current_data)
dashboard = Dashboard("Drift Monitoring")
dashboard.add_report(report)
dashboard.show()
Exercise 4: Set Up Alerts
Solution:
if result["drift_detected"]:
send_email_alert("Data drift detected!")
send_slack_alert("Drift score: {result['drift_score']}")
Topic 12: Triton
Exercise 1: Deploy Model
Solution:
# Create model repository
mkdir -p model_repository/gpt2/1
# Copy model files
cp model.pt model_repository/gpt2/1/
# Start Triton
docker run --gpus all \
-v $(pwd)/model_repository:/models \
nvcr.io/nvidia/tritonserver:23.10-py3 \
tritonserver --model-repository=/models
Exercise 2: Multiple Models
Solution:
model_repository/
gpt2/
config.pbtxt
1/model.pt
distilgpt2/
config.pbtxt
1/model.pt
Exercise 3: Dynamic Batching
Solution:
dynamic_batching {
max_queue_delay_microseconds: 100000
preferred_batch_size: [ 4, 8, 16 ]
max_batch_size: 32
}
Exercise 4: Model Ensemble
Solution: See 12_triton/README.md for ensemble configuration
General Tips
- Test locally first: Always test changes locally before deploying
- Monitor metrics: Watch metrics during exercises
- Read errors: Error messages often contain solutions
- Experiment: Try different values and see what happens
- Document: Note what works and what doesn’t
Topic 1: Basic LLM Serving
What You’ll Learn
This topic teaches you the fundamentals of serving an LLM:
- How to load a pre-trained model from HuggingFace
- Building a FastAPI endpoint for inference
- Understanding the inference pipeline
- Handling requests and responses
- Basic error handling
Why We Need This
Business Need
Companies need to expose LLM capabilities as APIs to:
- Integrate AI into applications: Chatbots, content generation, code completion
- Serve multiple clients: Web apps, mobile apps, internal tools
- Scale independently: Separate model serving from application logic
- Enable monetization: API-based business models
Technical Need
- Separation of concerns: Model serving separate from application code
- Resource management: Dedicated servers for compute-intensive inference
- Standardization: REST APIs are universal, language-agnostic
- Testing: Easy to test model independently
Real-World Impact
Without proper serving infrastructure:
- ❌ Models can’t be used in production applications
- ❌ No way to integrate AI into existing systems
- ❌ Difficult to scale and maintain
- ❌ Hard to version and update models
Industry Use Cases
1. Customer Support Chatbots
Company: E-commerce, SaaS platforms Use Case:
- Customer asks question → API call → LLM generates response
- Handles 24/7 support, reduces human agent workload
Example Request:
POST /generate
{
"prompt": "Customer: How do I return an item?\nAssistant:",
"max_length": 100
}
2. Content Generation
Company: Marketing agencies, content platforms Use Case:
- Generate blog posts, social media content, product descriptions
- API called from CMS or marketing tools
Example Request:
POST /generate
{
"prompt": "Write a product description for a wireless headphone:",
"temperature": 0.8, # More creative
"max_length": 200
}
3. Code Completion & Assistance
Company: GitHub Copilot, IDEs Use Case:
- Developer types code → API suggests completions
- Real-time code generation in editor
Example Request:
POST /generate
{
"prompt": "def calculate_total(items):\n ",
"max_length": 50,
"temperature": 0.2 # More deterministic
}
4. Translation Services
Company: Google Translate, DeepL Use Case:
- Translate text between languages
- API integrated into websites, apps
Example Request:
POST /generate
{
"prompt": "Translate to French: Hello, how are you?",
"max_length": 50
}
5. Sentiment Analysis
Company: Social media platforms, review sites Use Case:
- Analyze customer reviews, social media posts
- Real-time sentiment detection
Example Request:
POST /generate
{
"prompt": "Sentiment: This product is amazing!",
"max_length": 10
}
Industry-Standard Boilerplate Code
Complete FastAPI Serving Application
"""
Industry-standard LLM serving application
Used by: OpenAI API, Anthropic Claude, HuggingFace Inference API
"""
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch
import time
import logging
from contextlib import asynccontextmanager
# Configure logging (industry standard)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Global model (loaded once at startup)
model = None
tokenizer = None
device = "cuda" if torch.cuda.is_available() else "cpu"
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Load model at startup, cleanup at shutdown"""
global model, tokenizer
logger.info(f"Loading model on {device}...")
model_name = "gpt2" # In production: from environment variable
try:
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForCausalLM.from_pretrained(model_name)
model.to(device)
model.eval() # Set to evaluation mode
logger.info("Model loaded successfully")
except Exception as e:
logger.error(f"Failed to load model: {e}")
raise
yield # Application runs here
# Cleanup
model = None
tokenizer = None
logger.info("Model unloaded")
app = FastAPI(
title="LLM Serving API",
description="Production-ready LLM inference endpoint",
version="1.0.0",
lifespan=lifespan
)
# Request/Response Models (Industry standard structure)
class GenerationRequest(BaseModel):
"""Standard request format (similar to OpenAI API)"""
prompt: str = Field(..., min_length=1, max_length=2000)
max_tokens: int = Field(50, ge=1, le=500)
temperature: float = Field(1.0, ge=0.0, le=2.0)
top_p: float = Field(1.0, ge=0.0, le=1.0)
stop: list[str] = Field(default_factory=list)
class GenerationResponse(BaseModel):
"""Standard response format"""
text: str
model: str
usage: dict
latency_ms: float
@app.get("/health")
async def health_check():
"""Health check for load balancers and K8s probes"""
return {
"status": "healthy" if model is not None else "unhealthy",
"model_loaded": model is not None
}
@app.post("/v1/completions", response_model=GenerationResponse)
async def generate_completion(request: GenerationRequest):
"""
Main generation endpoint
Industry standard: /v1/completions (OpenAI-compatible)
"""
if model is None:
raise HTTPException(status_code=503, detail="Model not loaded")
start_time = time.time()
try:
# Tokenize
inputs = tokenizer(
request.prompt,
return_tensors="pt",
truncation=True,
max_length=1024
).to(device)
# Generate
with torch.no_grad():
outputs = model.generate(
**inputs,
max_new_tokens=request.max_tokens,
temperature=request.temperature,
top_p=request.top_p,
do_sample=True,
pad_token_id=tokenizer.eos_token_id
)
# Decode
generated_text = tokenizer.decode(
outputs[0][inputs['input_ids'].shape[1]:],
skip_special_tokens=True
)
# Calculate metrics
latency_ms = (time.time() - start_time) * 1000
num_tokens = len(outputs[0]) - inputs['input_ids'].shape[1]
logger.info(
f"Generated {num_tokens} tokens in {latency_ms:.2f}ms"
)
return GenerationResponse(
text=generated_text,
model="gpt2",
usage={
"prompt_tokens": inputs['input_ids'].shape[1],
"completion_tokens": num_tokens,
"total_tokens": len(outputs[0])
},
latency_ms=latency_ms
)
except Exception as e:
logger.error(f"Generation error: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
if __name__ == "__main__":
import uvicorn
uvicorn.run(
app,
host="0.0.0.0",
port=8000,
log_level="info"
)
Usage Example (Industry Standard)
# Client code (how applications use the API)
import requests
def generate_text(prompt: str, max_tokens: int = 50) -> str:
"""
Call LLM serving API
Used by: Web applications, mobile apps, microservices
"""
response = requests.post(
"http://llm-api:8000/v1/completions",
json={
"prompt": prompt,
"max_tokens": max_tokens,
"temperature": 0.7
},
timeout=30
)
response.raise_for_status()
return response.json()["text"]
# Example: Customer support chatbot
def handle_customer_query(query: str) -> str:
prompt = f"Customer: {query}\nAssistant:"
return generate_text(prompt, max_tokens=100)
# Example: Content generation
def generate_product_description(product_name: str) -> str:
prompt = f"Write a compelling product description for {product_name}:"
return generate_text(prompt, max_tokens=200, temperature=0.8)
Concepts Explained
Model Loading
When you load a model, you’re:
- Downloading weights (if not cached) - These are the learned parameters
- Loading into memory - CPU RAM or GPU VRAM
- Initializing tokenizer - Converts text ↔ tokens
- Setting up device - CPU or CUDA (GPU)
Why this matters: Model loading is expensive. You do it once at startup, not per request.
Tokenization
- Text → Tokens: “Hello” → [15496]
- Tokens → Text: [15496] → “Hello”
- Special tokens:
<BOS>,<EOS>,<PAD>,<UNK>
Inference Process
- Tokenize input prompt
- Run forward pass through model (autoregressive)
- Sample next token
- Repeat until max_length or stop token
- Decode tokens back to text
Autoregressive Generation
LLMs generate one token at a time:
- Input: “The weather is”
- Step 1: Generate “nice”
- Step 2: Generate “today”
- Step 3: Generate “.”
- Output: “The weather is nice today.”
Each step uses the previous tokens as context.
Code Structure
01_basic_serving/
├── README.md # This file
├── app.py # FastAPI application
├── model_loader.py # Model loading logic
├── requirements.txt # Dependencies
└── test_api.py # Simple test script
Running the Code
1. Install Dependencies
pip install -r requirements.txt
2. Start the Server
python app.py
3. Test the API
# Health check
curl http://localhost:8000/health
# Generate text
curl -X POST http://localhost:8000/generate \
-H "Content-Type: application/json" \
-d '{
"prompt": "The future of AI is",
"max_length": 50,
"temperature": 0.7
}'
4. View API Docs
Open http://localhost:8000/docs in your browser
Key Code Sections
Model Loading (model_loader.py)
# Load model and tokenizer
model = AutoModelForCausalLM.from_pretrained("gpt2")
tokenizer = AutoTokenizer.from_pretrained("gpt2")
# Move to device (CPU or GPU)
device = "cuda" if torch.cuda.is_available() else "cpu"
model.to(device)
Generation (app.py)
# Tokenize input
inputs = tokenizer(prompt, return_tensors="pt").to(device)
# Generate
outputs = model.generate(
**inputs,
max_length=max_length,
temperature=temperature
)
# Decode
generated_text = tokenizer.decode(outputs[0], skip_special_tokens=True)
Understanding the Output
When you call the API, you get:
generated_text: The complete generated textprompt: Your input (echoed back)num_tokens: Number of tokens generatedlatency_ms: Time taken in millisecondsmodel_name: Which model was used
Common Issues
Out of Memory
- Problem: Model too large for available RAM/VRAM
- Solution: Use a smaller model (gpt2-small) or reduce batch size
Slow Inference
- Problem: Running on CPU
- Solution: Use GPU if available, or use a smaller model
Import Errors
- Problem: Missing dependencies
- Solution:
pip install -r requirements.txt
Exercises
- Change the model: Try different HuggingFace models (gpt2, distilgpt2, etc.)
- Modify parameters: Experiment with temperature, top_p, max_length
- Add logging: Log every request with timing information
- Error handling: Add try/except for different error cases
- Batch requests: Modify to handle multiple prompts at once
Next Steps
Once you understand this, move to:
- Topic 2: Containerization with Docker
- Topic 3: Kubernetes deployment
Further Reading
Basic LLM Serving — A Deep Dive
Standing up a language model behind an HTTP API from first principles, and understanding why the naive version is slow.
Why this matters
Every production LLM system — ChatGPT, a support bot, a code assistant — is at bottom a loop that turns text into tokens, runs a forward pass, and turns tokens back into text, wrapped in a network server. If you understand that loop end to end, and understand the two costs that dominate it (compute and GPU memory), the rest of this book is just engineering to make the loop cheaper and more concurrent.
This chapter builds the loop the obvious way: one model, one process, one request at a time. That version works, and it is a perfect teaching tool precisely because it is slow. By the end you will be able to say, with numbers, exactly where the time and the memory go — and that motivates batching, KV-cache management, and dedicated engines (vLLM, TGI, Triton) in the chapters that follow.
We keep the intuition first and the mechanism precise. Where there is a tradeoff, we name it honestly.
Core intuition: an LLM is an autoregressive next-token loop
A decoder-only transformer computes one thing: given a sequence of tokens, a probability distribution over the next token. Generation is just calling that repeatedly.
prompt: "The capital of France is"
-> tokenizer -> [464, 3139, 286, 4881, 318]
-> model -> logits over ~50k vocab -> pick "Paris" (token 6342)
-> append -> [464, 3139, 286, 4881, 318, 6342]
-> model -> pick "." -> append -> ...
-> stop on EOS or max_new_tokens
-> tokenizer.decode(...) -> " Paris."
Two things follow immediately, and they structure everything:
- Generation is sequential. Token N+1 depends on token N. You cannot decode a 200-token answer in one shot; you do (at least) 200 forward passes. This is why latency scales with output length.
- The model re-reads its own context every step — unless you cache. The naive loop re-processes the whole sequence on every token, which is quadratic waste. The fix is the KV cache (below), and the KV cache is what eats your GPU memory.
Hold those two facts. The whole performance story is a consequence of them.
Loading a model: weights, dtype, device, tokenizer
Before serving anything you load four things. Each has a failure mode.
Weights and where they live
A model is a set of tensors (the parameters) plus a config describing the architecture. With Hugging Face Transformers:
from transformers import AutoModelForCausalLM, AutoTokenizer
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-3.2-1B-Instruct",
torch_dtype="bfloat16", # precision — see below
device_map="cuda", # placement
)
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-3.2-1B-Instruct")
The weights download once to a local cache and are memory-mapped from safetensors shards on subsequent loads. Cold start = download + load + allocate; warm start = load + allocate. This distinction matters for autoscaling (Chapter 6): a cold pod may take minutes.
dtype / precision — the single biggest memory lever
Parameters are stored as floating-point numbers, and the bytes per parameter is a choice you make at load time.
| dtype | bytes/param | Typical use |
|---|---|---|
| FP32 | 4 | Rarely for inference; training reference |
| FP16 / BF16 | 2 | Standard inference precision on GPU |
| INT8 | 1 | Quantized inference (small quality hit) |
| INT4 / NF4 | ~0.5 | Aggressive quantization, edge/consumer GPUs |
BF16 (bfloat16) is usually preferred over FP16 on modern GPUs: same 2 bytes, but a wider exponent range, so it is less prone to overflow/NaN during the forward pass. FP32 doubles your memory for almost no inference quality gain — do not serve in FP32 by accident (it is the default if you forget torch_dtype).
Device placement
Weights must sit in GPU memory (VRAM) for fast inference. device_map="cuda" puts everything on one GPU; device_map="auto" will shard across multiple GPUs or spill to CPU/disk if the model does not fit — convenient, but CPU offload is catastrophically slow for serving. For a serving path you want the whole model resident on the GPU and you want to know it fits (memory math below).
Tokenizer — small, and a classic source of silent bugs
The tokenizer maps text <-> integer IDs. It must be the exact one the model was trained with; a mismatch produces garbage output with no error. Two things to get right in a server:
- Chat template. Instruct/chat models expect a specific formatting of roles (
<|user|>,<|assistant|>, etc.). Usetokenizer.apply_chat_template(messages, add_generation_prompt=True)rather than hand-concatenating strings — getting the special tokens wrong quietly degrades quality. - Padding side and pad token. For batched generation, decoder-only models must left-pad (
tokenizer.padding_side = "left"), and many models ship without apad_token— settokenizer.pad_token = tokenizer.eos_token. Right-padding a decoder batch corrupts the generation. (We revisit padding when we build real batching.)
Mechanism in depth: prefill vs decode, and the KV cache
This is the heart of the chapter. A single generation request has two phases with completely different performance characteristics.
Prefill (the prompt pass)
You feed the entire prompt (say 500 tokens) through the model in one forward pass. Because all prompt tokens are known up front, they are processed in parallel — the GPU does one big matrix-multiply-heavy pass over all 500 positions at once. This is compute-bound: it saturates the GPU’s arithmetic units. Prefill is what you pay for time-to-first-token (TTFT), and its cost grows with prompt length.
During prefill the model computes, for every layer and every attention head, a key (K) and value (V) vector for each prompt token, and stores them — that is the KV cache.
Decode (the generation loop)
Now you generate one token at a time. Each decode step feeds only the single newest token through the model. Its attention needs the K/V of all previous tokens — but those are already in the cache, so you do not recompute them. Each decode step is therefore tiny in arithmetic (one token’s worth of matmuls) but must read the entire KV cache and all model weights from GPU memory. Decode is memory-bandwidth-bound, not compute-bound: the GPU spends its time moving data, and its expensive tensor cores sit mostly idle.
This is the central asymmetry of LLM serving:
| Prefill | Decode | |
|---|---|---|
| Tokens processed per pass | whole prompt (parallel) | 1 |
| Bottleneck | compute (FLOPs) | memory bandwidth |
| Grows with | prompt length | output length |
| Determines | TTFT | TPOT / inter-token latency |
| GPU utilization | high | low (single request) |
The decode phase being memory-bound and low-utilization is exactly why one-request-at-a-time wastes the GPU, and exactly why batching helps: multiple requests can share the same weight read. Hold that thought for the tradeoff section.
Why the KV cache exists
Without a cache, generating token N would re-run attention over all N prior tokens from scratch — an (O(N^2)) blowup over a full sequence. The KV cache trades memory for compute: store each token’s K and V once, reuse them for every future step. It turns per-step attention cost from “re-read and recompute everything” into “read the cache.” The price is GPU memory that grows linearly with every token in every active request — which becomes the binding constraint on how many requests you can serve at once.
Worked example 1: a minimal FastAPI + Transformers server
Here is a complete, correct, single-file server. It is deliberately naive — synchronous generation, one request at a time — so it exposes every pitfall we then discuss. This is the “before” picture for the whole book.
# server.py
# pip install fastapi "uvicorn[standard]" transformers torch accelerate
# uvicorn server:app --host 0.0.0.0 --port 8000 --workers 1
import time
from contextlib import asynccontextmanager
import torch
from fastapi import FastAPI
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel
from transformers import AutoModelForCausalLM, AutoTokenizer
MODEL_ID = "meta-llama/Llama-3.2-1B-Instruct"
STATE = {}
@asynccontextmanager
async def lifespan(app: FastAPI):
# Load the model ONCE at startup, not per request.
tok = AutoTokenizer.from_pretrained(MODEL_ID)
if tok.pad_token is None:
tok.pad_token = tok.eos_token
tok.padding_side = "left"
model = AutoModelForCausalLM.from_pretrained(
MODEL_ID, torch_dtype=torch.bfloat16, device_map="cuda"
)
model.eval()
STATE["tok"], STATE["model"] = tok, model
yield
STATE.clear()
app = FastAPI(lifespan=lifespan)
class GenRequest(BaseModel):
prompt: str
max_new_tokens: int = 128
temperature: float = 0.7
top_p: float = 0.9
do_sample: bool = True
@torch.inference_mode()
def _generate(req: GenRequest) -> dict:
tok, model = STATE["tok"], STATE["model"]
messages = [{"role": "user", "content": req.prompt}]
inputs = tok.apply_chat_template(
messages, add_generation_prompt=True, return_tensors="pt"
).to(model.device)
prompt_len = inputs.shape[1]
t0 = time.perf_counter()
out = model.generate(
inputs,
max_new_tokens=req.max_new_tokens,
do_sample=req.do_sample,
temperature=req.temperature,
top_p=req.top_p,
pad_token_id=tok.pad_token_id,
)
dt = time.perf_counter() - t0
new_tokens = out[0, prompt_len:]
text = tok.decode(new_tokens, skip_special_tokens=True)
n_out = new_tokens.shape[0]
return {
"text": text,
"prompt_tokens": int(prompt_len),
"output_tokens": int(n_out),
"latency_s": round(dt, 3),
"tokens_per_s": round(n_out / dt, 1),
}
@app.post("/generate")
async def generate(req: GenRequest):
# Blocking, CPU/GPU-bound work goes to a thread so it does not
# freeze the async event loop (see pitfalls).
return await run_in_threadpool(_generate, req)
@app.get("/healthz")
async def healthz():
return {"ok": "model" in STATE}
Test it:
curl -s localhost:8000/generate \
-H 'content-type: application/json' \
-d '{"prompt": "Explain KV cache in one sentence.", "max_new_tokens": 64}'
What this server gets right, and what it deliberately does not:
- Right: model loaded once at startup (not per request);
@torch.inference_mode()disables gradient bookkeeping; blocking work offloaded off the event loop; chat template + pad token set correctly; returns real token counts and throughput. - Deliberately wrong / naive: it serves one request at a time per worker (the model is a shared object and
generateholds the GPU), it does not stream tokens (no TTFT benefit for the client), and it does no batching. Two simultaneous callers queue behind each other. That is the motivation for everything after this chapter.
Streaming, when you add it, uses TextIteratorStreamer + a background thread and a FastAPI StreamingResponse, so the client gets the first token as soon as prefill finishes rather than waiting for the whole answer — a large perceived latency win with no throughput change.
Generation parameters (what the knobs actually do)
generate is controlled by a GenerationConfig. The ones that matter for serving:
| Param | Effect | Note |
|---|---|---|
max_new_tokens | hard cap on output length | The #1 latency and cost lever — decode time is ~linear in it. Always set it. |
do_sample | greedy (False) vs sampling (True) | With do_sample=False, temperature/top_p are ignored and you get deterministic output. |
temperature | flattens (>1) or sharpens (<1) the distribution | 0 is not literally valid for sampling; use greedy for determinism. |
top_p (nucleus) | sample only from the smallest set of tokens summing to prob p | Common: 0.9–0.95. |
top_k | sample only from the k highest-prob tokens | Alternative/complement to top_p. |
repetition_penalty | discourage repeating tokens | Helps loops; tune carefully. |
stop / eos_token_id | stop conditions | Wrong EOS = runaway generation to max_new_tokens. |
A subtle correctness trap: if a caller passes do_sample=False and a non-default temperature, recent Transformers will warn that the sampling flags are ignored. Decide your server’s contract explicitly rather than passing user knobs through blindly.
Adding streaming (TTFT the user can feel)
The naive server returns the whole answer at once. Streaming emits tokens as they are decoded, so the client sees output right after prefill:
from threading import Thread
from transformers import TextIteratorStreamer
from fastapi.responses import StreamingResponse
@app.post("/generate/stream")
async def generate_stream(req: GenRequest):
tok, model = STATE["tok"], STATE["model"]
inputs = tok.apply_chat_template(
[{"role": "user", "content": req.prompt}],
add_generation_prompt=True, return_tensors="pt",
).to(model.device)
streamer = TextIteratorStreamer(tok, skip_prompt=True, skip_special_tokens=True)
kwargs = dict(inputs=inputs, streamer=streamer,
max_new_tokens=req.max_new_tokens, do_sample=req.do_sample,
temperature=req.temperature, top_p=req.top_p,
pad_token_id=tok.pad_token_id)
Thread(target=model.generate, kwargs=kwargs).start() # runs off the event loop
def emit():
for piece in streamer: # yields decoded text as tokens arrive
yield piece
return StreamingResponse(emit(), media_type="text/plain")
generate runs in a background thread and pushes tokens into the streamer; the handler yields them to the client. Same total work, dramatically better perceived latency — but note it still occupies the GPU serially. Streaming improves TTFT, not throughput.
Worked example 2: GPU memory budget (weights + KV cache + activations)
You cannot reason about serving without the memory math. The GPU must simultaneously hold model weights, the KV cache for every in-flight request, and transient activations. Run out and you get a CUDA OOM — the most common production failure.
Weights
[ \text{weight bytes} = (\text{number of parameters}) \times (\text{bytes per parameter}) ]
For a 7-billion-parameter model in BF16:
[ 7 \times 10^{9} \ \text{params} \times 2 \ \text{bytes} = 14 \times 10^{9} \ \text{bytes} \approx 14 \ \text{GB} ]
The rule of thumb “~2 GB per billion params in FP16/BF16” (and ~1 GB/B in INT8, ~0.5 GB/B in INT4) falls straight out of this.
KV cache (per token, then per request)
The KV cache stores a key and a value vector for every token, every layer, every KV head:
[ \text{bytes per token} = 2 \times L \times H_{kv} \times D_{h} \times b ]
where the leading (2) covers K and V, (L) is the number of transformer layers, (H_{kv}) the number of key/value heads, (D_{h}) the head dimension, and (b) the bytes per element. For a LLaMA-style 7B model with (L = 32), (H_{kv} = 32), (D_{h} = 128), BF16 ((b = 2)):
[ 2 \times 32 \times 32 \times 128 \times 2 = 524{,}288 \ \text{bytes} \approx 0.5 \ \text{MB per token} ]
For a request with a full context of 4,096 tokens:
[ 4096 \times 524{,}288 \ \text{bytes} = 2{,}147{,}483{,}648 \ \text{bytes} = 2 \ \text{GB} ]
So a single 4K-context request costs ~2 GB of KV cache on top of the 14 GB of weights. Note that models using grouped-query attention (GQA) have far fewer KV heads (H_{kv}) than query heads, which is specifically a trick to shrink this number — one reason modern models are cheaper to serve.
Activations and overhead
Beyond weights and KV cache, each forward pass allocates transient activation tensors, and the CUDA context / allocator reserves a fixed slab (often 1–2 GB). Activation memory scales with batch size and sequence length but is freed between steps, so it is usually a smaller, bounded term than the KV cache — which only grows. When you size a GPU, budget weights + peak KV + a safety margin for activations and fragmentation; do not plan to use the last gigabyte.
Putting it together on a 24 GB GPU
[ \text{free for KV + activations} \approx 24 \ \text{GB} - 14 \ \text{GB (weights)} - \sim 1\text{–}2 \ \text{GB (activations, CUDA ctx)} \approx 8 \ \text{GB} ]
At ~2 GB per 4K-token request, that GPU holds on the order of 4 concurrent full-length requests before OOM — and fewer if prompts are longer. This single calculation is why KV-cache efficiency (PagedAttention, Chapter 5) is the highest-leverage optimization in LLM serving: memory, not compute, usually caps your concurrency.
Metrics and the latency–throughput tradeoff
You cannot improve what you do not measure. Four numbers define an LLM endpoint.
| Metric | Definition | Formula |
|---|---|---|
| TTFT (time to first token) | Prompt submitted -> first output token received. Dominated by queueing + prefill. | measured directly |
| TPOT / ITL (time per output token / inter-token latency) | Average gap between successive output tokens in the “steady stream.” | (\text{TPOT} = \dfrac{\text{E2E} - \text{TTFT}}{N_{out} - 1}) |
| E2E latency | Request in -> full response out. | (\text{E2E} = \text{TTFT} + \text{generation time}) |
| Throughput | Tokens or requests completed per second, across all concurrent users. | (\text{TPS} = \dfrac{N_{out}}{T_{last} - T_{first}}), (\ \text{RPS} = \dfrac{\text{completed requests}}{\text{time}}) |
Definitions and formulas follow the Anyscale benchmarking guide (see Further Reading). Report percentiles (p50/p95/p99), never just the mean — tail latency is what users feel and what SLOs are written against.
The tradeoff
Here is the crux, and it is why “just add batching” is not free:
- A single request in isolation gets the lowest possible latency: the whole GPU is devoted to it. But decode is memory-bound, so the GPU’s compute units are ~idle — terrible throughput per dollar.
- Batching many requests together amortizes each weight/KV read across all of them: one memory pass serves (B) requests. Throughput (tokens/s, req/s) rises sharply — you use the idle compute. But any individual request may wait to be batched and shares GPU cycles, so its TTFT and TPOT rise.
So batch size is a dial between latency (small batch, low utilization, expensive per token) and throughput (large batch, high utilization, cheap per token, worse tail latency). There is no single right setting — it depends on your SLO. Real serving engines make this dial dynamic (continuous batching), which we introduce next and detail in Chapter 5.
Worked example 3: reading a latency timeline
Numbers make the asymmetry concrete. Suppose a request has a 500-token prompt and generates 200 tokens, and we measure:
- prefill takes 120 ms (this is the TTFT, ignoring queueing),
- each decode step takes 15 ms.
Then:
[ \text{TTFT} = 120 \ \text{ms}, \qquad \text{generation} = 199 \times 15 \ \text{ms} \approx 2985 \ \text{ms} ]
[ \text{E2E} = 120 + 2985 \approx 3.1 \ \text{s}, \qquad \text{TPOT} = \frac{3105 - 120}{200 - 1} \approx 15 \ \text{ms}, \qquad \text{user TPS} = \frac{200}{2.985} \approx 67 \ \text{tok/s} ]
Two lessons. First, decode dominates E2E here (~3 s vs ~120 ms) — output length is your biggest latency lever, which is why capping max_new_tokens matters so much. Second, if you stream, the user sees a token at 120 ms instead of waiting 3.1 s: identical work, far better perceived latency. Streaming trades nothing in throughput; it only reshapes when the user first sees output.
Why one-request-at-a-time is wasteful — the road to batching
The naive server above processes requests serially. During each request’s decode phase the GPU is memory-bandwidth-bound and its tensor cores are mostly idle — you are paying for an A100/H100 and using a fraction of its FLOPs. Meanwhile a second caller just waits.
Batching fixes this by running multiple sequences through the model together, so a single read of the weights (and a single scheduling step) produces a token for every request in the batch. Because decode was memory-bound, adding more requests is nearly free on compute up to a point — you convert idle compute into throughput.
There are three flavors, in increasing sophistication (full treatment in Chapter 5):
| Batching strategy | How it works | Weakness |
|---|---|---|
| Static batching | Collect (N) requests, pad to the same length, run them together to completion. | Head-of-line blocking: the whole batch waits for the slowest/longest sequence; padding wastes compute; new requests wait for the batch to finish. |
| Dynamic batching | Server briefly buffers incoming requests (a few ms) to form a batch, then runs it. Common in Triton. | Still runs the batch to completion; a short request is stuck behind a long one. |
| Continuous batching (a.k.a. in-flight / iteration-level) | The scheduler works at the granularity of a single decode step: finished sequences leave the batch and new ones join every iteration. | More complex; needs paged KV memory to do well. This is what vLLM and TGI do, and it is the big throughput unlock. |
The mental model: static/dynamic batching batches requests; continuous batching batches token-generation steps. The latter keeps the GPU full even when requests have wildly different lengths — which is the normal case. This is the single biggest reason a dedicated engine outperforms the naive server, often by an order of magnitude in throughput at the same latency.
Failure modes and pitfalls
The naive server fails in predictable ways. Know them cold.
- CUDA out of memory (OOM). The #1 killer. Causes: model too big for the GPU in the chosen dtype; too many concurrent requests inflating the KV cache; a single very long prompt/output. Symptoms:
CUDA out of memory. Tried to allocate .... Fixes: smaller dtype/quantization, capmax_new_tokensand context length, limit concurrency, use an engine with paged KV. Do the memory math before deploying. - Blocking the async event loop. FastAPI is async, but
model.generate()is a long, synchronous, GPU-bound call. If youawaitit directly in the handler (or call it inline), it freezes the entire event loop — health checks time out, every other connection stalls. Fix:run_in_threadpool(as above) or a dedicated worker/queue. This bug looks like “the server randomly hangs under load.” - No batching / serial serving. Two users -> the second waits for the first. Throughput is capped at one request’s worth of decode, and the expensive GPU sits underutilized. This is not a bug to fix in this server — it is the reason to graduate to vLLM/TGI.
- Tokenizer mismatch / wrong chat template. Using a tokenizer from a different model, skipping
apply_chat_template, or missing special tokens produces fluent-looking garbage with no error. Always pair the exact tokenizer with the model and use the official chat template. - Padding-side and pad-token mistakes. Right-padding a decoder-only batch, or a missing
pad_token, silently corrupts batched generation. Left-pad; setpad_token = eos_tokenif absent. - FP32 by accident. Forgetting
torch_dtypeloads in FP32 and doubles weight memory — an instant OOM on models that would fit fine in BF16. - Unbounded generation. No
max_new_tokensand a wrong/missing EOS -> the model runs until it hits some default cap, burning GPU time and blocking others. Always bound output. - Cold-start latency ignored. First request after a scale-up waits for model download + load (seconds to minutes). Add readiness probes (
/healthzgating on model-loaded) so traffic is not routed to a not-yet-ready pod (Chapters 3, 6).
Tools comparison (brief — pointers to later chapters)
| What it is | Batching | Best for | Covered in | |
|---|---|---|---|---|
| Raw Transformers + FastAPI | Hand-rolled server (this chapter) | None (DIY) | Learning, prototypes, custom logic | This chapter |
| vLLM | High-throughput OSS inference engine | Continuous + PagedAttention | Throughput-critical OSS serving; OpenAI-compatible API | Chapter 5 |
| TGI (Text Generation Inference) | Hugging Face’s production server (Rust + Python) | Continuous, tensor-parallel sharding | HF ecosystem, Inference Endpoints | referenced Ch. 5 |
| Triton Inference Server | NVIDIA multi-framework server | Dynamic batching; pairs with TensorRT-LLM / vLLM backends | Multi-model, mixed workloads, tight NVIDIA stack | Chapter 12 |
Rule of thumb: build the raw server once to understand the loop, then never ship it. For anything real, reach for an engine that does continuous batching and paged KV memory. TorchServe is another general-purpose model server in this space, but for LLMs specifically the KV-cache-aware engines (vLLM, TGI, TensorRT-LLM) win decisively.
Production checklist — what an interviewer probes
- “Walk me through a request.” Expected: request -> tokenize (+ chat template) -> prefill (compute-bound, sets TTFT) -> decode loop reusing KV cache (memory-bound, sets TPOT) -> detokenize -> response. Naming the prefill/decode split is the tell that you actually understand serving.
- “How much GPU memory does model X need?” Expected: weights = params × bytes/param (~2 GB/B in FP16); plus KV cache = (2 \times L \times H_{kv} \times D_h \times b) per token per request; plus activations/overhead. Know the ~0.5 MB/token, ~2 GB-per-4K-request order of magnitude.
- “Your p99 latency is bad but the GPU is at 30% util — why?” Expected: decode is memory-bandwidth-bound and you are serving serially / with tiny batches; add continuous batching to convert idle compute into throughput.
- “How do you trade latency for throughput?” Expected: batch size is the dial; larger batches amortize weight reads (higher throughput) at the cost of per-request TTFT/TPOT; pick per SLO; use continuous batching to get most of the throughput without static batching’s head-of-line blocking.
- “What’s your OOM story?” Expected: memory math up front; cap
max_new_tokensand context; limit concurrency; quantize; paged KV; monitor KV-cache utilization, not just GPU memory. - “Why not just
await model.generate()in the handler?” Expected: it blocks the event loop; offload to a threadpool/worker or use an engine with its own scheduler. - “Which metrics do you alert on?” Expected: TTFT, TPOT/ITL, E2E — all at p50/p95/p99 — plus throughput (tok/s, req/s), queue depth, KV-cache utilization, and error/OOM rate. Percentiles, not means.
- “When do you reach for vLLM/TGI/Triton over your own server?” Expected: as soon as you need concurrency — continuous batching + paged KV are hard to build well and are the whole point; roll your own only to learn or for genuinely custom logic.
Further reading
- Hugging Face — Text generation tutorial (
generate): https://huggingface.co/docs/transformers/en/llm_tutorial - Hugging Face — Generation config /
GenerationConfigreference: https://huggingface.co/docs/transformers/en/main_classes/text_generation - FastAPI documentation: https://fastapi.tiangolo.com/
- Text Generation Inference (TGI) docs: https://huggingface.co/docs/text-generation-inference/en/index
- TGI source and features: https://github.com/huggingface/text-generation-inference
- vLLM — PagedAttention & continuous batching overview: https://www.runpod.io/articles/guides/vllm-pagedattention-continuous-batching
- NVIDIA — Mastering LLM Techniques: Inference Optimization (KV cache, batching): https://developer.nvidia.com/blog/mastering-llm-techniques-inference-optimization/
- Anyscale — Understand LLM latency and throughput metrics (TTFT/TPOT/ITL/TPS/RPS): https://docs.anyscale.com/llm/serving/benchmarking/metrics
- KV cache memory calculation (worked example): https://mbrenndoerfer.com/writing/kv-cache-memory-calculation-llm-inference-gpu
- Pierre Lienhart — LLM Inference Series: KV caching, a deeper look: https://medium.com/@plienhar/llm-inference-series-4-kv-caching-a-deeper-look-4ba9a77746c8
- NVIDIA Triton Inference Server: https://github.com/triton-inference-server/server
Where this goes next: Chapter 2 containerizes this server; Chapter 4 load-tests it to see the serial bottleneck; Chapter 5 replaces it with vLLM and continuous batching to fix everything this chapter exposed.
Topic 2: Containerization with Docker
What You’ll Learn
This topic teaches you how to:
- Containerize LLM serving applications
- Create efficient Docker images for ML workloads
- Handle large model files
- Use multi-stage builds
- Configure environment variables
- Optimize image size
Why We Need This
Business Need
- Consistency: Same model behavior in dev, staging, production
- Speed to market: Deploy faster without environment setup
- Cost reduction: Standardized deployment reduces ops overhead
- Compliance: Reproducible deployments for audit trails
Technical Need
- Environment consistency: Python version, dependencies, system libraries
- Isolation: No conflicts between different ML projects
- Portability: Run anywhere (local, cloud, edge)
- Versioning: Tag images with model versions for rollback
Real-World Impact
Without containerization:
- ❌ “Works on my machine” problems
- ❌ Difficult to reproduce production issues
- ❌ Slow deployments (manual environment setup)
- ❌ Can’t scale easily (each server needs manual setup)
Industry Use Cases
1. Multi-Cloud Deployment
Company: Enterprise ML platforms Use Case:
- Same Docker image runs on AWS, GCP, Azure
- No vendor lock-in, easy migration
Example:
# Build once, deploy anywhere
docker build -t llm-model:v1.0 .
# Deploy to AWS
docker push llm-model:v1.0
# Deploy to GCP
docker push llm-model:v1.0
2. CI/CD Pipelines
Company: All tech companies Use Case:
- Automated testing in containers
- Consistent environments across pipeline stages
Example:
# GitHub Actions / GitLab CI
- name: Test model
run: |
docker build -t test-model .
docker run test-model pytest
3. Edge Deployment
Company: IoT, autonomous vehicles Use Case:
- Deploy models to edge devices
- Same container on server and edge
Example:
# Deploy to edge device
docker save llm-model:v1.0 | ssh edge-device docker load
4. Model Versioning & Rollback
Company: ML platforms (MLflow, Weights & Biases) Use Case:
- Tag images with model versions
- Quick rollback to previous version
Example:
docker tag llm-model:latest llm-model:v1.2.3
docker tag llm-model:latest llm-model:v1.2.2 # Rollback
5. Development Teams
Company: All companies with ML teams Use Case:
- New developers can run models immediately
- No “works on my machine” issues
Example:
# New developer setup
git clone repo
docker-compose up # Everything works!
Industry-Standard Boilerplate Code
Production Dockerfile (Industry Standard)
# Multi-stage build for production (used by: Google, AWS, Microsoft)
# Stage 1: Builder
FROM python:3.9-slim as builder
WORKDIR /app
# Install build dependencies
RUN apt-get update && apt-get install -y \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install
COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt
# Stage 2: Runtime (minimal image)
FROM python:3.9-slim
WORKDIR /app
# Copy Python dependencies from builder
COPY --from=builder /root/.local /root/.local
# Make sure scripts are in PATH
ENV PATH=/root/.local/bin:$PATH
# Create non-root user (security best practice)
RUN useradd -m -u 1000 appuser && \
chown -R appuser:appuser /app
USER appuser
# Copy application
COPY --chown=appuser:appuser . .
# Health check (for K8s, load balancers)
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD python -c "import requests; requests.get('http://localhost:8000/health')" || exit 1
# Expose port
EXPOSE 8000
# Run application
CMD ["python", "app.py"]
Docker Compose for Development (Industry Standard)
# docker-compose.yml
# Used by: Development teams, local testing
version: '3.8'
services:
llm-serving:
build:
context: .
dockerfile: Dockerfile
ports:
- "8000:8000"
environment:
- MODEL_NAME=gpt2
- DEVICE=cpu
- LOG_LEVEL=INFO
volumes:
# Mount code for development (hot reload)
- .:/app
# Mount models directory
- ./models:/app/models
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped
# Optional: Add monitoring
prometheus:
image: prom/prometheus:latest
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
Build & Deploy Script (Industry Standard)
#!/bin/bash
# build-and-deploy.sh
# Used by: CI/CD pipelines, deployment automation
set -e # Exit on error
IMAGE_NAME="llm-serving"
VERSION="${1:-latest}"
REGISTRY="your-registry.com" # Docker Hub, ECR, GCR, etc.
echo "Building image: ${IMAGE_NAME}:${VERSION}"
# Build image
docker build -t ${IMAGE_NAME}:${VERSION} .
# Tag for registry
docker tag ${IMAGE_NAME}:${VERSION} ${REGISTRY}/${IMAGE_NAME}:${VERSION}
docker tag ${IMAGE_NAME}:${VERSION} ${REGISTRY}/${IMAGE_NAME}:latest
# Push to registry
docker push ${REGISTRY}/${IMAGE_NAME}:${VERSION}
docker push ${REGISTRY}/${IMAGE_NAME}:latest
echo "Image pushed: ${REGISTRY}/${IMAGE_NAME}:${VERSION}"
# Deploy (example for K8s)
# kubectl set image deployment/llm-serving llm-serving=${REGISTRY}/${IMAGE_NAME}:${VERSION}
GPU Dockerfile (Industry Standard)
# GPU-enabled Dockerfile
# Used by: Production ML serving, high-performance inference
FROM nvidia/cuda:11.8.0-cudnn8-runtime-ubuntu22.04
# Install Python
RUN apt-get update && apt-get install -y \
python3.9 \
python3-pip \
curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Install PyTorch with CUDA
RUN pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118
# Install other dependencies
COPY requirements.txt .
RUN pip3 install --no-cache-dir -r requirements.txt
# Copy application
COPY . .
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
EXPOSE 8000
CMD ["python3", "app.py"]
Usage in Production
# Build for production
docker build -t llm-serving:prod -f Dockerfile.prod .
# Run with GPU
docker run --gpus all -p 8000:8000 llm-serving:prod
# Run with resource limits
docker run \
--memory="4g" \
--cpus="2.0" \
-p 8000:8000 \
llm-serving:prod
# Run in production (with restart policy)
docker run -d \
--name llm-serving \
--restart unless-stopped \
-p 8000:8000 \
llm-serving:prod
Key Concepts
Dockerfile Basics
FROM python:3.9-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["python", "app.py"]
Multi-Stage Builds
Split build into stages to reduce final image size:
- Builder stage: Install dependencies, download models
- Runtime stage: Copy only what’s needed
Layer Caching
Order matters! Put frequently changing files last:
# Good: Dependencies change less often
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . . # Code changes more often
Dockerfile Examples
Basic Dockerfile
See Dockerfile.basic for a simple example.
Optimized Dockerfile
See Dockerfile.optimized for:
- Multi-stage build
- Model caching
- Smaller image size
- Better layer caching
GPU Dockerfile
See Dockerfile.gpu for CUDA/GPU support.
Building Images
Basic Build
docker build -t llm-serving:latest .
With Build Args
docker build \
--build-arg MODEL_NAME=gpt2 \
--build-arg PYTHON_VERSION=3.9 \
-t llm-serving:gpt2 .
Multi-stage Build
docker build -f Dockerfile.optimized -t llm-serving:optimized .
Running Containers
CPU Mode
docker run -p 8000:8000 llm-serving:latest
GPU Mode (NVIDIA Docker)
docker run --gpus all -p 8000:8000 llm-serving:gpu
With Environment Variables
docker run \
-p 8000:8000 \
-e MODEL_NAME=gpt2 \
-e MAX_LENGTH=100 \
llm-serving:latest
With Volume Mounts (for models)
docker run \
-p 8000:8000 \
-v ./models:/app/models \
llm-serving:latest
Docker Compose
For multi-container setups, use docker-compose.yml:
- Application container
- Model storage
- Monitoring
- Load balancer
Image Optimization
Reduce Image Size
- Use slim base images:
python:3.9-sliminstead ofpython:3.9 - Multi-stage builds: Don’t include build tools in final image
- Remove cache:
pip install --no-cache-dir - Combine RUN commands: Fewer layers = smaller image
Speed Up Builds
- Layer caching: Order Dockerfile commands by change frequency
- Build cache: Use
--cache-fromfor CI/CD - Parallel builds: Build multiple images simultaneously
Handling Large Models
Option 1: Download in Image
RUN python -c "from transformers import AutoModel; AutoModel.from_pretrained('gpt2')"
Pros: Self-contained Cons: Large image, slow builds
Option 2: Volume Mount
docker run -v ./models:/app/models ...
Pros: Small image, fast builds Cons: Need models on host
Option 3: Model Registry
Download from S3/GCS at runtime Pros: Flexible, versioned Cons: Requires network access
Best Practices
- Use .dockerignore: Exclude unnecessary files
- Tag images: Use semantic versioning
- Health checks: Add HEALTHCHECK instruction
- Non-root user: Run as non-root for security
- Resource limits: Set memory/CPU limits
- Logging: Configure proper logging
Exercises
- Build basic image: Create Dockerfile for basic serving
- Optimize image: Reduce image size by 50%
- Multi-stage build: Create optimized multi-stage Dockerfile
- GPU support: Add CUDA support to Dockerfile
- Docker Compose: Create compose file for full stack
Common Issues
Out of Memory
- Problem: Container runs out of memory
- Solution: Increase Docker memory limit or use smaller model
Slow Builds
- Problem: Building takes forever
- Solution: Use layer caching, download models in separate stage
GPU Not Available
- Problem:
CUDA not availablein container - Solution: Install nvidia-docker, use
--gpus allflag
Large Image Size
- Problem: Image is several GB
- Solution: Use multi-stage build, slim base images
Next Steps
- Topic 3: Deploy containers to Kubernetes
- Topic 4: Load test your containerized app
- Topic 5: Optimize for production with vLLM
Further Reading
Docker for GPU LLM Serving
Containerizing an LLM inference service so it runs the same on your laptop, in CI, and on a rented H100 — without a 40 GB image, a leaked token, or a
CUDA driver version is insufficientat 3 a.m.
Why this matters
An LLM server is not a normal web app. It links against CUDA, cuDNN, NCCL, and a specific PyTorch build; it needs a physical GPU exposed into the container; and it depends on multi-gigabyte model weights that you do not want to redownload on every restart. Get the containerization wrong and you hit one of a dozen classic failures: the image balloons to tens of gigabytes, the container can’t see the GPU, the CUDA version mismatches the host driver, your Hugging Face token ends up baked into a layer, or the server runs as root with no healthcheck and the orchestrator can’t tell it’s wedged.
Containers are also the unit that Kubernetes, Nomad, ECS, and every autoscaler schedule. A well-built image is the foundation for everything in later chapters (K8s, canary, autoscaling). This chapter is about getting that foundation right.
The intuition first, then the exact mechanisms.
Core intuition
Three ideas carry most of the weight.
1. The container shares the host’s GPU driver, not its own. You never install an NVIDIA driver inside the image. The driver is a kernel module and lives on the host. The container ships userspace CUDA libraries. At docker run time, the NVIDIA Container Toolkit injects the host driver’s device files and libraries into the container. So the contract is: host driver must be new enough for the container’s CUDA userspace. This is the single most important mental model in GPU containers.
2. Model weights are data, not code. Code changes daily and is tiny; a 14 GB weights file changes rarely and is huge. Baking weights into an image layer couples the two lifecycles badly. The default for production is to keep weights out of the image and bring them in as a mounted volume or a startup download into a persistent cache.
3. Build image ≠ runtime image. The tools you need to compile CUDA kernels (nvcc, headers, build-essential) are hundreds of MB you never need to run the server. Multi-stage builds let you compile in a fat stage and copy only the artifacts into a lean runtime stage.
Hold these three and the rest is detail.
Mechanism 1 — GPU base images and the CUDA/driver contract
The nvidia/cuda image family
NVIDIA publishes nvidia/cuda images on Docker Hub and NGC. Tags follow the pattern:
nvidia/cuda:<cuda_version>-<flavor>-<os>
# e.g.
nvidia/cuda:12.4.1-runtime-ubuntu22.04
nvidia/cuda:12.4.1-cudnn-runtime-ubuntu22.04
nvidia/cuda:12.4.1-devel-ubuntu22.04
There are three flavors, and picking the wrong one is a common source of bloat:
| Flavor | Contains | Size (approx) | Use it for |
|---|---|---|---|
base | CUDA runtime libs minimum | ~200 MB | Rare; you usually need more |
runtime | CUDA runtime + math libs (cuBLAS), optionally cuDNN | ~2–3 GB | Final runtime stage of an inference server |
devel | Everything in runtime + nvcc, headers, static libs | ~5–7 GB | Build stage when you compile kernels |
Rule of thumb: build in devel, ship on runtime. If your framework ships prebuilt wheels (most do), you may not even need devel.
Driver/runtime compatibility
The host driver exposes a maximum supported CUDA version. CUDA has forward compatibility within a major version and minor-version compatibility so that, e.g., a driver supporting CUDA 12.2 can generally run CUDA 12.4 userspace on data-center GPUs via the compat package — but do not rely on this casually. The safe posture:
- Check the host:
nvidia-smiprintsDriver VersionandCUDA Version(the max CUDA the driver supports). - Pick a container CUDA version ≤ that, or confirm forward-compat coverage.
- Pin the container CUDA minor version explicitly (
12.4.1, not12).
The failure you’re avoiding looks like:
CUDA driver version is insufficient for CUDA runtime version
That means the container’s CUDA userspace is newer than the host driver can serve. Fix by upgrading the host driver or downgrading the image’s CUDA version — you cannot fix it inside the image.
Mechanism 2 — The NVIDIA Container Toolkit and --gpus
A plain docker run gives the container no GPU. Two pieces make it work.
The toolkit
The NVIDIA Container Toolkit is a set of host packages (nvidia-container-toolkit, the nvidia-ctk CLI, and a runtime shim) that automatically configure a container to use NVIDIA GPUs by mounting the driver libraries and device nodes at startup. You install it on the host, once:
# 1. Add NVIDIA's apt repo (see official install guide for the current key/URL)
sudo apt-get install -y nvidia-container-toolkit
# 2. Wire it into the Docker daemon (writes the "nvidia" runtime into
# /etc/docker/daemon.json)
sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker
Prerequisite: the NVIDIA driver is already installed on the host. You do not install the CUDA toolkit on the host — only the driver.
Verify the whole chain end-to-end:
docker run --rm --gpus all nvidia/cuda:12.4.1-base-ubuntu22.04 nvidia-smi
If that prints your GPU table, host driver + toolkit + runtime are all good. This is the first thing to run when a GPU container “can’t see the GPU.”
--gpus
Once the toolkit is installed, --gpus selects which GPUs to expose:
--gpus all # all GPUs
--gpus '"device=0,1"' # only GPU 0 and 1 (note the nested quoting)
--gpus 2 # any 2 GPUs
Under the hood this sets NVIDIA_VISIBLE_DEVICES and triggers the toolkit’s injection hook. Older stacks used --runtime=nvidia plus that env var directly; --gpus is the modern, preferred flag. Some tools (vLLM’s docs) still show --runtime nvidia — it’s equivalent when the runtime is registered.
Mechanism 3 — Handling large model weights
This is where architecture decisions bite hardest. You have three options.
Option A — Bake weights into the image
Copy the weights in during build (COPY ./model /model). The image is fully self-contained: pull it and run, no network, no external volume.
- Pro: hermetic, reproducible, air-gap friendly, one artifact to sign/scan.
- Con: the image is now 15–150 GB. Every push/pull moves all of it. Layer caching is useless once weights change. Registry storage costs balloon. Build context upload is slow.
- Verdict: reserve for small models, air-gapped/regulated deployments, or when the exact weights are part of your release contract.
Option B — Mount weights as a volume
Keep weights on the host / network storage and bind-mount at runtime (-v /data/models:/models). This is what the vLLM and TGI official images assume.
- Pro: small image, weights shared across containers and versions, swap models without rebuilding, fast cold builds.
- Con: image is no longer self-contained; you must provision and pre-populate the volume; in K8s you need a
PersistentVolume/hostPath/ CSI mount. - Verdict: the default for most production on a fixed node pool or shared filesystem.
Option C — Download at startup into a cache
The container downloads weights from Hugging Face (or S3/GCS) on first boot into a persistent cache directory, then reuses the cache on restart.
- Pro: smallest image, model chosen by env var, trivially swappable.
- Con: cold start pays a multi-GB download; needs network egress + a token for gated models; if the cache volume isn’t persistent you redownload every restart (a classic and expensive bug); registry outages ≠ HF outages now both matter.
- Verdict: great for dev, experimentation, and autoscaling where a warm cache volume (or a pre-baked node image) hides the download.
The Hugging Face cache — make it persist
The huggingface_hub library caches downloads under HF_HOME (default ~/.cache/huggingface; the hub cache is \(HF\_HOME\)/hub). The env vars that matter:
HF_HOME— root of all HF caches. Set this and everything follows.HF_HUB_CACHE(older:HUGGINGFACE_HUB_CACHE) — the model blob cache specifically.HF_TOKEN— auth for gated/private models.
The whole point of Options B/C is to mount a volume at the cache path so weights survive container restarts:
# vLLM: cache lives at /root/.cache/huggingface inside the image
-v ~/.cache/huggingface:/root/.cache/huggingface
# TGI: the official image sets HUGGINGFACE_HUB_CACHE=/data, so mount /data
-v $PWD/data:/data
Miss this mount and every docker run redownloads the model — slow, costly, and rate-limit-prone.
Mechanism 4 — Multi-stage builds, layer caching, and .dockerignore
Multi-stage
A multi-stage build uses multiple FROM statements. Early stages compile; the final stage copies only what’s needed. For an LLM server that means: compile custom kernels / install a heavy build toolchain in a devel stage, then COPY --from=builder the installed environment into a slim runtime stage. The devel layers never ship.
Layer caching order
Docker caches each instruction as a layer and reuses it until an input changes; everything after a changed layer is rebuilt. So order from least-to-most volatile:
- Base image
- System packages (
apt-get) - Dependency manifests (
requirements.txt/pyproject.toml) andpip install - Application source code
Copy requirements.txt and install before copying your source. Then a one-line code change reuses the (slow) dependency layer instead of reinstalling PyTorch every build. Use BuildKit cache mounts for the pip/uv cache to speed rebuilds further:
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
.dockerignore
The build context is everything Docker uploads to the daemon before building. Without a .dockerignore, a stray ./models, .git, __pycache__, or a 30 GB checkpoint gets shipped into the build — slow, and a vector for secrets and bloat. A minimal one:
.git
__pycache__/
*.pyc
*.pt
*.safetensors
models/
data/
.env
*.log
.venv/
This is also your first line of defense against COPY . . accidentally baking weights or a .env file into a layer.
Mechanism 5 — Reproducibility, security, healthchecks, config
Pin everything. python:3.11-slim is a moving target. Prefer a digest for the base and pinned versions for packages:
FROM nvidia/cuda:12.4.1-cudnn-runtime-ubuntu22.04@sha256:<digest>
Pin your Python deps (lockfile or ==), and tag your own images with an immutable version, never rely on :latest in production.
Run as non-root. By default containers run as UID 0. A container escape from root is worse than from an unprivileged user. Create a user and drop to it:
RUN useradd --create-home --uid 10001 appuser
USER appuser
Note some GPU stacks and cache paths assume /root; if you run non-root, point HF_HOME at a directory that user can write.
Keep secrets out of layers. Never ENV HF_TOKEN=hf_xxx or COPY .env — both persist in the image history for anyone who pulls it. Pass secrets at runtime (-e HF_TOKEN=..., Docker/K8s secrets) or use BuildKit --secret mounts for build-time-only credentials.
Add a HEALTHCHECK. Orchestrators restart containers that fail their health probe. For an OpenAI-compatible server, probe the health/models endpoint:
HEALTHCHECK --interval=30s --timeout=5s --start-period=180s --retries=3 \
CMD curl -fsS http://localhost:8000/health || exit 1
The long start-period matters: model load can take minutes, and you don’t want the container killed during warmup.
Config via env, not baked files. Model id, tensor-parallel size, port, and dtype should be env vars / CLI args so one image serves many configs.
Fully worked example
A real, multi-stage GPU Dockerfile for a vLLM-based OpenAI-compatible server. (If you just want vLLM, the official vllm/vllm-openai image is usually the right call — this shows the general pattern you’d use for a custom server or a framework without an official image.)
# syntax=docker/dockerfile:1.7
###############################################################################
# Stage 1: builder — has nvcc + build tools, compiles/installs the env.
###############################################################################
FROM nvidia/cuda:12.4.1-devel-ubuntu22.04 AS builder
ENV DEBIAN_FRONTEND=noninteractive \
PIP_NO_CACHE_DIR=0 \
PYTHONDONTWRITEBYTECODE=1
# System build deps. Pin, clean apt lists to keep the layer lean.
RUN apt-get update && apt-get install -y --no-install-recommends \
python3.11 python3.11-venv python3-pip build-essential git \
&& rm -rf /var/lib/apt/lists/*
# Isolated virtualenv so we can copy the whole thing to the runtime stage.
RUN python3.11 -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Dependency layer FIRST — cached across code changes.
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --upgrade pip && pip install -r requirements.txt
###############################################################################
# Stage 2: runtime — slim CUDA runtime, no compilers, non-root, healthcheck.
###############################################################################
FROM nvidia/cuda:12.4.1-cudnn-runtime-ubuntu22.04 AS runtime
ENV DEBIAN_FRONTEND=noninteractive \
PATH="/opt/venv/bin:$PATH" \
PYTHONUNBUFFERED=1 \
# Persist weights here; mount a volume at this path (see docker run).
HF_HOME=/models/hf
# Only the runtime OS deps: python + curl (for healthcheck). No build-essential.
RUN apt-get update && apt-get install -y --no-install-recommends \
python3.11 curl \
&& rm -rf /var/lib/apt/lists/*
# Copy the fully-built virtualenv from the builder stage — no nvcc ships.
COPY --from=builder /opt/venv /opt/venv
# Non-root user that can write the cache dir.
RUN useradd --create-home --uid 10001 appuser \
&& mkdir -p /models/hf && chown -R appuser:appuser /models
COPY --chown=appuser:appuser ./app /app
WORKDIR /app
USER appuser
EXPOSE 8000
# Config comes from env / CLI at runtime, not baked in.
ENV MODEL_ID=meta-llama/Llama-3.1-8B-Instruct \
TENSOR_PARALLEL_SIZE=1
HEALTHCHECK --interval=30s --timeout=5s --start-period=300s --retries=3 \
CMD curl -fsS http://localhost:8000/health || exit 1
# Exec form so signals reach the process (clean shutdown).
ENTRYPOINT ["python3.11", "-m", "vllm.entrypoints.openai.api_server"]
CMD ["--host", "0.0.0.0", "--port", "8000"]
Build and run:
# Build (BuildKit on for cache mounts + syntax directive)
DOCKER_BUILDKIT=1 docker build -t my-llm-server:1.0.0 .
# Run: expose GPUs, mount the HF cache volume, pass secrets at runtime.
docker run --rm \
--gpus all \ # expose all GPUs (toolkit required)
--ipc=host \ # shared mem for NCCL / TP; see note
-p 8000:8000 \
-v $HOME/.cache/hf:/models/hf \ # persist weights across restarts
-e HF_TOKEN=$HF_TOKEN \ # gated-model auth, NOT baked in
my-llm-server:1.0.0 \
--model meta-llama/Llama-3.1-8B-Instruct \ # config via CLI
--tensor-parallel-size 1
Why --ipc=host? vLLM (and PyTorch tensor-parallel generally) uses shared memory (/dev/shm) for inter-process/GPU communication. Docker’s default /dev/shm is 64 MB, which causes cryptic crashes or hangs under load. --ipc=host (or --shm-size=1g) gives it room. TGI uses --shm-size 1g for the same reason.
Reference: the official one-liner most teams actually start from —
# vLLM official image
docker run --runtime nvidia --gpus all \
-v ~/.cache/huggingface:/root/.cache/huggingface \
--env HF_TOKEN=$HF_TOKEN -p 8000:8000 --ipc=host \
vllm/vllm-openai:latest --model mistralai/Mistral-7B-Instruct-v0.2
# TGI official image
docker run --gpus all --shm-size 1g -p 8080:80 \
-v $PWD/data:/data \
ghcr.io/huggingface/text-generation-inference:3.3.5 \
--model-id teknium/OpenHermes-2.5-Mistral-7B
Compose snippet
docker compose needs the deploy.resources.reservations.devices block to request GPUs:
services:
llm:
image: my-llm-server:1.0.0
ports:
- "8000:8000"
ipc: host # equivalent to --ipc=host
environment:
- HF_TOKEN=${HF_TOKEN} # sourced from host env / .env, not in image
- MODEL_ID=meta-llama/Llama-3.1-8B-Instruct
volumes:
- hf-cache:/models/hf # persistent named volume for weights
command: ["--model", "meta-llama/Llama-3.1-8B-Instruct"]
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all # or `device_ids: ["0","1"]`
capabilities: [gpu]
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8000/health"]
interval: 30s
timeout: 5s
start_period: 300s
retries: 3
volumes:
hf-cache:
Weights-handling comparison
| Dimension | Bake into image | Mount volume | Download at startup |
|---|---|---|---|
| Image size | Huge (15–150 GB) | Small | Smallest |
| Cold start | Fast (already present) | Fast (local mount) | Slow (multi-GB download) |
| Self-contained | Yes (air-gap ok) | No (needs volume) | No (needs network + token) |
| Swap models | Rebuild image | Change mount / env | Change env var |
| Registry cost / push | High | Low | Low |
| Reproducibility | Highest (weights pinned) | Depends on volume contents | Depends on HF tag/revision |
| Best for | Air-gapped, small, regulated | Fixed nodes, shared FS | Dev, autoscaling w/ warm cache |
Pin the model revision (commit SHA), not just the repo name, when reproducibility matters for Options B and C.
Failure modes and pitfalls
- Driver/runtime mismatch —
CUDA driver version is insufficient. Container CUDA newer than host driver supports. Fix host driver or lower image CUDA; can’t be patched in the image. - GPU invisible —
torch.cuda.is_available()isFalse, nonvidia-smiin container. Toolkit not installed,--gpusomitted, or runtime not registered. Test withdocker run --gpus all nvidia/cuda:...-base nvidia-smi. - Giant images — shipped the
develbase,aptlists left behind, weights baked in, orpipcache retained. Use multi-stage,--no-install-recommends, clean/var/lib/apt/lists, and keep weights out. - Redownloading weights every restart — cache path not mounted to a persistent volume, or
HF_HOMEpoints at an ephemeral dir. Mount a named volume at the cache path. - Secrets in layers —
ENV HF_TOKEN=...orCOPY .envpersists in image history forever. Pass at runtime or use BuildKit secrets. - Running as root — default UID 0; a bad default for security and for shared-filesystem permissions. Create and drop to a non-root user.
- No / bad healthcheck — orchestrator can’t detect a wedged server, or kills it during a 3-minute model load. Add a healthcheck with a generous
start-period. /dev/shmtoo small — default 64 MB causes NCCL/tensor-parallel hangs andBus error. Use--ipc=hostor--shm-size.:latesteverywhere — non-reproducible builds and surprise upgrades. Pin base by digest, deps by version, your image by semver.- Signals ignored — shell-form
CMDruns under/bin/shwhich doesn’t forward SIGTERM; use exec-formENTRYPOINT/CMDso shutdown is graceful and requests drain.
Tools and options comparison
| Option | What it is | When to reach for it |
|---|---|---|
nvidia/cuda:*-runtime | Slim CUDA userspace base | Final stage of a custom server |
nvidia/cuda:*-devel | CUDA + nvcc + headers | Build stage compiling kernels |
vllm/vllm-openai | Official vLLM OpenAI server image | Fast path to production vLLM |
ghcr.io/.../text-generation-inference | Official HF TGI image | HF-ecosystem serving, gated models |
| NVIDIA Container Toolkit | Host runtime that injects GPUs | Required for any GPU container |
BuildKit / docker buildx | Modern builder: cache mounts, secrets | Every build (faster, safer) |
dive / docker history | Inspect layers & size | Hunting image bloat |
docker scout / trivy | Image vulnerability scanning | CI gate before push |
Production checklist — what an interviewer probes
- “How does a container get access to the GPU?” — Host driver + NVIDIA Container Toolkit +
--gpus. You never install the driver in the image; the toolkit injects host driver libs at runtime. Verify withdocker run --gpus all ... nvidia-smi. - “runtime vs devel base image — which do you ship?” — Build in
devel, ship onruntimevia multi-stage. Shippingdevelis a multi-GB mistake. - “Where do the weights live and why?” — Articulate bake vs mount vs download and the cold-start/size/reproducibility tradeoffs; know that a mounted persistent HF cache (
HF_HOME) is the usual answer. - “How do you keep the image small?” — Multi-stage,
--no-install-recommends, clean apt lists,.dockerignore, don’t bake weights, BuildKit cache mounts. - “How do you handle the CUDA/driver version contract?” — Pin container CUDA ≤ host-driver-supported; understand minor-version/forward compatibility; recognize the “driver insufficient” error.
- “How do secrets and config get in?” — Runtime env / orchestrator secrets and BuildKit
--secret, neverENV/COPY .env(persists in layer history). - “Non-root, healthcheck, signals?” — Drop to an unprivileged UID, healthcheck with a long
start-periodfor model load, exec-form entrypoint for graceful SIGTERM draining. - “Why
--ipc=host/--shm-size?” — Tensor-parallel / NCCL uses/dev/shm; the 64 MB default causes hangs and bus errors under load.
Further reading
- NVIDIA Container Toolkit — repo and overview: https://github.com/NVIDIA/nvidia-container-toolkit
- NVIDIA Container Toolkit — install guide: https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html
nvidia/cudaimage tags (Docker Hub): https://hub.docker.com/r/nvidia/cuda/tags- CUDA container supported tags & flavors: https://gitlab.com/nvidia/container-images/cuda/-/blob/master/doc/supported-tags.md
- vLLM — Using Docker: https://docs.vllm.ai/en/stable/deployment/docker/
- Hugging Face TGI — Nvidia GPU install: https://huggingface.co/docs/text-generation-inference/en/installation_nvidia
- Hugging Face TGI — Quick Tour: https://huggingface.co/docs/text-generation-inference/quicktour
- Hugging Face Hub — Understand caching (
HF_HOME): https://huggingface.co/docs/huggingface_hub/en/guides/manage-cache - Docker — Multi-stage builds: https://docs.docker.com/build/building/multi-stage/
- Docker — Building best practices: https://docs.docker.com/build/building/best-practices/
- Docker — Build cache & BuildKit: https://docs.docker.com/build/cache/
- Docker Compose — GPU support: https://docs.docker.com/compose/how-tos/gpu-support/
Topic 3: Kubernetes Deployment
What You’ll Learn
This topic teaches you how to:
- Deploy LLM serving applications to Kubernetes
- Configure health checks and probes
- Set resource limits and requests
- Use ConfigMaps and Secrets
- Handle rolling updates
- Scale applications
Why We Need This
Business Need
- Cost optimization: Auto-scale down during low traffic, save money
- Reliability: 99.9% uptime SLA requires automatic recovery
- Speed: Deploy updates in seconds, not hours
- Compliance: Resource limits ensure fair resource usage
Technical Need
- Orchestration: Manage hundreds of containers automatically
- Resource isolation: Prevent one service from starving others
- Service discovery: Automatic load balancing across pods
- Self-healing: Restart failed containers automatically
Real-World Impact
Without Kubernetes:
- ❌ Manual scaling (slow, error-prone)
- ❌ Downtime during deployments
- ❌ Resource conflicts between services
- ❌ Difficult to manage at scale (100+ services)
Industry Use Cases
1. Large-Scale ML Platforms
Company: OpenAI, Anthropic, HuggingFace Use Case:
- Serve millions of API requests per day
- Auto-scale based on demand
- Zero-downtime deployments
Example:
# Handles traffic spikes automatically
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
spec:
minReplicas: 10
maxReplicas: 1000 # Scale to 1000 pods during peak
2. Enterprise ML Infrastructure
Company: Banks, healthcare, finance Use Case:
- Multi-tenant ML serving
- Resource quotas per team
- Compliance and audit trails
Example:
# Resource quotas per namespace
apiVersion: v1
kind: ResourceQuota
metadata:
name: ml-team-quota
spec:
hard:
requests.cpu: "100"
requests.memory: 200Gi
limits.nvidia.com/gpu: "10"
3. Multi-Region Deployment
Company: Global SaaS companies Use Case:
- Deploy same model to multiple regions
- Low latency for global users
- Regional failover
Example:
# Deploy to multiple regions
kubectl apply -f deployment.yaml --context us-east
kubectl apply -f deployment.yaml --context eu-west
kubectl apply -f deployment.yaml --context asia-pacific
4. A/B Testing Infrastructure
Company: Tech companies (Netflix, Spotify) Use Case:
- Run multiple model versions simultaneously
- Split traffic between versions
- Compare performance
Example:
# Deploy model A and B
kubectl apply -f model-a-deployment.yaml
kubectl apply -f model-b-deployment.yaml
# Route 50% traffic to each
5. Development/Staging/Production
Company: All companies Use Case:
- Same deployment config for all environments
- Easy promotion from dev → staging → prod
Example:
# Deploy to dev
kubectl apply -f deployment.yaml -n dev
# Test, then promote to staging
kubectl apply -f deployment.yaml -n staging
# Finally, production
kubectl apply -f deployment.yaml -n production
Industry-Standard Boilerplate Code
Complete Production Deployment (Industry Standard)
# deployment.yaml
# Used by: Production ML serving at scale
apiVersion: v1
kind: Namespace
metadata:
name: llm-serving
---
apiVersion: v1
kind: ConfigMap
metadata:
name: llm-config
namespace: llm-serving
data:
model_name: "gpt2"
max_length: "100"
temperature: "0.7"
log_level: "INFO"
---
apiVersion: v1
kind: Secret
metadata:
name: llm-secrets
namespace: llm-serving
type: Opaque
stringData:
api_key: "your-api-key-here"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: llm-serving
namespace: llm-serving
labels:
app: llm-serving
version: v1.0.0
spec:
replicas: 3 # High availability
selector:
matchLabels:
app: llm-serving
template:
metadata:
labels:
app: llm-serving
version: v1.0.0
spec:
containers:
- name: llm-serving
image: your-registry/llm-serving:v1.0.0
imagePullPolicy: Always
ports:
- containerPort: 8000
name: http
env:
# From ConfigMap
- name: MODEL_NAME
valueFrom:
configMapKeyRef:
name: llm-config
key: model_name
- name: MAX_LENGTH
valueFrom:
configMapKeyRef:
name: llm-config
key: max_length
# From Secret
- name: API_KEY
valueFrom:
secretKeyRef:
name: llm-secrets
key: api_key
resources:
requests:
memory: "4Gi"
cpu: "2000m"
nvidia.com/gpu: 1 # GPU request
limits:
memory: "8Gi"
cpu: "4000m"
nvidia.com/gpu: 1 # GPU limit
# Health checks (critical for production)
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 60 # Model loading time
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 30
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
startupProbe:
httpGet:
path: /health
port: 8000
failureThreshold: 30 # Allow 5 minutes for startup
periodSeconds: 10
# Node selector for GPU nodes
nodeSelector:
accelerator: nvidia-tesla-v100
# Tolerations for GPU nodes
tolerations:
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
---
apiVersion: v1
kind: Service
metadata:
name: llm-serving
namespace: llm-serving
spec:
type: ClusterIP
ports:
- port: 80
targetPort: 8000
protocol: TCP
name: http
selector:
app: llm-serving
---
# Ingress for external access
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: llm-ingress
namespace: llm-serving
annotations:
kubernetes.io/ingress.class: nginx
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
tls:
- hosts:
- llm-api.yourcompany.com
secretName: llm-tls
rules:
- host: llm-api.yourcompany.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: llm-serving
port:
number: 80
Deployment Script (Industry Standard)
#!/bin/bash
# deploy.sh
# Used by: CI/CD pipelines, deployment automation
set -e
NAMESPACE="llm-serving"
IMAGE_TAG="${1:-latest}"
ENVIRONMENT="${2:-staging}"
echo "Deploying to ${ENVIRONMENT} with image tag ${IMAGE_TAG}"
# Create namespace if it doesn't exist
kubectl create namespace ${NAMESPACE} --dry-run=client -o yaml | kubectl apply -f -
# Update image in deployment
kubectl set image deployment/llm-serving \
llm-serving=your-registry/llm-serving:${IMAGE_TAG} \
-n ${NAMESPACE}
# Wait for rollout
kubectl rollout status deployment/llm-serving -n ${NAMESPACE} --timeout=5m
# Verify deployment
kubectl get pods -n ${NAMESPACE}
kubectl get svc -n ${NAMESPACE}
echo "Deployment complete!"
Rollback Script (Industry Standard)
#!/bin/bash
# rollback.sh
# Used when issues are detected in production
NAMESPACE="llm-serving"
echo "Rolling back deployment..."
kubectl rollout undo deployment/llm-serving -n ${NAMESPACE}
kubectl rollout status deployment/llm-serving -n ${NAMESPACE}
echo "Rollback complete!"
Key Concepts
- Pods: Smallest deployable unit (your container)
- Deployments: Manage pod replicas
- Services: Expose pods to network
- ConfigMaps: Configuration data
- Secrets: Sensitive data
- Ingress: External access
Prerequisites
Local Kubernetes
# Option 1: Minikube
minikube start
# Option 2: Kind
kind create cluster
# Option 3: Docker Desktop (has K8s built-in)
Verify Setup
kubectl cluster-info
kubectl get nodes
Basic Deployment
Deployment Manifest
See deployment.yaml for a complete example.
Key components:
- Deployment: Manages pods
- Service: Exposes pods
- ConfigMap: Configuration
- Resource limits: CPU/memory
Deploy
kubectl apply -f deployment.yaml
Check Status
kubectl get pods
kubectl get services
kubectl logs -f deployment/llm-serving
Health Checks
Liveness Probe
Detects if pod is alive. If fails, pod is restarted.
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 60
periodSeconds: 10
Readiness Probe
Detects if pod is ready to serve traffic.
readinessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 30
periodSeconds: 5
Startup Probe
Gives pod time to start (useful for slow-starting apps).
startupProbe:
httpGet:
path: /health
port: 8000
failureThreshold: 30
periodSeconds: 10
Resource Management
Requests
Minimum resources guaranteed to pod.
resources:
requests:
memory: "2Gi"
cpu: "1000m"
Limits
Maximum resources pod can use.
resources:
limits:
memory: "4Gi"
cpu: "2000m"
nvidia.com/gpu: 1 # For GPU
Why This Matters
- Requests: Scheduler uses this to place pods
- Limits: Prevents pods from consuming all resources
- GPU: Must specify for GPU workloads
ConfigMaps and Secrets
ConfigMap
Store non-sensitive configuration.
apiVersion: v1
kind: ConfigMap
metadata:
name: llm-config
data:
model_name: "gpt2"
max_length: "100"
temperature: "0.7"
Secret
Store sensitive data (API keys, tokens).
apiVersion: v1
kind: Secret
metadata:
name: llm-secrets
type: Opaque
data:
api_key: <base64-encoded>
Using in Deployment
env:
- name: MODEL_NAME
valueFrom:
configMapKeyRef:
name: llm-config
key: model_name
- name: API_KEY
valueFrom:
secretKeyRef:
name: llm-secrets
key: api_key
Scaling
Manual Scaling
kubectl scale deployment llm-serving --replicas=3
Auto-scaling (HPA)
See 06_autoscaling/ for detailed HPA setup.
Rolling Updates
Update Image
kubectl set image deployment/llm-serving \
llm-serving=llm-serving:v2
Rollback
kubectl rollout undo deployment/llm-serving
Check Rollout Status
kubectl rollout status deployment/llm-serving
GPU Support
Node Labels
# Label nodes with GPU
kubectl label nodes <node-name> accelerator=nvidia-tesla-v100
GPU Resource Request
resources:
limits:
nvidia.com/gpu: 1
Device Plugin
Install NVIDIA device plugin:
kubectl apply -f https://raw.githubusercontent.com/NVIDIA/k8s-device-plugin/v0.14.1/nvidia-device-plugin.yml
Ingress
Expose Service Externally
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: llm-ingress
spec:
rules:
- host: llm.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: llm-serving
port:
number: 8000
Monitoring
View Logs
kubectl logs -f deployment/llm-serving
kubectl logs -f <pod-name>
Describe Resources
kubectl describe pod <pod-name>
kubectl describe deployment llm-serving
Get Events
kubectl get events --sort-by='.lastTimestamp'
Common Issues
Pod Not Starting
- Check logs:
kubectl logs <pod-name> - Describe pod:
kubectl describe pod <pod-name> - Check events:
kubectl get events
Out of Resources
- Check node resources:
kubectl describe node - Adjust resource requests/limits
- Add more nodes
Image Pull Errors
- Check image name and tag
- Verify image registry access
- Check image pull secrets
Health Check Failures
- Verify health endpoint works
- Adjust probe timing
- Check application startup time
Best Practices
- Always set resource limits: Prevent resource exhaustion
- Use health checks: Enable automatic recovery
- Use ConfigMaps: Don’t hardcode configuration
- Tag images: Use semantic versioning
- Test locally: Use minikube/kind before production
- Monitor: Set up logging and metrics
Exercises
- Basic deployment: Deploy app to local K8s
- Health checks: Add liveness/readiness probes
- Resource limits: Set appropriate CPU/memory
- ConfigMap: Move config to ConfigMap
- Scaling: Scale to 3 replicas
- Rolling update: Update to new version
Next Steps
- Topic 4: Load test your K8s deployment
- Topic 6: Set up auto-scaling
- Topic 8: Add monitoring
Further Reading
Kubernetes for LLM Serving
Deploying and operating GPU inference on Kubernetes — the control plane that most production LLM stacks run on.
Why this matters
Once you have a working inference server (vLLM, TGI, Triton, SGLang), the next question is always the same: how do I run twelve of these across a fleet of GPU nodes, upgrade them without dropping traffic, and not set money on fire? Kubernetes is the de-facto answer. It gives you a declarative fleet, health-based traffic gating, rolling upgrades, and a scheduler that can place a pod on exactly the right GPU.
But LLM serving breaks several of Kubernetes’ defaults. Models take minutes to load, so naive probes will kill pods mid-startup in an infinite crash loop. GPUs are not overcommittable, so the usual CPU/memory bin-packing intuition is wrong. Container images and model weights are tens of gigabytes, so image pulls and cold starts dominate. This chapter walks the mechanisms and the sharp edges.
Autoscaling (HPA, KEDA, custom metrics, scale-to-zero) is deep enough to deserve its own chapter — see Autoscaling GPU Inference. Here we reference it but focus on deployment, scheduling, health, weights, and lifecycle.
Core intuition
Three mental models carry most of the chapter:
-
A GPU is an indivisible, non-overcommittable device. Unlike CPU (compressible) and memory (overcommittable at your peril), a GPU is handed to exactly one container by the device plugin. You can share it deliberately (time-slicing, MPS, MIG), but the scheduler still treats each advertised unit as an integer resource. There is no “burst above your GPU limit.”
-
Health is a function of time, not just liveness. A pod that has been alive for 40 seconds but hasn’t loaded a 140 GB model is not broken — it’s starting. Kubernetes has a dedicated primitive for exactly this distinction: the startup probe. Get this wrong and you get a crash loop that looks like a hardware failure.
-
The weights are the workload. For classic web services the container image is the app. For LLM serving the image is a runtime and the weights are the payload — often 10x larger than the image. Where the weights live and how they land on the node (baked into the image, PVC,
initContainer, object storage, model cache) determines your cold-start time and your blast radius. -
Voluntary disruption is the one you control. Nodes get drained for upgrades, spot reclamation, and autoscaler scale-down constantly. Kubernetes lets you bound that damage with a PodDisruptionBudget and a graceful-shutdown path. Unlike a crashed process (involuntary), these are scheduled, negotiable evictions — and the difference between a clean rolling drain and a full outage is a few lines of YAML you either wrote or didn’t.
Keep these four in mind and the rest of the chapter is mostly detail: GPUs are exclusive integers, health is time-aware, weights are the real payload, and disruptions are bounded on purpose.
Mechanisms in depth
1. The building blocks: Deployment, Service, Ingress
For a stateless replicated inference server the standard trio is:
- Deployment — declares N replicas of a pod template, handles rolling updates and self-healing.
- Service — a stable virtual IP + DNS name that load-balances across the ready pods (
ClusterIPfor in-cluster,LoadBalancerfor cloud L4). - Ingress (or Gateway API) — L7 routing, TLS termination, path/host rules, into the Service.
A subtlety unique to LLM serving: long request durations and streaming. Token-streaming responses (SSE) can run for tens of seconds to minutes. Make sure your Ingress/proxy timeouts (proxy-read-timeout on the NGINX ingress, backend request timeout on cloud LBs) are raised, and that buffering is disabled so tokens flush as they generate. Default 30–60s timeouts will cut long generations.
2. GPU scheduling: the NVIDIA device plugin
Kubernetes has no native notion of a GPU. The NVIDIA device plugin is a DaemonSet that runs on every GPU node, discovers the GPUs, and advertises them to the kubelet as an extended resource named nvidia.com/gpu. The scheduler then treats that resource like any countable resource.
You request GPUs under resources:
resources:
limits:
nvidia.com/gpu: 1 # request one whole GPU
Key rules that trip people up:
- Extended resources must be integers, and request must equal limit. Kubernetes requires that for any extended resource, if you set it at all, the request and limit are equal. You cannot request 0.5 of a
nvidia.com/gpuand burst to 1. Practically you only ever specify it underlimits(Kubernetes copies it torequestsfor you). - GPUs are never overcommitted. Two pods cannot each hold
nvidia.com/gpu: 1on a node that advertises one GPU — the second staysPending. This is by design: two processes fighting over one GPU’s memory would OOM each other unpredictably. - Sharing is opt-in and explicit. If you want to pack multiple pods on one GPU you enable time-slicing (the plugin advertises, say, 4 “replicas” of each GPU — but note this is oversubscription with no memory isolation, so proportional compute is not guaranteed), MPS, or hardware MIG partitions. Each mechanism changes what the plugin advertises; the scheduler math stays “integer units.”
The device plugin is often installed as part of the NVIDIA GPU Operator, which additionally manages the driver, the container toolkit, DCGM metrics exporter, Node Feature Discovery, and MIG configuration — so you don’t hand-install drivers on every node.
2b. GPU sharing: MIG vs time-slicing vs MPS
One whole GPU per pod is wasteful for small models that use a few GB of a 80 GB card. Three mechanisms let you pack more, each changing what the device plugin advertises:
| Mechanism | Isolation | How it splits | Advertised as | Use when |
|---|---|---|---|---|
| MIG (Multi-Instance GPU) | Hardware — separate memory + compute slices | Physically partitions an A100/H100 into up to 7 instances | nvidia.com/mig-1g.10gb etc. (or relabeled nvidia.com/gpu) | Strong isolation, predictable QoS, multi-tenant |
| Time-slicing | None — processes share memory, take turns on the SMs | Plugin advertises N “replicas” of each GPU; the driver context-switches | nvidia.com/gpu (inflated count) | Bursty/low-QPS dev workloads that tolerate contention |
| MPS (Multi-Process Service) | Soft — shared memory, concurrent kernels with optional compute caps | A daemon runs many clients’ kernels concurrently | nvidia.com/gpu (configured slots) | Higher utilization than time-slicing, some control |
Critical caveat: time-slicing gives no memory isolation — two pods on one time-sliced GPU can OOM each other, and “requesting 2 shared GPUs” does not guarantee 2x the compute. For production multi-tenant serving, MIG is the safe choice; time-slicing/MPS are for dev or trusted, well-characterized co-tenancy. All three are configured via the device plugin / GPU Operator, not by the pod author.
3. Getting pods onto GPU nodes: selectors, taints, tolerations
You almost never want a random CPU workload landing on an expensive GPU node, and you want GPU pods to land only on GPU nodes. Two complementary mechanisms:
-
Node labels +
nodeSelector/affinity (attraction). GPU nodes carry labels — cloud pools add things likecloud.google.com/gke-accelerator=nvidia-l4, and the GPU Operator / NFD add labels such asnvidia.com/gpu.product=NVIDIA-A100-SXM4-80GB. You pull your pod toward them:nodeSelector: nvidia.com/gpu.product: NVIDIA-A100-SXM4-80GB -
Taints + tolerations (repulsion). You taint GPU nodes so nothing schedules there unless it explicitly tolerates the taint. Cloud GPU pools often auto-apply a taint like
nvidia.com/gpu=present:NoSchedule. Your inference pod must tolerate it:tolerations: - key: nvidia.com/gpu operator: Exists effect: NoSchedule
Use both: the taint keeps freeloaders off, the selector/affinity ensures your pod picks the right GPU SKU. A GPU node pool is simply a node group with a fixed instance type (all A100, or all L4), its own taint, and often its own cluster-autoscaler settings so you can scale GPU capacity independently of the CPU fleet.
4. Probes done right for multi-minute model loads
This is the single most common LLM-on-k8s bug. Kubernetes has three probes:
| Probe | Question it answers | Failure action |
|---|---|---|
| startup | “Has the container finished starting yet?” | Kill & restart the container (crash loop). Disables the other two until it first succeeds. |
| readiness | “Should this pod receive traffic right now?” | Remove pod from Service endpoints (no traffic), do not kill. |
| liveness | “Is this container wedged and needs a restart?” | Kill & restart the container. |
The classic failure: you set a liveness probe with a short initialDelaySeconds, the model takes 4 minutes to load, the liveness probe fails during load, the kubelet kills the container, it restarts, tries to load again, gets killed again — an infinite crash loop that looks like the model is broken.
The fix is the startup probe. While a startup probe is configured and not yet successful, liveness and readiness are suppressed. So you give the startup probe a generous budget:
startupProbe:
httpGet:
path: /health
port: 8000
periodSeconds: 10
failureThreshold: 60 # 10s * 60 = up to 600s (10 min) to become healthy
The effective grace window is periodSeconds * failureThreshold. Size it to your worst-case cold load (weights download + load into VRAM + CUDA graph capture / warmup), then add margin. Once the startup probe passes once, the fast liveness and readiness probes take over.
- readiness should reflect “can serve a request” — many servers expose
/health(up) vs a readiness endpoint that only returns 200 once the model is loaded and warmup is done. Gate traffic on the latter. - liveness should be cheap and lenient — it exists to recover a genuinely wedged process (deadlock, CUDA error), not to police slow loads. Use a modest
periodSecondsand afailureThresholdof 3+ so a single slow health check doesn’t kill a healthy pod mid-inference.
5. Resource requests/limits and why GPUs aren’t overcommitted
For CPU and memory you set requests (scheduling guarantee) and limits (cap). For LLM pods:
- CPU: set a request so the scheduler reserves headroom (tokenization, HTTP, scheduling loops are CPU-hungry), but be cautious with CPU limits — throttling the server’s event loop can tank throughput. Many teams set a CPU request and no CPU limit.
- Memory: set request and limit close together and generous. Host RAM is used to stage weights before they hit VRAM; an OOMKill mid-load looks like a probe failure but is actually the kernel.
- GPU:
nvidia.com/gpurequest == limit == integer, always. There is no overcommit. The reason is physical: GPU memory (VRAM) has no swap and no soft limit the scheduler understands. If two pods both assumed they had the whole 80 GB, the second allocation would fail at CUDA-malloc time, not at schedule time — an ugly runtime crash instead of a cleanPending. So Kubernetes refuses to double-book.
Corollary — GPU fragmentation: because allocation is integer and per-node, a cluster with eight 8-GPU nodes and lots of single-GPU pods can end up unable to schedule a pod that needs 4 GPUs on one node, even though 20 GPUs are free cluster-wide. Multi-GPU / tensor-parallel pods need whole nodes or careful bin-packing (topology-aware scheduling, podAffinity, or a scheduler like the NVIDIA/Volcano gang scheduler).
6. Serving the model weights
Where do the weights come from at pod start? Four common patterns, with tradeoffs:
| Approach | How | Pros | Cons |
|---|---|---|---|
| Baked into image | COPY weights into the Docker image | Simplest; immutable; no runtime fetch | Enormous images (30–100 GB+), slow pulls, registry bloat, rebuild to change weights |
| initContainer download | An initContainer pulls weights from object storage (S3/GCS) into a shared emptyDir | Small runtime image; weights versioned in bucket | Re-downloads on every cold start unless cached; needs credentials |
| PVC (shared/RWX) | Weights on a persistent volume (e.g. a network filesystem), mounted read-only | Download once, many pods share; fast pod start | Storage class must support RWX or you pre-populate; network FS bandwidth can bottleneck concurrent loads |
| Node-local cache / model cache | Cache weights on local NVMe, or use a model-cache layer (e.g. Run:ai model streamer, KServe modelcar, Fluid) | Fast warm starts; streams weights into VRAM | More moving parts; cache warmup / eviction to manage |
Rules of thumb: bake weights into the image only for small models or when immutability matters more than pull time. For large models, keep the runtime image lean and fetch weights via initContainer or a pre-populated read-only PVC, and cache on the node so replicas 2..N start fast. Beware the thundering herd: ten pods cold-starting simultaneously all pulling 140 GB from the same bucket will saturate egress and each other.
7. Rolling updates, PodDisruptionBudgets, graceful shutdown
-
Rolling updates: the Deployment’s
RollingUpdatestrategy withmaxUnavailable/maxSurgecontrols how many pods are replaced at once. For GPU pods,maxSurgecosts real extra GPUs — surging by 1 means the autoscaler must find another GPU node. Often you setmaxSurge: 0, maxUnavailable: 1to avoid needing spare GPUs, accepting slightly reduced capacity during the rollout. And remember: each new pod pays the full multi-minute cold-start, so rollouts of GPU fleets are slow. Budget for it. -
PodDisruptionBudget (PDB): protects against voluntary disruptions (node drains, cluster-autoscaler scale-down, upgrades). Without a PDB, a node drain can evict all your replicas at once and take the service down. Set
minAvailable(ormaxUnavailable) so the eviction API refuses to take down too many at once:apiVersion: policy/v1 kind: PodDisruptionBudget spec: minAvailable: 2 selector: matchLabels: { app: llm-inference } -
Graceful shutdown: on SIGTERM, a good inference server should stop accepting new requests, drain in-flight generations, then exit. Kubernetes gives it
terminationGracePeriodSeconds(default 30s) before SIGKILL — raise this well above your longest expected generation (e.g. 120–300s) so streaming requests aren’t cut off. Pair it with apreStophook or a readiness flip so the pod is pulled from Service endpoints before it starts draining, avoiding races where traffic hits a shutting-down pod.
8. Serving frameworks & operators
You don’t have to hand-roll Deployments. Higher-level tools add model-aware features (autoscaling on GPU/queue metrics, scale-to-zero, canary, standardized model formats):
- KServe — a CRD (
InferenceService) on top of Knative/Kubernetes. Handles autoscaling (incl. scale-to-zero), canary rollout, and a standard prediction protocol. Has first-class support for LLM runtimes (vLLM) viaServingRuntime. Good when you want a platform abstraction over raw pods. - NVIDIA NIM / Triton on k8s — NIM packages optimized model microservices as containers; the NIM Operator (and Triton) deploy them, and NIM integrates with KServe for the serving layer. Best when you’re standardized on NVIDIA’s optimized stack and want vendor-supported images.
- KubeAI — an open, k8s-native inference operator focused on OpenAI-compatible serving of LLMs (vLLM/Ollama), with built-in autoscaling and model management, no Istio/Knative dependency. Lighter-weight alternative to KServe.
- Ray Serve (KubeRay) — deploy via the
RayServiceCRD. Shines for multi-model, model-composition, and distributed (multi-node tensor/pipeline-parallel) serving where a request fans across many actors/GPUs. More of a distributed compute framework than a thin serving layer.
9. Networking specifics: Gateway API, timeouts, affinity
- Ingress vs Gateway API. The classic
Ingressresource works, but the newer Gateway API (Gateway+HTTPRoute) is the direction the ecosystem is moving and expresses timeouts, traffic splitting, and header routing more cleanly — useful for canarying model versions. - Streaming timeouts. Token-by-token SSE/HTTP responses can run minutes. On the NGINX ingress set
nginx.ingress.kubernetes.io/proxy-read-timeoutandproxy-send-timeoutto several hundred seconds and disable buffering (proxy-buffering: "off") so tokens flush live. Cloud L7 LBs have their own backend timeout you must raise. - Session affinity for KV-cache reuse. With prefix/KV caching, routing a follow-up request to the same replica that holds the cache boosts throughput. Basic
sessionAffinity: ClientIPon the Service helps; smarter setups use a cache-aware router (e.g. the vLLM production stack / router) instead of round-robin. - Headless Services for multi-node. Distributed (tensor/pipeline-parallel across nodes) runtimes often need pod-to-pod addressing; a headless Service (
clusterIP: None) plus a StatefulSet gives stable per-pod DNS.
10. Observability: know when a GPU pod is unhealthy
Standard pod metrics miss the GPU. Add:
- DCGM exporter (shipped by the GPU Operator) → Prometheus: GPU utilization, memory used, temperature, ECC errors, throttling. Alert on sustained 0% utilization on a “ready” pod (stuck), on VRAM near 100% (OOM risk), and on XID/ECC errors (failing hardware).
- Server-level metrics from the runtime: queue depth, time-to-first-token, tokens/sec, running vs waiting requests. These drive autoscaling (see the autoscaling chapter) and tell you why latency moved.
- Event/probe signals: watch for
Unhealthyprobe events,CrashLoopBackOff, andFailedScheduling(usually taint/selector or capacity).kubectl describe podandkubectl get eventsare your first stop.
A “ready” pod pinned at 0% GPU utilization with a growing request queue is the classic silent failure — the health endpoint returns 200 but inference is wedged. Alert on the metric, not just the probe.
11. Spot / preemptible GPUs and cost
GPU nodes are the dominant cost, so many teams run inference on spot/preemptible instances at a large discount — accepting that the cloud can reclaim the node with ~30–120s notice.
- Spread replicas across on-demand and spot with
topologySpreadConstraintsso a spot reclamation storm can’t take the whole service down; keep a baseline of on-demand capacity protected by the PDB. - The preemption signal arrives as a node drain → your graceful shutdown path (SIGTERM, drain,
terminationGracePeriodSeconds) must fit inside the cloud’s notice window, or in-flight requests are lost. - Cold-start time is your enemy here: a reclaimed spot pod must re-download weights and reload the model before serving. Node-local weight caches and pre-pulled images shrink the recovery gap.
- Right-size the GPU: an 8B model on an 80 GB H100 wastes the card — MIG-slice it or pick a smaller SKU (L4/L40S) and let the comparison table of frameworks + autoscaling do the packing.
Fully worked example: raw Deployment + Service
A complete, correct manifest for a vLLM server on a single A100, with GPU request, startup/readiness/liveness probes tuned for a slow load, weights fetched from object storage by an initContainer into a shared cache, a PDB, and graceful shutdown.
apiVersion: apps/v1
kind: Deployment
metadata:
name: llm-inference
labels: { app: llm-inference }
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 0 # don't demand a spare GPU during rollout
maxUnavailable: 1 # replace one pod at a time
selector:
matchLabels: { app: llm-inference }
template:
metadata:
labels: { app: llm-inference }
spec:
terminationGracePeriodSeconds: 180 # let in-flight generations drain
# --- placement: only land on the right GPU nodes ---
nodeSelector:
nvidia.com/gpu.product: NVIDIA-A100-SXM4-80GB
tolerations:
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
# --- weights: download once into a shared emptyDir cache ---
volumes:
- name: model-cache
emptyDir:
sizeLimit: 200Gi
initContainers:
- name: fetch-weights
image: amazon/aws-cli:2.15.0
command:
- sh
- -c
- |
if [ ! -f /models/.done ]; then
aws s3 sync s3://my-models/llama-3.1-70b /models/llama-3.1-70b
touch /models/.done
fi
volumeMounts:
- { name: model-cache, mountPath: /models }
containers:
- name: vllm
image: vllm/vllm-openai:v0.6.3
args:
- --model=/models/llama-3.1-70b
- --served-model-name=llama-3.1-70b
- --port=8000
ports:
- containerPort: 8000
volumeMounts:
- { name: model-cache, mountPath: /models, readOnly: true }
resources:
limits:
nvidia.com/gpu: 1 # one whole GPU, request==limit, integer
memory: 96Gi # host RAM to stage weights
requests:
cpu: "8"
memory: 96Gi
nvidia.com/gpu: 1
# --- probes: startup guards the slow load, then liveness/readiness ---
startupProbe:
httpGet: { path: /health, port: 8000 }
periodSeconds: 10
failureThreshold: 60 # up to 600s to load 70B weights + warmup
readinessProbe:
httpGet: { path: /health, port: 8000 }
periodSeconds: 10
failureThreshold: 3 # pull from LB if it goes unhealthy
livenessProbe:
httpGet: { path: /health, port: 8000 }
periodSeconds: 20
failureThreshold: 3 # only restart a genuinely wedged process
lifecycle:
preStop:
exec:
# flip out of rotation, give the LB time to notice before drain
command: ["sh", "-c", "sleep 15"]
---
apiVersion: v1
kind: Service
metadata:
name: llm-inference
spec:
selector: { app: llm-inference }
ports:
- name: http
port: 80
targetPort: 8000
type: ClusterIP
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: llm-inference
spec:
minAvailable: 2 # keep >=2 replicas through drains/upgrades
selector:
matchLabels: { app: llm-inference }
Notes on the choices:
startupProbebudget =10s * 60 = 600s. If your model loads in ~90s, this is generous headroom; shrinkfailureThresholdif you want faster crash detection, but never below your real worst-case load time.- The
initContaineridempotency (.donesentinel) means a restarted pod on a node whoseemptyDirsurvived (it won’t across reschedule) skips the re-download; for true cross-pod caching use a read-only RWX PVC or a node-local hostPath cache instead ofemptyDir. maxSurge: 0trades a little capacity during rollout for not needing an extra GPU.- The
preStopsleep + 180s grace period gives streaming requests time to finish and the load balancer time to stop routing before the process exits.
Brief KServe example
The same intent, far less YAML, using KServe’s InferenceService. KServe wires up autoscaling, routing, and the storage fetch for you.
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
name: llama-31-70b
spec:
predictor:
minReplicas: 1
maxReplicas: 4
model:
modelFormat: { name: vLLM }
storageUri: s3://my-models/llama-3.1-70b # KServe fetches the weights
resources:
limits:
nvidia.com/gpu: "1"
requests:
nvidia.com/gpu: "1"
memory: 96Gi
KServe pulls the weights from storageUri (S3/GCS/PVC/HTTP), applies a ServingRuntime for the vLLM format, and manages the Deployment/Service/autoscaler behind the CRD. You still tune probes and node placement via the ServingRuntime or pod overrides.
Debugging playbook: Pending and crash-looping GPU pods
Two symptoms cover most incidents. Work them like this:
Pod stuck Pending.
kubectl describe pod <pod> | sed -n '/Events/,$p'
Read the scheduler message:
0/12 nodes are available: 12 Insufficient nvidia.com/gpu→ no free GPUs. Is the cluster-autoscaler adding a GPU node? Is your node pool at max? Is another pod holding the GPU?... node(s) had untolerated taint {nvidia.com/gpu: present}→ you’re missing the toleration.... didn't match Pod's node affinity/selector→ yournodeSelector/label is wrong (check exact label withkubectl get nodes --show-labels).Insufficient cpu/memory→ GPU is free but the node can’t fit your CPU/RAM request.
Pod in CrashLoopBackOff during startup.
kubectl logs <pod> -c vllm --previous # logs from the killed attempt
kubectl get events --field-selector involvedObject.name=<pod>
- Repeated
Liveness probe failedevents at ~the same age → probe is killing the model mid-load; add/extend the startup probe. OOMKilledin the container’slastState→ raise the memory limit (host RAM to stage weights).- initContainer errors (S3 auth, disk full on
emptyDirsizeLimit) → weights fetch is failing; the main container never starts. - CUDA/driver errors in logs → driver/toolkit mismatch (a job for the GPU Operator), or the GPU was already claimed.
A note on cold-start math
Cold-start time for a scaled-up or rescheduled pod is roughly:
[ T_{cold} = T_{provision} + T_{pull} + T_{weights} + T_{load} + T_{warmup} ]
where ( T_{provision} ) is node acquisition (0 if a node is warm, minutes if the cluster-autoscaler must boot a GPU VM), ( T_{pull} ) is image pull, ( T_{weights} ) is fetching weights to the node, ( T_{load} ) is loading them into VRAM, and ( T_{warmup} ) is CUDA-graph capture / first-token warmup. Your startup probe budget must exceed ( T_{weights} + T_{load} + T_{warmup} ) (the init container covers ( T_{weights} ) separately if you split it out), and your autoscaling responsiveness is gated by the whole sum — which is why node-local caches and pre-pulled images matter so much.
Comparison: serving-on-Kubernetes options
| Option | What it is | Autoscaling / scale-to-zero | Best for | Cost |
|---|---|---|---|---|
| Raw Deployment + Service | You write the manifests | HPA only (you wire it); no scale-to-zero out of the box | Full control, simple single-model services, learning | High YAML/ops effort, most flexible |
| KServe (InferenceService) | CRD over Knative/k8s, standard model protocol | Yes, incl. scale-to-zero + canary | Platform teams wanting a model abstraction, many models, standardized rollout | Heavier install (Knative/Istio or raw-deploy mode), more concepts |
| Ray Serve (KubeRay) | Distributed serving on Ray via RayService | Yes, Ray-native autoscaling | Multi-model composition, distributed/multi-node tensor-parallel, complex pipelines | Ray cluster to operate; overkill for one small model |
| Triton / NVIDIA NIM (+ NIM Operator) | NVIDIA-optimized model servers/containers | Via KServe/HPA integration | NVIDIA-standardized stacks, optimized/quantized engines, vendor support | Vendor lock-in to NVIDIA images; excellent perf |
| KubeAI | Lightweight k8s-native LLM operator | Yes, incl. scale-from-zero | OpenAI-compatible serving without Istio/Knative | Younger ecosystem, smaller community |
Rule of thumb: start with a raw Deployment to understand the mechanics; graduate to KServe or KubeAI when you have many models and want autoscaling/canary for free; reach for Ray Serve when a single request must fan across multiple GPUs/nodes or you’re composing models; adopt NIM/Triton when NVIDIA’s optimized engines and support matter.
Failure modes & pitfalls
- Probes killing pods mid-load. No startup probe (or a liveness probe with a too-short
initialDelaySeconds) turns a 4-minute model load into an infiniteCrashLoopBackOff. Always use a startup probe sized to worst-case load; keep liveness lenient. This is the number-one LLM-on-k8s bug. - GPU fragmentation. Integer, per-node GPU allocation strands capacity: 20 GPUs free cluster-wide but no single node has the 4 your tensor-parallel pod needs. Use topology-aware / gang scheduling and design node pools around your parallelism.
- Image pull of huge images. A 60 GB image (weights baked in) can take many minutes to pull on a cold node, and the cluster-autoscaler’s node-provision + pull time compounds it. Keep runtime images lean, pre-pull images to nodes, or use a node-local weight cache.
- Thundering-herd weight downloads. N pods cold-starting at once each pulling the full model from one bucket saturates egress and slows all of them. Pre-populate a read-only PVC or node cache; stagger scale-ups.
- No PDB → full outage on drain. A routine node upgrade or autoscaler scale-down can evict every replica simultaneously. Always ship a PodDisruptionBudget with
minAvailable. - Missing tolerations / wrong selectors → Pending forever. GPU nodes are tainted; a pod without the matching toleration silently stays
Pending. Conversely, no selector and CPU pods squat on GPU nodes.kubectl describe podshows the scheduling reason. - CPU/memory misconfig masquerading as GPU failure. An OOMKill while staging weights into host RAM, or CPU throttling from a tight CPU limit, looks like a model/probe problem. Give generous memory limits; be careful with CPU limits.
- Ingress/proxy timeouts cutting streams. Default 30–60s proxy timeouts truncate long token streams. Raise read/backend timeouts and disable response buffering.
- Rollouts assuming spare GPUs.
maxSurge > 0on a full GPU pool blocks the rollout waiting for GPUs that don’t exist. UsemaxSurge: 0or ensure headroom. - Ephemeral
emptyDircache re-downloads every reschedule. AnemptyDirdies with the pod, so a rescheduled pod re-fetches the whole model. If cold-start matters, back the cache with a read-only RWX PVC or a node-localhostPath/CSI volume that survives pod churn. - Graceful shutdown too short. Default 30s
terminationGracePeriodSecondsSIGKILLs pods mid-generation. Raise it above your longest generation and flip readiness first.
Production checklist — what an interviewer probes
- “A pod loads a 100 GB model in 5 minutes but keeps restarting. Why?” — Missing/short startup probe; liveness kills it mid-load. Fix with a startup probe whose
periodSeconds * failureThresholdexceeds worst-case load, and suppress liveness until then. - “Why can’t you overcommit GPUs like memory?” — VRAM has no swap and the scheduler can’t reason about it; the device plugin advertises integer, exclusive units (request == limit). Sharing requires explicit time-slicing/MPS/MIG.
- “How do you keep GPU pods on GPU nodes and everything else off?” — Taint GPU nodes, add matching tolerations, plus a
nodeSelector/affinity on the GPU SKU label. Explain the attraction-vs-repulsion split. - “Where do the weights come from and how fast is a cold start?” — Articulate baked-image vs initContainer vs read-only PVC vs node cache, the thundering-herd risk, and how you make replicas 2..N start fast.
- “How do you upgrade without an outage?” — RollingUpdate with
maxSurge: 0/maxUnavailable: 1, a PDB withminAvailable, graceful drain viaterminationGracePeriodSeconds+preStop+ readiness flip. - “Readiness vs liveness vs startup — when does each fire and what does failure do?” — Startup gates the others and restarts on failure; readiness gates traffic (no kill); liveness restarts a wedged process. Traffic should ride on a readiness endpoint that only passes after warmup.
- “When would you reach for KServe or Ray Serve over a raw Deployment?” — KServe/KubeAI for many models + autoscaling/canary/scale-to-zero for free; Ray Serve for distributed multi-GPU/multi-node or model composition; raw Deployment for control/simplicity.
- “You have 20 free GPUs but a 4-GPU pod won’t schedule — explain.” — Fragmentation: allocation is per-node and integer. Needs topology-aware/gang scheduling or node pools sized to your parallelism.
Further reading
- NVIDIA k8s device plugin (README, resource requests, time-slicing/MPS): https://github.com/NVIDIA/k8s-device-plugin
- NVIDIA GPU Operator (drivers, toolkit, NFD, MIG): https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/latest/index.html
- Kubernetes — Configure Liveness, Readiness and Startup Probes: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/
- Kubernetes — Probes concepts: https://kubernetes.io/docs/concepts/workloads/pods/probes/
- Kubernetes — Pod Disruption Budgets: https://kubernetes.io/docs/concepts/workloads/pods/disruptions/
- Kubernetes — Gateway API: https://gateway-api.sigs.k8s.io/
- Kubernetes — Schedule GPUs: https://kubernetes.io/docs/tasks/manage-gpus/scheduling-gpus/
- Kubernetes — Taints and Tolerations: https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration/
- KServe documentation: https://kserve.github.io/website/
- KServe GitHub: https://github.com/kserve/kserve
- NVIDIA NIM — Kubernetes deployment & KServe: https://docs.nvidia.com/nim/large-language-models/latest/deployment/kubernetes-deployment/kserve.html
- NVIDIA NIM Operator: https://docs.nvidia.com/nim-operator/latest/index.html
- Ray Serve LLM on Kubernetes (KubeRay): https://docs.ray.io/en/latest/cluster/kubernetes/examples/rayserve-llm-example.html
- KubeAI (k8s-native LLM inference operator): https://github.com/substratusai/kubeai
- EKS — Manage NVIDIA GPU devices: https://docs.aws.amazon.com/eks/latest/userguide/device-management-nvidia.html
Next: autoscaling these deployments — HPA on custom/GPU metrics, KEDA, queue-depth scaling, and scale-to-zero — is covered in Autoscaling GPU Inference.
Topic 4: Load Testing & Latency Measurement
What You’ll Learn
This topic teaches you how to:
- Measure inference latency (P50, P95, P99)
- Test throughput (requests/second)
- Identify performance bottlenecks
- Use Locust for load testing
- Analyze performance metrics
Key Concepts
Latency Metrics
Latency = Time from request sent to response received
Common percentiles:
- P50 (Median): 50% of requests faster than this
- P95: 95% of requests faster than this
- P99: 99% of requests faster than this
Why P95/P99 matter: P50 might be 100ms, but P99 could be 5s. Users notice the slow requests!
Throughput
Throughput = Requests processed per second
- Single request: Measure latency
- Multiple requests: Measure throughput
- Trade-off: Higher throughput often means higher latency
Load Testing Types
- Baseline: Single request, measure latency
- Ramp-up: Gradually increase load
- Sustained: Constant load for extended period
- Spike: Sudden increase in load
- Stress: Keep increasing until system breaks
Tools
Locust
- Python-based load testing
- Write tests in Python
- Web UI for monitoring
- Real-time statistics
Alternatives
- Apache Bench (ab): Simple, command-line
- wrk: High-performance, Lua scripting
- k6: JavaScript-based, modern
Installation
pip install locust requests
Running Load Tests
Option 1: Basic Locust Test
cd 04_load_testing
locust -f locust_test.py --host=http://localhost:8000
Then open http://localhost:8089 in your browser.
Option 2: Headless Mode (No UI)
locust -f locust_test.py \
--host=http://localhost:8000 \
--headless \
--users 10 \
--spawn-rate 2 \
--run-time 60s
Option 3: Custom Python Script
python measure_latency.py
Understanding Results
Latency Distribution
P50: 150ms (median)
P95: 450ms (95% of requests faster)
P99: 800ms (99% of requests faster)
Max: 2000ms (worst case)
Throughput
Requests/sec: 25.3
Total requests: 1518
Failures: 2 (0.13%)
What to Look For
- High P99: System struggling under load
- Increasing latency: Resource exhaustion
- Failures: System overloaded or errors
- Low throughput: Bottleneck somewhere
Performance Bottlenecks
Common Issues
-
CPU-bound: Model too large for CPU
- Solution: Use GPU, smaller model, or quantization
-
Memory-bound: Out of memory
- Solution: Reduce batch size, use smaller model
-
I/O-bound: Slow tokenization or network
- Solution: Optimize tokenization, use faster network
-
GPU underutilized: Not batching efficiently
- Solution: Use continuous batching (vLLM)
Exercises
- Baseline Test: Measure single-request latency
- Ramp-up Test: Gradually increase from 1 to 50 users
- Compare Models: Test gpt2 vs distilgpt2 performance
- Find Bottleneck: Identify what limits throughput
- Stress Test: Find maximum capacity
Next Steps
- Topic 5: Use vLLM for better performance
- Topic 8: Set up monitoring dashboards
- Topic 6: Configure autoscaling based on load
Load Testing LLM Inference — Measuring Throughput and Latency Correctly Under Load
Why This Matters
You cannot capacity-plan, price, or SLA a serving system you have not load-tested. And LLM inference is unusually easy to benchmark wrong: a single request against an idle server tells you almost nothing about behavior at 200 concurrent users, because the whole point of a modern engine (vLLM, TGI, TensorRT-LLM) is continuous batching — throughput and latency both change as concurrency changes.
The stakes are concrete:
- Capacity planning. “How many GPUs do I need for 500 chat sessions?” is answerable only from a concurrency sweep.
- SLA definition. “p95 time-to-first-token under 500 ms” is meaningless without saying at what load.
- Cost. Output tokens/second/GPU is the number your finance model divides into. A 2× throughput win halves your bill.
- Regression gating. A benchmark you can rerun in CI catches the day someone flips
--enable-chunked-prefilloff.
A wrong benchmark is worse than none: it gives false confidence. Most of this chapter is about the ways benchmarks lie, and how to stop them.
Core Intuition
Why average latency lies
Latency distributions in a batched system are heavy-tailed and multi-modal. A request that lands in an empty batch returns fast; an identical request that lands when the batch is full waits for a scheduler slot, then shares GPU compute with 63 neighbors. Same input, wildly different latency.
Average that distribution and you get a number that describes no actual request. Consider ten requests with latencies (ms):
90, 95, 100, 100, 105, 110, 110, 120, 130, 2000
The mean is 296 ms. Nine of ten users saw ≤130 ms; one saw 2 s. The mean reports a latency nobody experienced and hides the 2 s tail that will dominate your support tickets. The p90 is 130 ms and the p100 (max) is 2000 ms — those describe reality. Tail latency is where user pain, timeouts, and retry storms live, so you report percentiles, never just the mean.
Rule: the mean is for throughput accounting; percentiles are for latency SLAs.
Why concurrency defines the operating point
There is no single “latency” or “throughput” for a serving system — there is a curve parameterized by load. As you push more concurrent requests:
- Low load: GPU underutilized. Latency is flat and near-minimal. Throughput rises roughly linearly with concurrency.
- The knee: GPU compute (or KV-cache memory) saturates. Throughput flattens — you’ve hit the roofline. Latency starts climbing because requests now queue.
- Overload: Throughput is flat (or falls, from scheduling/paging overhead), but latency climbs without bound as the queue grows.
throughput (tok/s) latency p95 (ms)
| ____________ | /
| / | /
| / <- knee | _____/
| / | ____/
| / |___/
| / |
|/____________________ concurrency |________________ concurrency
The engineering goal is to find the knee and operate just below it: that’s where you get near-peak throughput while latency is still bounded. A benchmark that reports one concurrency level has told you one point on a two-dimensional curve. Always sweep.
Metrics, Defined Precisely
Let a single streaming request produce output tokens at wall-clock times ( t_1 < t_2 < \dots < t_N ), with the request sent at ( t_0 ). Over a whole test, let ( R ) requests complete in wall-clock window ( T ) seconds, producing ( O ) total output tokens.
Time To First Token (TTFT)
[ \text{TTFT} = t_1 - t_0 ]
The latency until the first token appears. Dominated by prefill (processing the prompt) plus any queueing wait for a scheduler slot. This is what a user perceives as “responsiveness” — the cursor starting to move. TTFT is the metric most sensitive to load, because a queued request pays its wait entirely before ( t_1 ).
Micro-example: prompt sent at ( t_0 = 0 ), first token at ( t_1 = 0.18\text{ s} ) → TTFT = 180 ms.
Time Per Output Token (TPOT) / Inter-Token Latency (ITL)
TPOT is the average gap between output tokens after the first, for one request:
[ \text{TPOT} = \frac{t_N - t_1}{N - 1} ]
ITL is the per-gap version — the distribution of individual ( t_{i+1} - t_i ) values. TPOT is the mean of a request’s ITLs. (Tools differ: vLLM reports both TPOT and ITL; some tools call TPOT “inter-token latency.” Know which your tool means.) TPOT is governed by the decode phase; ( 1/\text{TPOT} ) is the per-user tokens/second — the perceived “typing speed.”
Micro-example: a request emits 201 tokens, ( t_1 = 0.18 ), ( t_{201} = 4.18 ). TPOT ( = (4.18 - 0.18)/200 = 20\text{ ms} ) → each user sees ~50 tokens/s.
End-to-End (E2E) Request Latency
[ \text{E2E} = t_N - t_0 = \text{TTFT} + (N-1)\cdot\text{TPOT} ]
Total time from send to last token. This is the number that scales with output length, so it is only comparable across runs if output-length distributions match. A “latency regression” is often just longer outputs.
Normalized latency (per-token E2E)
[ \text{normalized latency} = \frac{\text{E2E}}{N} ]
Divides out output length, making runs with different output distributions comparable. vLLM’s benchmark historically reported this.
Request throughput
[ \lambda_{\text{out}} = \frac{R}{T} \quad [\text{req/s}] ]
Completed requests per second across the whole run. The right top-line number when your unit of work is “a request” (e.g., classification).
Output-token throughput
[ X = \frac{O}{T} \quad [\text{tok/s}] ]
Generated tokens per second across all concurrent requests. This is the money metric for generative workloads and the one that goes up with better batching. Report output tokens (excludes the prompt); also report total token throughput (prompt + output) if prefill cost matters to you. Divide by GPU count for tok/s/GPU.
Micro-example: 64 concurrent requests each streaming at 50 tok/s → aggregate ( X \approx 3200 ) tok/s, even though each user still sees only 50 tok/s. Per-user speed and aggregate throughput are different axes.
Percentiles
For a metric with sorted samples, the p-th percentile ( P_p ) is the smallest value ( \geq p% ) of samples:
[ P_p = \text{value at rank } \lceil \tfrac{p}{100} \cdot n \rceil \text{ in sorted order} ]
Report p50 (median), p95, p99 for TTFT, TPOT, and E2E. p99 matters more than it looks: if a page makes 10 backend calls, the chance all 10 beat p99 is ( 0.99^{10} \approx 0.90 ) — so ~10% of page loads hit a p99 tail. Tail latency compounds.
Open-Loop vs Closed-Loop Load Generation
This is the single most important methodology decision, and the one most benchmarks get wrong.
Closed-loop
A fixed pool of ( C ) “virtual users.” Each sends a request, waits for the full response, then immediately sends the next. Concurrency is capped at ( C ) by construction. This models a fixed number of clients in a tight loop (e.g., a batch job, or exactly ( C ) synchronous callers).
Open-loop
Requests are launched on an arrival schedule (e.g., Poisson at rate ( \lambda )) independent of whether prior requests have finished. In-flight concurrency is an emergent property, free to grow if the server slows down. This models real traffic: users arrive whether or not your server is keeping up.
Coordinated omission — why closed-loop hides overload
Here is the trap. In a closed-loop test, when the server slows down, each virtual user’s loop stalls waiting for its response — so it stops sending new requests. The offered load automatically backs off exactly when the system is struggling. The load generator “coordinates” with the server’s slowness and omits the requests a real (open) world would have kept sending.
Consequences:
- Your measured request rate silently drops below target, and you may not notice.
- The latency samples you do collect exclude the requests that would have queued behind a slow one, so tail latency is dramatically underreported. The one 2-second stall in a real system would have delayed 40 requests behind it; closed-loop just… didn’t send them.
- You conclude the system is healthy at a load it actually cannot sustain.
The fix in an open-loop tool is to schedule requests on absolute wall-clock times and measure each request’s latency from its intended send time, not from when a freed-up worker got around to it. k6’s constant-arrival-rate/ramping-arrival-rate executors and Gatling’s open model do this; constant-vus/ramping-vus are closed. Corrected closed-loop tools (wrk2, Gatling) reconstruct the omitted samples by back-dating latency to the scheduled time.
When each is right
- Open-loop / fixed request-rate: validating an SLA against realistic traffic (“can we hold p95 TTFT < 500 ms at 30 req/s?”). This is the honest default for user-facing services.
- Closed-loop / fixed concurrency: finding maximum sustainable throughput and the saturation curve, where a bounded, known concurrency is exactly the independent variable you want to sweep. LLMPerf and GenAI-Perf’s concurrency mode work this way, and that is fine because you are deliberately measuring the throughput ceiling, not pretending to reproduce arrival traffic.
Use both: a concurrency sweep to find the knee, then an open-loop test at your chosen arrival rate to confirm the SLA holds with realistic tail behavior.
Little’s Law — Reasoning About Concurrency
Little’s Law relates the three quantities you care about, for any stable system in steady state:
[ L = \lambda , W ]
- ( L ) = average number of requests in the system (concurrency / in-flight).
- ( \lambda ) = average arrival = completion rate (req/s), in steady state.
- ( W ) = average time in system (E2E latency, seconds).
It is an identity — no assumptions about distributions. Uses:
Sanity-check a benchmark. If your open-loop test offers ( \lambda = 20 ) req/s and you measure mean E2E ( W = 4 ) s, then average in-flight concurrency is ( L = 20 \times 4 = 80 ). If your engine’s --max-num-seqs is 64, you are oversubscribed: requests are queuing, latency will keep rising, and the system is not in steady state. The law just told you the offered load exceeds capacity before you stare at a climbing latency graph.
Convert closed-loop to a rate. A closed-loop test with ( C = 100 ) virtual users measuring ( W = 2.5 ) s achieves ( \lambda = L/W = 100/2.5 = 40 ) req/s. That’s how you translate a concurrency-sweep point into “requests per second this config sustains.”
Token form. Apply it to tokens: aggregate output throughput ( X ) (tok/s) with ( n ) requests in flight each of length ( N ) tokens taking ( W ) seconds gives ( X = nN/W ) — decompose regressions into “fewer concurrent” vs “slower per request.”
Caveat: Little’s Law holds in steady state. During warmup, ramp, or overload it does not, which is one more reason to discard warmup and to hold each sweep point long enough to stabilize.
A Fully Worked Example — Async Open-Loop Client
Below is a self-contained async Python client that drives an OpenAI-compatible streaming endpoint (vLLM, TGI, etc.), generates a Poisson arrival schedule (true open-loop — arrivals do not wait for completions), records TTFT/TPOT/E2E per request from the intended send time (coordinated-omission-safe), discards warmup, and prints percentiles and throughput.
#!/usr/bin/env python3
"""Open-loop load test for an OpenAI-compatible LLM endpoint.
Launches requests on a Poisson schedule at a target rate, independent of
whether prior requests finished, and measures latency from the INTENDED
send time to avoid coordinated omission.
Usage:
python load_test.py --url http://localhost:8000/v1/chat/completions \
--model my-model --rate 20 --duration 60 --warmup 10
"""
import argparse, asyncio, json, random, statistics, time
import aiohttp
PROMPTS = [
"Explain how continuous batching improves LLM throughput.",
"Write a haiku about GPU memory fragmentation.",
"Summarize the tradeoffs of speculative decoding in three sentences.",
"What is time-to-first-token and why does it depend on load?",
]
async def one_request(session, url, model, prompt, max_tokens, sched_t, t0, results):
"""Fire one request. Latency is measured from sched_t (intended send),
NOT from now — this is the coordinated-omission fix."""
# Sleep until this request's scheduled arrival time (open-loop).
delay = sched_t - (time.perf_counter() - t0)
if delay > 0:
await asyncio.sleep(delay)
intended = t0 + sched_t # absolute intended send time
payload = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": max_tokens,
"stream": True,
"temperature": 0.0,
}
token_times, first_t, err = [], None, None
send = time.perf_counter()
try:
async with session.post(url, json=payload) as resp:
async for raw in resp.content:
line = raw.decode("utf-8").strip()
if not line.startswith("data:"):
continue
data = line[len("data:"):].strip()
if data == "[DONE]":
break
chunk = json.loads(data)
delta = chunk["choices"][0]["delta"].get("content")
if delta:
now = time.perf_counter()
if first_t is None:
first_t = now
token_times.append(now)
except Exception as e: # noqa: BLE001
err = repr(e)
end = time.perf_counter()
n = len(token_times)
results.append({
"intended": intended,
"ttft": (first_t - send) if first_t else None,
# E2E measured from INTENDED time, not send — captures scheduling debt.
"e2e": end - send, # from actual send time
"e2e_true": end - (t0 + sched_t), # from intended arrival (CO-safe)
"n_tokens": n,
"tpot": ((token_times[-1] - first_t) / (n - 1)) if n > 1 else None,
"error": err,
})
def pct(xs, p):
if not xs:
return float("nan")
xs = sorted(xs)
k = max(0, min(len(xs) - 1, int(round(p / 100 * len(xs) + 0.5)) - 1))
return xs[k]
async def main():
ap = argparse.ArgumentParser()
ap.add_argument("--url", required=True)
ap.add_argument("--model", required=True)
ap.add_argument("--rate", type=float, default=10.0, help="req/s (Poisson mean)")
ap.add_argument("--duration", type=float, default=60.0, help="seconds")
ap.add_argument("--warmup", type=float, default=10.0, help="seconds to discard")
ap.add_argument("--max-tokens", type=int, default=200)
args = ap.parse_args()
# Pre-build a Poisson arrival schedule: gaps ~ Exponential(rate).
schedule, t = [], 0.0
while t < args.duration:
t += random.expovariate(args.rate)
schedule.append(t)
results = []
conn = aiohttp.TCPConnector(limit=0) # no client-side cap!
timeout = aiohttp.ClientTimeout(total=None)
async with aiohttp.ClientSession(connector=conn, timeout=timeout) as session:
t0 = time.perf_counter()
tasks = [
asyncio.create_task(one_request(
session, args.url, args.model,
random.choice(PROMPTS), args.max_tokens, s, t0, results))
for s in schedule
]
await asyncio.gather(*tasks)
wall = time.perf_counter() - t0
# Drop warmup window and errored requests.
ok = [r for r in results if r["error"] is None
and r["intended"] - t0 >= args.warmup]
measured_window = wall - args.warmup
ttfts = [r["ttft"] * 1000 for r in ok if r["ttft"]]
tpots = [r["tpot"] * 1000 for r in ok if r["tpot"]]
e2es = [r["e2e_true"] * 1000 for r in ok]
out_tokens = sum(r["n_tokens"] for r in ok)
print(f"\n=== target rate {args.rate} req/s | wall {wall:.1f}s | "
f"measured window {measured_window:.1f}s ===")
print(f"requests ok: {len(ok)} errors: {sum(1 for r in results if r['error'])}")
print(f"achieved req/s: {len(ok)/measured_window:8.2f}")
print(f"output tok/s: {out_tokens/measured_window:8.1f}")
for name, xs in (("TTFT ms", ttfts), ("TPOT ms", tpots), ("E2E ms", e2es)):
if xs:
print(f"{name:9s} p50 {pct(xs,50):8.1f} p95 {pct(xs,95):8.1f} "
f"p99 {pct(xs,99):8.1f} mean {statistics.mean(xs):8.1f}")
if __name__ == "__main__":
asyncio.run(main())
Two design points that matter:
TCPConnector(limit=0)removes the client’s own connection cap. If you leave aiohttp’s default (100) or run one CPU core hot parsing SSE, the client becomes the bottleneck and you benchmark your laptop, not the server. (See pitfalls.)e2e_truemeasures from the intended arrival time (t0 + sched_t), so a request delayed because the event loop was busy still counts its full latency — coordinated-omission-safe. (Use thee2e_truefield,end - (t0 + sched_t), for reporting; the plaine2efrom actual send time will under-report latency when the event loop falls behind.)
Sample results — a concurrency/rate sweep
Run the client at increasing rates against one A100 serving an 8B model, fixed input ≈512 / output ≈200 tokens:
| Target rate (req/s) | Achieved req/s | Output tok/s | TTFT p50 (ms) | TTFT p95 (ms) | TPOT p50 (ms) | E2E p95 (ms) | Avg in-flight (L=\lambda W) |
|---|---|---|---|---|---|---|---|
| 5 | 5.0 | 1000 | 42 | 70 | 15 | 3200 | 16 |
| 10 | 10.0 | 2000 | 55 | 95 | 17 | 3600 | 36 |
| 20 | 20.0 | 4000 | 88 | 210 | 21 | 4400 | 88 |
| 30 | 29.8 | 5900 | 180 | 620 | 28 | 6800 | 200 |
| 40 | 33.1 | 6600 | 540 | 2400 | 41 | 14200 | 470 |
| 50 | 32.9 | 6600 | 1900 | 9000 | 63 | 41000 | 1350 |
Reading the saturation curve
- 5 → 20 req/s: achieved rate tracks target, output tok/s scales ~linearly (1000→4000), TTFT p95 stays modest. Underloaded region — GPU has headroom.
- ~30 req/s is the knee. Output tok/s (5900) is close to the ceiling; achieved rate still ≈ target but TTFT p95 has jumped to 620 ms. This is the operating point you’d target for a latency-sensitive service, maybe backing off to ~25 for headroom.
- 40 → 50 req/s: overload. Achieved rate flatlines at ~33 req/s even as you offer more — that ~33 req/s (≈6600 tok/s) is the true saturation throughput. Meanwhile latency explodes (TTFT p95 2.4 s → 9 s; E2E p95 41 s) and Little’s-Law in-flight ( L ) blows past any sane
max-num-seqs. Offering 50 doesn’t get you 50; it just grows the queue.
The signature of the knee: throughput stops rising while latency starts rising superlinearly. Peak throughput and acceptable latency are different points — publish both, and state which you’re operating at.
A Second Worked Example — Locust (open-model)
Locust is convenient for HTTP services and dashboards. Locust is closed-loop by default (each user loops), but the constant_throughput/constant_pacing shape plus a high user count approximates open arrivals. Here is a locustfile.py that measures streaming TTFT and records it as a custom metric:
# locustfile.py — run: locust -f locustfile.py --host http://localhost:8000
import json, time
from locust import HttpUser, task, constant_throughput
class LLMUser(HttpUser):
# Each user targets 1 req/s; scale arrivals via -u (number of users).
# constant_throughput paces to a rate rather than back-to-back looping,
# which is closer to open-loop than the default.
wait_time = constant_throughput(1.0)
@task
def chat(self):
payload = {
"model": "my-model",
"messages": [{"role": "user", "content": "Explain KV cache paging."}],
"max_tokens": 200, "stream": True, "temperature": 0.0,
}
start = time.perf_counter()
first_t = None
n_tokens = 0
with self.client.post("/v1/chat/completions", json=payload,
stream=True, catch_response=True,
name="chat-stream") as resp:
for raw in resp.iter_lines():
if not raw:
continue
line = raw.decode("utf-8")
if not line.startswith("data:"):
continue
data = line[len("data:"):].strip()
if data == "[DONE]":
break
delta = json.loads(data)["choices"][0]["delta"].get("content")
if delta:
if first_t is None:
first_t = time.perf_counter()
n_tokens += 1
# Report TTFT as a named event so it shows in Locust stats/percentiles.
if first_t is not None:
ttft_ms = (first_t - start) * 1000
self.environment.events.request.fire(
request_type="METRIC", name="TTFT_ms",
response_time=ttft_ms, response_length=n_tokens,
exception=None, context={})
Locust’s own percentile table then gives you p50/p95/p99 for both the full request and the synthetic TTFT_ms event. Drive concurrency with -u <users> and -r <ramp>; use the web UI’s charts to watch the knee live. Caveat: Locust workers are Python and can become the bottleneck — run distributed workers (--worker) and confirm client CPU isn’t saturated before trusting numbers at high load.
Tools Comparison
| Tool | Load model | Metrics reported | Endpoints | Best for | Watch out |
|---|---|---|---|---|---|
vLLM benchmark_serving.py / vllm bench serve | Open-loop via --request-rate (inf = burst all --num-prompts at once); Poisson/--burstiness | Request throughput (req/s), Output token throughput (tok/s), Total token throughput, Mean/Median/P99 TTFT, TPOT, ITL, E2E | OpenAI-compatible + native vLLM/TGI backends | Purpose-built LLM serving benchmarks; realistic datasets (--dataset-name sharegpt/random/sonnet) | Ships with vLLM version; --request-rate inf is a burst, not steady rate |
| LLMPerf (Ray) | Closed-loop, --num-concurrent-requests | TTFT, inter-token latency, E2E, output throughput per-request and aggregate | Many providers (OpenAI, Anthropic, Together, Bedrock, Vertex, SageMaker, vLLM) | Cross-provider apples-to-apples; the LLMPerf leaderboard | Concurrency mode = throughput ceiling, not arrival-rate SLA; token counts are provider-tokenized approximations |
| NVIDIA GenAI-Perf (Triton Perf Analyzer) | Both: --concurrency (closed) or --request-rate (open) | TTFT, inter-token latency, output token throughput, request throughput, seq lengths, all with avg/p90/p99 | OpenAI-compatible, Triton (TRT-LLM, vLLM), gRPC/HTTP | Deep NVIDIA/Triton stacks; synthetic + custom datasets; rich exports | Heavier setup; Triton-centric defaults |
| Locust | Closed-loop by default; constant_throughput ≈ open | Whatever you instrument; built-in RPS + percentile table + web UI | Any HTTP (write Python tasks) | Custom flows, quick dashboards, mixed traffic | Python workers can be the bottleneck; streaming/TTFT needs custom code |
| k6 | Open-loop (constant/ramping-arrival-rate) or closed (*-vus) | RPS, latency percentiles, custom Trends | Any HTTP/gRPC (JS scripts) | Honest open-loop SLA tests, CI gating | Go runtime doesn’t tokenize; TTFT needs manual SSE parsing/custom metrics |
Rule of thumb: benchmark_serving.py/GenAI-Perf when you want LLM-native metrics and datasets out of the box; k6 when you want a rigorous open-loop SLA test; LLMPerf for cross-provider comparisons; Locust for bespoke multi-step traffic with a dashboard.
Failure Modes and Pitfalls
Closed-loop coordinated omission. Covered above — the big one. A closed-loop tool stops sending when the server stalls, so it under-reports tail latency and over-reports sustainable load. Fix: use open-loop arrival-rate executors, or a corrected tool that back-dates latency to the intended send time. If you must go closed-loop, only use it to measure the throughput ceiling, and never quote its latencies as an SLA.
No warmup. The first requests hit cold caches: CUDA graph capture, torch.compile / TRT-LLM engine warmup, cuBLAS autotune, KV-cache allocation, JIT. Cold TTFT can be 5–50× steady-state. Including warmup poisons your percentiles (a handful of huge samples wreck p99). Fix: send a warmup burst and discard the first N seconds/requests (the example’s --warmup). Also warm up the client (DNS, TLS, connection pool).
Unrealistic input/output lengths. A benchmark with 128-in/128-out tokens tells you nothing about a RAG workload with 4000-in/500-out. Prefill cost scales with input length; decode cost and E2E scale with output length; KV-cache pressure scales with both × concurrency. Fixed lengths also hide batching dynamics because every request finishes together. Fix: replay a realistic length distribution (ShareGPT, your own production logs, or --dataset-name random with a mean/std matching production). Report the distribution you used.
Measuring only averages. The mean hides the tail and can be dominated by a few slow requests. Always report p50/p95/p99 (and max) for TTFT, TPOT, and E2E separately. And never average latency across different output lengths without normalizing — longer outputs inflate E2E and masquerade as a regression.
Client-side bottleneck. The most insidious: your load generator, not the server, is the limit. Symptoms: achieved rate plateaus well below the server’s known capacity, client CPU pegged at 100%, or latency that scales with client concurrency. Causes: single-threaded Python parsing SSE, aiohttp/requests connection-pool caps, GIL contention, running the client on the same box as the server, or a network link between client and server that’s the actual bottleneck. Fixes: pin and monitor client CPU, remove connection caps (TCPConnector(limit=0)), use async or distributed workers (k6/Locust workers, multiple client hosts), and sanity-check with Little’s Law — if measured ( L = \lambda W ) can’t reach the server’s max-num-seqs, your client is starving it.
Reusing identical prompts / prefix caching artifacts. If every request sends the same prompt, prefix caching makes prefill nearly free and TTFT looks unrealistically good. Vary prompts (or explicitly test both with- and without-cache) so you measure the case you’ll actually run.
Not holding steady state / too-short runs. Little’s Law and stable percentiles need steady state. A 5-second run at 30 req/s is ~150 samples — too few for a trustworthy p99 (you want ≥1000+ post-warmup samples) and too short to reach queue equilibrium. Run each sweep point long enough that metrics stop drifting.
Production Checklist — What an Interviewer Probes
- “What do you measure, and why not just average latency?” — Name TTFT, TPOT/ITL, E2E, request- and output-token throughput; report p50/p95/p99, not means, because latency is heavy-tailed and the tail is what users feel. Mean is only for throughput accounting.
- “Open-loop or closed-loop, and what’s coordinated omission?” — Open-loop (arrival-rate) for SLA validation; closed-loop (fixed concurrency) only to find the throughput ceiling. Coordinated omission = closed-loop stops sending when the server stalls, so it hides the tail. Bonus: measure latency from the intended send time.
- “How do you find the right operating point?” — Sweep concurrency/rate, plot throughput and p95 latency vs load, find the knee where throughput flattens and latency turns up; operate just below it with headroom.
- “Apply Little’s Law here.” — ( L = \lambda W ). Given a rate and E2E latency, compute in-flight concurrency and compare to
max-num-seqsto detect oversubscription; convert closed-loop concurrency to sustainable req/s. - “How do you make the workload realistic?” — Match production input/output length distributions (ShareGPT / replayed logs), vary prompts to avoid prefix-cache flattery, and report the distribution used.
- “How do you know the client isn’t the bottleneck?” — Monitor client CPU, remove connection caps, use async/distributed generators, separate client and server hosts, and cross-check achieved ( L ) against server capacity.
- “Warmup and steady state?” — Discard cold-start requests (CUDA graphs/compile/cache allocation); run long enough (≥~1000 post-warmup samples, metrics stable) for trustworthy p99.
- “How do you gate regressions in CI?” — A reproducible benchmark (pinned tool/model/dataset/lengths) run at a fixed rate, asserting on p95 TTFT and output tok/s thresholds, so a config regression fails the build.
Further Reading
- vLLM — Benchmark CLI (
vllm bench serve): https://docs.vllm.ai/en/latest/cli/bench/serve/ - vLLM — Benchmarking overview & datasets: https://docs.vllm.ai/en/latest/benchmarking/cli/
- LLMPerf (Ray) — benchmarking library and
token_benchmark_ray.py: https://github.com/ray-project/llmperf - LLMPerf Leaderboard: https://github.com/ray-project/llmperf-leaderboard
- NVIDIA GenAI-Perf (Triton Perf Analyzer): https://docs.nvidia.com/deeplearning/triton-inference-server/user-guide/docs/perf_analyzer/genai-perf/README.html
- NVIDIA AIPerf — Metrics Reference (TTFT/ITL definitions): https://docs.nvidia.com/aiperf/reference/ai-perf-metrics-reference
- k6 — Open vs closed workload models: https://grafana.com/docs/k6/latest/using-k6/scenarios/concepts/open-vs-closed/
- Locust — documentation: https://docs.locust.io/en/stable/
- Gil Tene — “How NOT to Measure Latency” (coordinated omission, source): https://www.infoq.com/presentations/latency-response-time/
- ScyllaDB — On Coordinated Omission: https://www.scylladb.com/2021/04/22/on-coordinated-omission/
- Marc Brooker — Open, Closed, Omission and Collapse: https://brooker.co.za/blog/2023/05/10/open-closed.html
- Little’s Law (overview): https://en.wikipedia.org/wiki/Little%27s_law
- Red Hat AI Inference Server — validating with key metrics (TTFT/TPOT/throughput): https://docs.redhat.com/en/documentation/red_hat_ai_inference_server/3.1/html/getting_started/validating-benefits-with-key-metrics_getting-started
Topic 5: High-Performance Serving with vLLM
What You’ll Learn
This topic teaches you how to use vLLM for high-performance LLM serving:
- What vLLM is and why it’s fast
- Setting up vLLM server
- Understanding continuous batching
- PagedAttention memory optimization
- GPU utilization and throughput optimization
Why We Need This
Business Need
- Cost reduction: 10x higher throughput = 10x lower cost per request
- User experience: Lower latency = better user satisfaction
- Scalability: Handle more users with same infrastructure
- Competitive advantage: Faster responses than competitors
Technical Need
- GPU efficiency: 80-95% utilization vs 20-40% with basic serving
- Memory efficiency: Support longer sequences with same memory
- Throughput: 50-200 req/s vs 1-5 req/s with basic serving
- Production-ready: Used by major companies in production
Real-World Impact
Without vLLM:
- ❌ 10x higher infrastructure costs
- ❌ Poor user experience (slow responses)
- ❌ Can’t scale to handle traffic
- ❌ Wasted GPU resources (expensive!)
Industry Use Cases
1. High-Volume API Services
Company: OpenAI, Anthropic, Cohere Use Case:
- Serve millions of requests per day
- Need maximum GPU utilization
- Cost-sensitive at scale
Example:
# vLLM handles 1000 req/s vs 10 req/s with basic serving
# Cost: $10,000/month vs $100,000/month
2. Real-Time Applications
Company: Chatbots, code completion tools Use Case:
- Sub-second response times required
- Many concurrent users
- Low latency critical
Example:
# GitHub Copilot, ChatGPT use continuous batching
# User types → immediate suggestions
3. Cost-Optimized ML Platforms
Company: ML infrastructure companies Use Case:
- Serve multiple customers on shared infrastructure
- Maximize GPU utilization = lower costs
- Pass savings to customers
Example:
# Shared GPU cluster
# vLLM allows 10x more customers per GPU
4. Long Context Windows
Company: Document processing, code analysis Use Case:
- Process long documents (10K+ tokens)
- PagedAttention enables longer sequences
- Memory-efficient
Example:
# Process entire codebase (100K tokens)
# Traditional: Out of memory
# vLLM: Works efficiently
5. Multi-Tenant Serving
Company: SaaS platforms, ML platforms Use Case:
- Serve multiple models/users simultaneously
- Efficient resource sharing
- Fair resource allocation
Example:
# 100 customers, each with different model
# vLLM manages memory efficiently
Industry-Standard Boilerplate Code
Production vLLM Server (Industry Standard)
"""
Production vLLM server
Used by: OpenAI-compatible APIs, high-throughput serving
"""
from vllm import LLM, SamplingParams
from vllm.engine.arg_utils import AsyncEngineArgs
from vllm.engine.async_llm_engine import AsyncLLMEngine
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import uvicorn
import os
# Configuration from environment (12-factor app)
MODEL_NAME = os.getenv("MODEL_NAME", "gpt2")
GPU_MEMORY_UTILIZATION = float(os.getenv("GPU_MEMORY_UTILIZATION", "0.9"))
TENSOR_PARALLEL_SIZE = int(os.getenv("TENSOR_PARALLEL_SIZE", "1"))
MAX_MODEL_LEN = int(os.getenv("MAX_MODEL_LEN", "2048"))
app = FastAPI(title="vLLM Serving API")
# Initialize vLLM engine
llm_engine = None
@app.on_event("startup")
async def startup():
global llm_engine
engine_args = AsyncEngineArgs(
model=MODEL_NAME,
tensor_parallel_size=TENSOR_PARALLEL_SIZE,
gpu_memory_utilization=GPU_MEMORY_UTILIZATION,
max_model_len=MAX_MODEL_LEN,
dtype="float16", # FP16 for speed
trust_remote_code=True,
)
llm_engine = AsyncLLMEngine.from_engine_args(engine_args)
class CompletionRequest(BaseModel):
prompt: str
max_tokens: int = 100
temperature: float = 1.0
top_p: float = 1.0
@app.post("/v1/completions")
async def completions(request: CompletionRequest):
"""OpenAI-compatible endpoint"""
from vllm.utils import random_uuid
request_id = random_uuid()
sampling_params = SamplingParams(
temperature=request.temperature,
top_p=request.top_p,
max_tokens=request.max_tokens,
)
llm_engine.add_request(
request_id=request_id,
prompt=request.prompt,
sampling_params=sampling_params,
)
final_output = None
async for request_output in llm_engine.generate(
request_id=request_id,
prompt=request.prompt,
sampling_params=sampling_params,
):
final_output = request_output
return {
"choices": [{
"text": final_output.outputs[0].text,
"finish_reason": final_output.outputs[0].finish_reason
}]
}
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
vLLM with OpenAI Client (Industry Standard)
"""
Client code using vLLM server
Used by: Applications integrating with LLM APIs
"""
from openai import OpenAI
# vLLM provides OpenAI-compatible API
client = OpenAI(
base_url="http://vllm-server:8000/v1",
api_key="dummy" # vLLM doesn't require real key
)
def generate_text(prompt: str, max_tokens: int = 100) -> str:
"""Generate text using vLLM server"""
response = client.completions.create(
model="gpt2",
prompt=prompt,
max_tokens=max_tokens,
temperature=0.7
)
return response.choices[0].text
# Example: High-throughput batch processing
async def process_batch(prompts: list[str]):
"""Process multiple prompts concurrently"""
import asyncio
tasks = [
generate_text(prompt)
for prompt in prompts
]
return await asyncio.gather(*tasks)
Key Concepts
Continuous Batching
Traditional batching:
Request 1: [████████████████] (waiting)
Request 2: [████████████████] (waiting)
Request 3: [████████████████] (waiting)
→ Process all together
→ Wait for all to finish
vLLM continuous batching:
Request 1: [████████████] (done, remove)
Request 2: [████████████████] (processing)
Request 3: [████████] (processing)
Request 4: [██] (just added)
→ Process together, remove completed, add new
→ GPU always busy
PagedAttention
- Divides KV cache into fixed-size pages
- Allocates pages on-demand
- Reuses freed pages
- Result: Support for longer sequences, less memory waste
Installation
# vLLM requires CUDA (GPU)
pip install vllm
# Or with specific CUDA version
pip install vllm --extra-index-url https://download.pytorch.org/whl/cu118
Running the Examples
Option 1: Using vLLM’s Built-in Server
# Start vLLM server
python -m vllm.entrypoints.openai.api_server \
--model gpt2 \
--port 8000 \
--tensor-parallel-size 1
Option 2: Using Our Custom Server
cd 05_vllm_serving
pip install -r requirements.txt
python vllm_server.py
API Usage
vLLM provides OpenAI-compatible API:
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:8000/v1",
api_key="dummy" # vLLM doesn't require real API key
)
# Chat completion
response = client.chat.completions.create(
model="gpt2",
messages=[
{"role": "user", "content": "Hello, how are you?"}
],
max_tokens=100,
temperature=0.7
)
print(response.choices[0].message.content)
Performance Comparison
Basic Serving (HuggingFace)
- Throughput: ~1-5 requests/second
- Latency: 100-500ms per request
- GPU Utilization: 20-40%
vLLM Serving
- Throughput: ~50-200 requests/second
- Latency: 50-200ms per request
- GPU Utilization: 80-95%
Why the difference?
- Continuous batching keeps GPU busy
- PagedAttention uses memory efficiently
- Optimized CUDA kernels
Configuration Options
Model Loading
from vllm import LLM
llm = LLM(
model="gpt2",
tensor_parallel_size=1, # Number of GPUs
gpu_memory_utilization=0.9, # Use 90% of GPU memory
max_model_len=2048, # Maximum sequence length
dtype="float16", # Use FP16 for speed
)
Generation Parameters
outputs = llm.generate(
prompts=["Hello"],
sampling_params={
"temperature": 0.7,
"top_p": 0.9,
"max_tokens": 100,
}
)
Monitoring vLLM
vLLM exposes metrics you can monitor:
- Request queue size
- GPU utilization
- Throughput (tokens/second)
- Latency (P50, P95, P99)
See 08_monitoring/ for Grafana dashboards.
Common Issues
Out of Memory
- Problem: Model too large for GPU
- Solutions:
- Use smaller model
- Reduce
gpu_memory_utilization - Use quantization (INT8, INT4)
- Use tensor parallelism (split across GPUs)
Slow Performance
- Problem: Not using GPU
- Solution: Make sure CUDA is available:
python -c "import torch; print(torch.cuda.is_available())"
Import Errors
- Problem: vLLM not installed correctly
- Solution: Install with correct CUDA version
Exercises
- Compare Performance: Run same model with HuggingFace vs vLLM, measure throughput
- Tune Parameters: Experiment with
gpu_memory_utilization,max_model_len - Test Continuous Batching: Send requests at different rates, observe GPU utilization
- Monitor Metrics: Set up Prometheus to scrape vLLM metrics
Next Steps
- Topic 6: Autoscaling with vLLM
- Topic 8: Monitoring vLLM with Grafana
- Topic 11: Multi-model serving with Triton
Further Reading
vLLM Serving — PagedAttention, Continuous Batching, and Production Tuning
Why this matters
If you serve open-weight LLMs at any real scale, vLLM is very likely the engine underneath — or the baseline everything else is measured against. It became the default high-throughput inference server because it attacked the single biggest bottleneck in LLM serving: memory, specifically the KV cache. Its headline result, from the original paper, is a 2–4× throughput improvement at the same latency versus the prior state of the art (FasterTransformer and Orca).
The insight is almost embarrassingly simple in hindsight: LLM serving was wasting 60–80% of KV-cache memory to fragmentation and over-reservation. vLLM borrowed virtual memory and paging from operating systems, applied it to the KV cache, and turned that wasted memory back into batch capacity. More batch capacity means more requests share each expensive weight-load from GPU memory, which is exactly what raises throughput.
This chapter goes deep on the mechanisms (PagedAttention, continuous batching, prefix caching), the knobs that actually matter in production (gpu-memory-utilization, max-num-seqs, max-num-batched-tokens, chunked prefill), how to scale across GPUs, and how to trade quality for speed with quantization and speculative decoding. Then a fully worked tuning walkthrough, a comparison to TGI and TensorRT-LLM, and the failure modes that page you at 3am.
Core intuition: LLM inference is memory-bound, and the KV cache is the problem
Two facts drive everything:
-
Autoregressive decoding is memory-bandwidth-bound, not compute-bound. Generating one token touches the entire model weights but does very little arithmetic per token. The GPU spends most of its time reading weights from HBM, not doing math. The fix is batching: process many sequences at once so a single weight read serves many tokens. Bigger batch → higher throughput, until you run out of memory.
-
What runs you out of memory is the KV cache. For every token in every sequence, attention must remember the key and value vectors of all previous tokens. This “KV cache” grows linearly with sequence length and with the number of concurrent sequences. On a typical setup the weights are fixed, and whatever HBM is left is a fixed budget you spend on KV cache. The more efficiently you pack the KV cache, the bigger your batch, the higher your throughput.
So the game is: fit as many sequences’ KV caches into the leftover HBM as possible, and keep the GPU busy on all of them at once. PagedAttention wins the first half; continuous batching wins the second.
How big is the KV cache?
Per token, the KV cache size is:
[ \text{bytes/token} = 2 \times n_\text{layers} \times n_\text{kv_heads} \times d_\text{head} \times \text{dtype_bytes} ]
The leading (2) is for K and V. Note (n_\text{kv_heads}), not the number of query heads — models with grouped-query attention (GQA) share KV heads across query heads, which shrinks the cache dramatically.
Worked numbers (fp16, 2 bytes):
- OPT-13B (40 layers, hidden 5120, full MHA): (2 \times 40 \times 5120 \times 2 = 819{,}200) bytes ≈ 800 KB per token. A single 2048-token sequence needs ~1.6 GB of KV cache — this is the number from the vLLM paper.
- Llama-3-8B (32 layers, 8 KV heads, (d_\text{head}=128), GQA): (2 \times 32 \times 8 \times 128 \times 2 = 131{,}072) bytes = 128 KB per token. GQA makes it ~6× cheaper than a same-size MHA model.
At 800 KB/token, KV cache is enormous and dynamic — you don’t know a request’s final length in advance. That combination is exactly what classic allocators handle badly.
PagedAttention in depth
The problem it solves: fragmentation and over-reservation
Pre-vLLM systems (Orca, FasterTransformer) stored each sequence’s KV cache in one contiguous chunk of memory, sized to the maximum possible length. If your model supports 2048 tokens, every sequence reserved space for 2048 tokens the moment it started — even if it only ever generated 30. Three kinds of waste result, and the paper measures them directly:
- Internal fragmentation (13.3%–57.3%): the slot reserved for max length but never filled.
- Reservation waste (part of 25.2%–96.3%): space reserved for future tokens of a still-running sequence — technically “will be used” but idle now, so it can’t serve other requests.
- External fragmentation: gaps between contiguous chunks of different sizes that no new sequence fits into.
Net effect: measured effective KV utilization of only 20.4%–38.2%. Four out of five bytes wasted.
The idea: page the KV cache like virtual memory
Operating systems solved this decades ago. A process sees a contiguous virtual address space, but physically it’s scattered across fixed-size pages mapped by a page table. No process reserves all of physical RAM up front; pages are handed out on demand.
PagedAttention does the same for the KV cache:
- The KV cache of a sequence is split into fixed-size KV blocks, each holding the K and V vectors for a fixed number of tokens — the block size, default 16 tokens.
- Blocks live in a global pool of physical GPU memory and need not be contiguous.
- Each sequence has a block table mapping its logical block index → physical block number, exactly like a page table.
- The attention kernel is modified to gather K/V from these scattered blocks using the block table, so attention runs correctly over non-contiguous memory.
Diagram-in-words
Logical view (what the sequence "sees"):
Seq A tokens: [ t0 t1 ... t15 | t16 t17 ... t31 | t32 t33 ... ]
logical block 0 logical block 1 logical block 2
Block table for Seq A: [ 0 -> phys #7 ] [ 1 -> phys #3 ] [ 2 -> phys #11 ]
Physical KV block pool (16 tokens each, scattered in HBM):
#0 #1 #2 #3(A1) #4 #5 #6 #7(A0) #8 #9 #10 #11(A2) #12 ...
free free free used free ... free used ... used free
A block is allocated only when the sequence’s current block fills up. A sequence generating 30 tokens uses 2 blocks (32 slots), wasting at most 15 token-slots in its last block — at most one block of internal fragmentation per sequence, and zero reservation waste. External fragmentation vanishes because all blocks are the same size and interchangeable. Effective utilization approaches ~96%.
Sharing and copy-on-write
Because blocks are indirected through a block table, two sequences can point their block tables at the same physical block. This is where paging pays a second dividend:
- Shared prompts. In parallel sampling or beam search, (n) outputs share the same prompt. Instead of (n) copies of the prompt’s KV cache, all (n) block tables point at one shared set of prompt blocks. The paper reports up to 55% memory savings on parallel sampling / beam search.
- Copy-on-write (CoW). When one sharer needs to diverge (e.g., append a different token into a shared block), vLLM copies just that one block, updates that sequence’s block table, and leaves the others untouched — the same trick
fork()uses. Reference counts on each block track sharing.
This block-level sharing is the foundation that automatic prefix caching (below) builds on.
Worked memory example
Serve Llama-3-8B in fp16 on one A100-80GB.
- Weights: (8\text{B} \times 2\ \text{bytes} = 16\ \text{GB}).
- Budget:
--gpu-memory-utilization 0.9→ vLLM may use (0.9 \times 80 = 72\ \text{GB}). - Non-KV overhead: CUDA context, activations, CUDA graphs — call it ~2 GB.
- KV cache pool: (72 - 16 - 2 = 54\ \text{GB}).
- Per token: 128 KB (from above). Per block (16 tokens): (16 \times 128\ \text{KB} = 2\ \text{MB}).
- Total blocks: (54\ \text{GB} / 2\ \text{MB} \approx 27{,}600) blocks = ~442,000 tokens of KV capacity.
That single number, ~442k tokens, is your batch budget. It can be 54 sequences at the full 8192-context ((442000/8192)), or ~880 concurrent chatbot turns averaging 500 tokens each, or anything in between. vLLM logs this at startup as # GPU blocks: 27600 and reports “Maximum concurrency for 8192 tokens per request.” Watch that log line — it tells you exactly how much headroom you bought.
Continuous (in-flight) batching in depth
PagedAttention gives you the memory to run a big batch. Continuous batching keeps that batch full.
Static batching (the naive baseline)
Collect (N) requests, run them together, wait for all to finish, return, repeat. The problem: generations have wildly different lengths. If request A emits 20 tokens and request B emits 500, A’s slot sits idle for 480 steps while B finishes, because the batch can’t return or refill until the whole batch is done. GPU utilization craters, and latency for A is dictated by the slowest sibling.
Dynamic batching (a partial fix)
Servers like Triton’s dynamic batcher wait a few milliseconds to form a larger batch before launching, then still run it to completion as a unit. This improves batch size but does not solve the ragged-completion problem — it’s still batch-at-a-time.
Continuous batching (a.k.a. in-flight / iteration-level scheduling)
vLLM schedules at the granularity of a single decode step, not a whole request (the idea comes from Orca’s iteration-level scheduling). At every forward pass:
- Any sequence that emitted its EOS/stop this step is evicted immediately, and its KV blocks are freed.
- Waiting requests are admitted mid-flight to fill the vacated slots.
- The next forward pass runs over the new mix of prefills and decodes.
No sequence waits on a slower sibling. The batch is continuously topped up, so the GPU stays saturated. Anyscale’s widely cited benchmark measured up to 23× throughput from continuous batching plus paging versus naive batching, while also reducing p50 latency — a rare win on both axes, because higher utilization means less queueing.
Why it raises throughput: decoding is memory-bound, so throughput scales with how many sequences you can run per weight-load. Static batching leaves the effective batch shrinking toward 1 as siblings finish; continuous batching holds it near its memory-limited maximum every single step.
Prefill vs decode, and chunked prefill
A request has two phases with opposite performance profiles:
- Prefill — process the whole prompt in one big parallel pass. Compute-bound, high FLOPs, fills the pipeline. A 4000-token prompt is one heavy step.
- Decode — generate tokens one at a time. Memory-bound, tiny per-step compute.
Mixing them is awkward. A giant prefill can monopolize a forward pass and stall every decoding sequence, spiking inter-token latency (ITL) for everyone already streaming. This is the classic prefill/decode interference.
Chunked prefill (--enable-chunked-prefill) splits a large prefill into token-sized chunks and co-schedules a prefill chunk alongside ongoing decodes in the same batch, bounded by max-num-batched-tokens. Benefits:
- Smooths out ITL — decodes no longer freeze behind a monster prompt.
- Improves GPU utilization — decode steps are compute-light, so padding the batch with prefill tokens uses otherwise-idle FLOPs.
- In modern vLLM (V1 engine) chunked prefill is on by default, and prefill/decode are unified in one scheduler.
Tuning: raise max-num-batched-tokens for throughput (bigger chunks, more prefill work per step); lower it to protect decode latency (smaller chunks yield to decodes more often).
Prefix caching (automatic KV reuse)
Many requests share a prefix: the same long system prompt, a shared few-shot preamble, a document everyone asks questions about, a multi-turn conversation where each turn re-sends the history.
Automatic prefix caching (--enable-prefix-caching) hashes KV blocks by their content (and the tokens preceding them). When a new request’s prefix hashes to blocks already in the cache, vLLM skips recomputing that prefill entirely and points the new sequence’s block table at the cached blocks. It’s the block-sharing / CoW machinery from PagedAttention, applied across requests and over time.
- When it wins big: long shared system prompts, RAG with a fixed instruction preamble, multi-turn chat (each turn reuses the whole prior conversation’s KV), agent loops that resend context.
- Cost: cached blocks occupy KV memory that could otherwise hold active batch. Under memory pressure, cached prefix blocks are evicted LRU. It’s a hit-rate bet — near-free when hits are common, mild overhead when they’re not.
- In current vLLM (V1) prefix caching is enabled by default.
The saving is real work avoided: a 2000-token shared system prompt cached across 1000 requests skips ~2,000,000 tokens of prefill compute.
Memory and the engine args that matter
These are the flags you actually turn in production. Names are the current vllm serve CLI form (dashes); the Python LLM(...) form uses underscores.
| Flag | Default | What it does | How to tune |
|---|---|---|---|
--gpu-memory-utilization | 0.9 | Fraction of each GPU’s HBM vLLM may use (weights + KV + activations). Sets the KV pool size. | Raise toward 0.92–0.95 to grow the batch if you have headroom; lower if you OOM or co-locate other processes. Leave slack for activation spikes. |
--max-num-seqs | 256 (V1; was model-dependent) | Max sequences in a batch (concurrency cap). | Raise for throughput if KV memory allows; lower to cap per-request latency and memory. Often the real batch limit is KV memory, not this. |
--max-num-batched-tokens | auto (e.g. 8192/2048) | Max tokens processed per iteration (prefill chunks + decode tokens). | Raise for throughput, lower to protect ITL. Must be ≥ max-model-len unless chunked prefill is on. |
--max-model-len | from model config | Max context (prompt + output) per request. | Lower it to fit more sequences / avoid OOM when the model’s native context exceeds your needs. Directly bounds worst-case KV per sequence. |
--block-size | 16 | Tokens per KV block. | Rarely changed. Larger blocks = less overhead but more internal fragmentation. |
--enable-prefix-caching / --no-enable-prefix-caching | on (V1) | Reuse KV of shared prefixes across requests. | Keep on for chat/RAG/agents; disable only if prefixes never repeat and you want the memory back. |
--enable-chunked-prefill | on (V1) | Split prefills into chunks, co-schedule with decodes. | Keep on; tune via max-num-batched-tokens. |
--tensor-parallel-size (-tp) | 1 | Shard each layer across N GPUs (intra-node). | Set to fit a model too big for one GPU / to cut latency. Use ≤ GPUs per node with fast NVLink. |
--pipeline-parallel-size (-pp) | 1 | Split layers into stages across GPUs/nodes. | Use to span multiple nodes or when TP alone can’t fit the model. |
--quantization | none | Weight/activation quant scheme (awq, gptq, fp8, bitsandbytes, …). | Use to shrink weights → more KV room / smaller GPU. Costs some quality. |
--kv-cache-dtype | auto | Store KV cache in fp8 etc. | fp8 ~halves KV memory → bigger batch/context; small accuracy cost. |
--swap-space | 4 (GiB/GPU) | CPU RAM for swapping out preempted sequences’ KV. | Raise if you see frequent preemption + recompute; swap can be cheaper than recompute for long sequences. |
--max-num-seqs + --max-num-batched-tokens together | — | The two levers that shape the batch. | Co-tune: token budget caps work/step; seq budget caps concurrency. |
--dtype | auto | Compute dtype (bfloat16, float16). | bfloat16 on Ampere+; matters for numerical stability. |
--speculative-config | none | Speculative decoding config (JSON). | See below — latency win when acceptance is high. |
Startup log lines to watch: # GPU blocks: (your KV capacity), Maximum concurrency for N tokens, and any Sequence group ... is preempted warnings (you’re memory-starved).
Parallelism for big models
When a model (plus its KV cache) doesn’t fit on one GPU, or single-GPU latency is too high, split it.
Tensor parallelism (TP) — --tensor-parallel-size
Mechanism: shard every layer’s weight matrices across N GPUs; each GPU computes its slice, and an all-reduce combines partial results each layer. The KV cache is also sharded (by heads), so TP grows your KV budget too.
When: the model is too big for one GPU, or you want lower latency on a single request (more GPUs working the same forward pass). Best within one node over NVLink, because the per-layer all-reduce is bandwidth-hungry.
Tradeoff: communication overhead grows with N; going cross-node over slower interconnect (Ethernet/PCIe) tanks efficiency. Keep -tp ≤ GPUs-per-node. TP size must divide the number of attention heads.
Pipeline parallelism (PP) — --pipeline-parallel-size
Mechanism: assign contiguous stages of layers to different GPUs; activations flow stage → stage. Communication is a small point-to-point hand-off between stages, tolerant of slower links.
When: to span multiple nodes, or to fit truly huge models where even TP-across-a-node isn’t enough. Common pattern: -tp 8 within each node × -pp 2 across two nodes = 16 GPUs.
Tradeoff: introduces pipeline bubbles (stages idle waiting for the previous stage); throughput-friendly with enough in-flight requests, but adds latency per request. Combine TP (intra-node) + PP (inter-node) for the best of both.
Rule of thumb: TP first, up to one node; PP to cross nodes. vLLM also supports data parallelism / multi-replica behind a router for pure scale-out.
Quantization — trade quality for memory and speed
Quantization shrinks weights (and optionally activations/KV) to fewer bits. Smaller weights free HBM for KV cache and can speed up the memory-bound decode. All of it costs some accuracy; how much depends on scheme and model.
| Scheme | Bits | What’s quantized | When to use | Tradeoff |
|---|---|---|---|---|
| AWQ | 4-bit | Weights only (activation-aware, protects salient weights) | Serving throughput on Ampere/Ada; strong quality at 4-bit | Needs a pre-quantized AWQ checkpoint; INT4 weights dequantized for compute |
| GPTQ | 3/4/8-bit | Weights only (2nd-order error minimization) | Broad hardware/checkpoint availability | Quality can degrade at 3-bit; per-model calibration sensitivity |
| FP8 (E4M3) | 8-bit | Weights + activations (and KV via --kv-cache-dtype fp8) | Hopper/H100, Ada with hardware FP8; near-lossless, high throughput | Requires FP8-capable GPUs for full speedup |
| INT8 (SmoothQuant/W8A8) | 8-bit | Weights + activations | Good quality/speed balance where FP8 HW absent | More setup; less dramatic memory savings than 4-bit |
| bitsandbytes | 4/8-bit | Weights, on-the-fly | Quick experiments, no pre-quant step | Slower kernels; not the throughput champion |
Guidance:
- Memory-constrained, throughput-focused, Ampere/Ada: AWQ 4-bit is the workhorse — cuts weight memory ~4×, freeing large KV headroom.
- H100 / Hopper: prefer FP8 — near-lossless and uses native tensor-core FP8 for real speedups; add
--kv-cache-dtype fp8to roughly double KV capacity. - Quality-sensitive tasks (code, math, long reasoning): measure. 4-bit weight-only can visibly hurt; validate on your eval set, not just perplexity.
- Quantization reduces weight memory, not KV — for long-context blowup,
--kv-cache-dtype fp8and--max-model-lenare the relevant levers.
Speculative decoding — lower latency, not more throughput
Mechanism: a cheap draft proposes several tokens ahead; the big target model verifies them all in one forward pass. Accepted tokens are kept; the first rejection resets to the target’s own token. Because verification is parallel, a good draft yields multiple tokens per target forward pass — output is provably identical in distribution to the target alone (it’s exact, not approximate).
Draft sources vLLM supports include a small draft model, n-gram / prompt-lookup (propose from repeated text — great for code and RAG where output echoes input), and EAGLE / Medusa-style self-speculative heads.
When to use: latency-sensitive, low-to-moderate batch serving where the GPU has spare compute (decode is memory-bound, so verification is nearly free). Interactive chat, single-user, or bursty low-QPS endpoints.
Tradeoff: the win depends entirely on acceptance rate. Low acceptance means you paid for drafting and got little back — it can reduce throughput. And under high batch load the GPU is already compute-saturated, so speculation’s “free” parallel verification isn’t free anymore — its benefit shrinks or reverses. Rule: speculate when you’re latency-bound and under-batched; skip it when you’re throughput-bound and saturated.
Fully worked example: serve Llama-3-8B on one A100-80GB, then tune
1. Baseline launch (OpenAI-compatible server)
vllm serve meta-llama/Meta-Llama-3-8B-Instruct \
--host 0.0.0.0 --port 8000 \
--dtype bfloat16 \
--max-model-len 8192 \
--gpu-memory-utilization 0.90
At startup, read the log:
INFO ... # GPU blocks: 27600, # CPU blocks: 2048
INFO ... Maximum concurrency for 8192 tokens per request: 53.9x
That confirms the ~442k-token / ~54-concurrent budget we computed by hand.
2. Call it with the OpenAI client
The server speaks the OpenAI API, so existing SDKs work unchanged — just point base_url at vLLM:
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1", api_key="EMPTY")
resp = client.chat.completions.create(
model="meta-llama/Meta-Llama-3-8B-Instruct",
messages=[
{"role": "system", "content": "You are a terse assistant."},
{"role": "user", "content": "Explain PagedAttention in two sentences."},
],
max_tokens=128,
temperature=0.2,
stream=True, # tokens stream as they decode
)
for chunk in resp:
delta = chunk.choices[0].delta.content or ""
print(delta, end="", flush=True)
curl sanity check:
curl http://localhost:8000/v1/completions \
-H "Content-Type: application/json" \
-d '{"model":"meta-llama/Meta-Llama-3-8B-Instruct","prompt":"Hello","max_tokens":16}'
3. Tuning walkthrough (target: max throughput for a RAG service)
Symptoms to check first with a load test (vllm bench serve or a locust/k6 run): GPU util, tokens/s, p99 ITL, and any preempted warnings.
- Shared 1500-token system+retrieval preamble across requests → keep
--enable-prefix-caching(default on). Hit rate is high; prefill work drops sharply. - GPU shows headroom, no OOM → push memory:
Bigger token budget = fatter prefill chunks and higher throughput; more seqs = more concurrency, backed by the enlarged KV pool.vllm serve meta-llama/Meta-Llama-3-8B-Instruct \ --gpu-memory-utilization 0.94 \ --max-num-seqs 384 \ --max-num-batched-tokens 16384 \ --enable-chunked-prefill \ --max-model-len 8192 - p99 inter-token latency too high (big prompts stalling decodes) → lower
--max-num-batched-tokens(e.g. to 4096). Smaller chunks yield to decodes more often, smoothing ITL at a small throughput cost. Sequence group is preempted by ...warnings → you over-committed KV. Either drop--max-num-seqs, drop--max-model-len, or raise--swap-spaceso preempted sequences swap to CPU instead of recomputing.- Need more KV for longer contexts → add
--kv-cache-dtype fp8(roughly doubles KV capacity) and/or--quantization fp8on an H100 to also shrink weights. - Model too big for one GPU (e.g. Llama-3-70B) →
--tensor-parallel-size 4(or 8) within the node; across nodes add--pipeline-parallel-size 2.
Iterate: change one knob, re-run the load test, compare tokens/s and p99. Stop when you’re memory-limited (preemptions appear) or latency SLO-limited.
Comparison: vLLM vs TGI vs TensorRT-LLM / Triton
| Dimension | vLLM | TGI (HF Text Generation Inference) | TensorRT-LLM + Triton |
|---|---|---|---|
| Core strength | PagedAttention + continuous batching; best throughput/$ out of the box | Solid production server, tight HF ecosystem fit | Peak NVIDIA-GPU performance via compiled engines |
| Batching | Continuous, iteration-level | Continuous (in-flight) | In-flight batching (Triton backend) |
| KV memory mgmt | PagedAttention (near-zero waste) | Paged KV (adopted vLLM-style ideas) | Paged KV |
| Prefix caching | Automatic, on by default | Supported | Supported |
| Quantization | AWQ, GPTQ, FP8, INT8, bnb | AWQ, GPTQ, EETQ, FP8, bnb | INT4/8, FP8 (compiled, very fast) |
| Speculative decode | Draft model, n-gram, EAGLE/Medusa | Medusa / n-gram | EAGLE, Medusa, draft |
| Setup cost | Low — pip install, one command | Low — Docker image | High — per-model engine build/compile step |
| Hardware | NVIDIA + AMD ROCm + others | NVIDIA + AMD | NVIDIA only |
| API | OpenAI-compatible server | OpenAI-compatible + native | Triton (OpenAI frontend available) |
| Best when | Default choice; open models, fast iteration, high throughput | HF-centric stacks wanting a batteries-included server | Squeezing max perf on fixed NVIDIA hardware, willing to pay build complexity |
Reality check: the three have converged — TGI and TensorRT-LLM adopted paged KV and in-flight batching. TensorRT-LLM often wins raw latency/throughput on NVIDIA thanks to ahead-of-time kernel compilation, at the cost of a per-model engine-build step and NVIDIA lock-in. vLLM wins on flexibility, ease, and hardware breadth, and is the usual default. Always benchmark on your model, hardware, and traffic shape before deciding.
Failure modes and pitfalls
- OOM at startup from
gpu-memory-utilizationtoo high. vLLM pre-allocates the KV pool; if you set 0.98 with no slack, activation spikes or CUDA-graph capture push you over and it crashes on load. Back off to ~0.90 and grow gradually. Co-located processes share the same HBM — vLLM only sees the fraction you give it. - Preemption and recompute thrash. When admitted sequences collectively exceed KV capacity, vLLM preempts some — either swapping their KV to CPU (
--swap-space) or discarding and recomputing it later. Frequentpreemptedwarnings mean you over-committedmax-num-seqs/max-model-len; throughput drops as work is redone. Fix by lowering concurrency, shorteningmax-model-len, or adding swap. - Long-context KV blowup. KV grows linearly with context. A handful of 128k-token requests can consume the entire pool and starve everyone else. Bound it with
--max-model-len,--kv-cache-dtype fp8, and admission limits; don’t advertise a context you can’t afford to serve concurrently. - Quantization quality loss. 4-bit weight-only (AWQ/GPTQ) can degrade code/math/reasoning noticeably even when perplexity looks fine. Always validate on a task-specific eval, and prefer FP8 on Hopper where it’s near-lossless.
- Speculative decoding backfiring. Low draft acceptance, or high batch load, turns speculation into pure overhead. Measure acceptance rate; disable under saturation.
max-num-batched-tokenstoo low with chunked prefill off. If a prompt exceeds the token budget and chunked prefill isn’t enabled, requests fail. Keep chunked prefill on, or set the budget ≥max-model-len.- Assuming
max-num-seqsis the batch limit. Usually KV memory binds first. Raisingmax-num-seqswithout KV headroom just causes preemption. Watch the# GPU blockslog, not just the seq cap. - Prefix cache eviction under pressure. Cached prefixes compete with active KV; under load they’re evicted and hit rate falls — throughput quietly regresses. Size memory for both if prefix reuse is core to your workload.
Production checklist — what an interviewer probes
- “Why is LLM decode memory-bound, and why does that make batching the key throughput lever?” — Weights are re-read every token; batching amortizes the read. Expect you to connect this to KV cache being the batch-size limiter.
- “Explain PagedAttention and what waste it eliminates.” — Blocks + block table, non-contiguous KV, block size 16, kills internal/external fragmentation and reservation waste (from ~20–38% utilization to ~96%).
- “Continuous vs static batching — why does continuous raise throughput and cut latency?” — Iteration-level scheduling evicts finished seqs and admits new ones each step; no slot idles behind a slow sibling.
- “Walk me through sizing KV cache and picking
gpu-memory-utilization/max-num-seqs/max-model-lenfor a given GPU.” — Do the bytes/token math, subtract weights, derive block count and concurrency; explain preemption when over-committed. - “When TP vs PP? What are the comms costs?” — TP intra-node over NVLink (per-layer all-reduce), PP across nodes (stage hand-off, pipeline bubbles).
- “Quantization choices and their quality cost — AWQ vs GPTQ vs FP8, and when each?” — 4-bit weight-only for memory on Ampere; FP8 near-lossless on Hopper; validate on task evals.
- “When does speculative decoding help vs hurt?” — Helps latency at low batch with high acceptance; hurts under saturation or low acceptance.
- “How do you debug an OOM or a latency spike in production?” — Check
# GPU blocksand preemption logs; lowergpu-memory-utilization/max-model-len; tunemax-num-batched-tokensfor ITL; addswap-space; confirm prefix-cache hit rate.
Bonus signals: knowing chunked prefill and prefix caching are on by default in the V1 engine, that max-num-seqs rarely binds before KV memory, and that you always benchmark on real traffic rather than trusting a spec sheet.
How the attention kernel actually reads paged blocks
It’s worth being precise about why PagedAttention needs a custom kernel, because interviewers probe it. Standard fused attention (FlashAttention) assumes K and V for a sequence live in one contiguous tensor it can stride through. Paged KV breaks that assumption: a sequence’s K/V are scattered across physical blocks in arbitrary order.
The PagedAttention kernel therefore takes the block table as an input. For a query at the current position it:
- Reads the sequence’s block table (logical block → physical block number).
- For each logical block, computes attention scores ( q \cdot k ) against the K vectors in that physical block, iterating block by block.
- Accumulates the softmax-weighted sum of V vectors from the same blocks.
Because the block is the unit of gather, the kernel does a small indirection per block (once per 16 tokens), not per token — cheap relative to the matmul. The block table lives in GPU memory alongside the cache. Modern vLLM builds this on FlashAttention/FlashInfer backends that natively accept paged KV, so you keep FlashAttention’s IO-awareness and paging. This is the crux: paging costs almost nothing at kernel time, yet returns most of the wasted memory as usable batch.
The scheduler: waiting, running, swapped
vLLM’s scheduler maintains three queues and reconciles them every step — this is the machinery behind continuous batching, preemption, and swapping.
- Waiting — admitted requests not yet started (need KV blocks allocated for their prefill).
- Running — sequences actively decoding (or being prefilled) this step.
- Swapped — sequences preempted out of GPU KV, their blocks parked in CPU swap space.
Each iteration the scheduler:
- Frees blocks of any sequence that finished last step.
- Tries to admit waiting requests into running, subject to the KV block budget and
max-num-seqs/max-num-batched-tokens. - If running collectively needs more blocks than exist (e.g., all sequences grew a token and a new block boundary was crossed), it preempts the lowest-priority sequences — either swap (copy their KV blocks to CPU, restore later) or recompute (drop KV, re-run prefill when readmitted). Recompute is the default for short sequences; swap wins for long ones where recompute is expensive.
The default policy is FCFS-ish with the newest/lowest-priority preempted first. The practical takeaway: preemption is the pressure-relief valve, and seeing it constantly in logs means your admission settings exceed your true KV capacity. It is correct behavior, not a bug — but it costs throughput, so tune it away.
Benchmarking vLLM properly
Never tune by feel. vLLM ships a benchmark harness that mirrors real serving:
# Start the server, then in another shell:
vllm bench serve \
--model meta-llama/Meta-Llama-3-8B-Instruct \
--dataset-name sharegpt \
--dataset-path ShareGPT_V3_unfiltered_cleaned_split.json \
--num-prompts 1000 \
--request-rate 20
Metrics that matter, and what they mean:
- Throughput (tokens/s, requests/s) — the number to maximize for batch/offline workloads.
- TTFT (time to first token) — dominated by prefill and queueing; what a chat user feels as “lag before it starts.”
- ITL / TPOT (inter-token latency / time per output token) — decode smoothness; hurt by big un-chunked prefills.
- p50 vs p99 — always look at the tail. High p99 with fine p50 usually means preemption or prefill interference.
Sweep one knob at a time (gpu-memory-utilization, max-num-batched-tokens, max-num-seqs) and plot throughput vs p99 latency. The right operating point is the knee of that curve for your SLO — not the max-throughput point, which usually violates latency targets.
V1 engine and disaggregated serving (where vLLM is heading)
Two architectural notes worth knowing:
- The V1 engine (default in current vLLM) rewrote the core for lower CPU overhead and a unified scheduler where prefill and decode are co-scheduled by default. Chunked prefill and prefix caching are on by default there. If you read older tutorials that tell you to manually enable these, that advice is stale.
- Disaggregated prefill/decode separates the compute-bound prefill and memory-bound decode onto different GPU pools, streaming the KV cache between them. Because the two phases have opposite resource profiles, dedicating hardware to each — and scaling them independently — can beat co-locating them, especially at high scale with long prompts. This is an emerging pattern (KV transfer over NVLink/RDMA) that large deployments increasingly adopt.
Second worked example: Llama-3-70B across GPUs
An 8B model fits one card; a 70B does not. In fp16, weights alone are ( 70\text{B} \times 2 = 140\ \text{GB} ) — larger than a single 80 GB GPU. Options:
Option A — tensor-parallel across 4 GPUs (one node, NVLink):
vllm serve meta-llama/Meta-Llama-3-70B-Instruct \
--tensor-parallel-size 4 \
--dtype bfloat16 \
--max-model-len 8192 \
--gpu-memory-utilization 0.92
Weights shard to ( 140/4 = 35\ \text{GB} ) per GPU, leaving each card ~( 0.92 \times 80 - 35 \approx 38\ \text{GB} ) (minus overhead) for its KV shard. KV is also split by heads across the 4 GPUs, so aggregate KV capacity is roughly 4× a single card’s leftover — that is what makes big batches on 70B feasible.
Option B — quantize to AWQ 4-bit, fit on fewer GPUs:
vllm serve casperhansen/llama-3-70b-instruct-awq \
--quantization awq \
--tensor-parallel-size 2 \
--max-model-len 8192
4-bit weights are ~( 70\text{B} \times 0.5 = 35\ \text{GB} ), fitting two 80 GB cards with room for KV. Fewer GPUs, lower cost, at some quality cost — validate on your eval set.
Option C — two nodes, TP×PP: --tensor-parallel-size 8 --pipeline-parallel-size 2 spreads a large model across 16 GPUs, TP within each node over NVLink and PP across the two nodes over the slower inter-node link. Launch via vLLM’s Ray-based multi-node path.
Decision order: fit on one node with TP first; quantize to shrink weights and cut GPU count; go multi-node with PP only when a single node genuinely cannot hold model + working KV.
Reading the startup logs (your first diagnostic)
Every launch prints the numbers that tell you whether your config is sane. Learn to read them before touching load tests:
INFO ... Available KV cache memory: 53.7 GiB
INFO ... GPU KV cache size: 442,368 tokens
INFO ... Maximum concurrency for 8192 tokens per request: 54.0x
INFO ... # GPU blocks: 27648, # CPU blocks: 2048
- Available KV cache memory — what’s left after weights + overhead. If this is tiny or negative-adjacent, lower
max-model-len, quantize, or add GPUs. - GPU KV cache size (tokens) — your total batch budget in tokens; divide by average request length to estimate real concurrency.
- Maximum concurrency — full-context sequences you can run at once. If it’s < your expected concurrency, you will preempt under load.
- # CPU blocks — swap capacity, sized by
--swap-space.
If you never look at anything else, look at these four lines. They convert the abstract flags into the one number that governs throughput: how many tokens of KV you can hold.
Quick-reference config recipes
| Goal | Starting flags |
|---|---|
| Max throughput, batch/offline | --gpu-memory-utilization 0.95 --max-num-batched-tokens 16384 --max-num-seqs 512 |
| Low-latency interactive chat | --max-num-batched-tokens 4096 (protect ITL) --enable-chunked-prefill, consider speculative decoding |
| Long-context serving | --kv-cache-dtype fp8 --max-model-len <needed> --swap-space 16, cap concurrency |
| Memory-tight single GPU | --quantization awq (or fp8 on Hopper) --gpu-memory-utilization 0.90 |
| RAG with shared preamble | keep --enable-prefix-caching (default), moderate max-num-seqs |
| Model bigger than one GPU | --tensor-parallel-size N (one node) [+ --pipeline-parallel-size M across nodes] |
Further reading
- Kwon et al., “Efficient Memory Management for Large Language Model Serving with PagedAttention,” SOSP 2023 — the vLLM paper: https://arxiv.org/abs/2309.06180
- ACM DL (SOSP ’23 proceedings): https://dl.acm.org/doi/10.1145/3600006.3613165
- vLLM PagedAttention design doc: https://docs.vllm.ai/en/latest/design/paged_attention/
- vLLM Engine Arguments reference: https://docs.vllm.ai/en/stable/configuration/engine_args/
- vLLM Optimization and Tuning (chunked prefill, batching knobs): https://docs.vllm.ai/en/latest/configuration/optimization/
- vLLM Automatic Prefix Caching: https://docs.vllm.ai/en/latest/features/automatic_prefix_caching.html
- vLLM Distributed Inference (TP/PP): https://docs.vllm.ai/en/latest/serving/distributed_serving.html
- vLLM Quantization overview: https://docs.vllm.ai/en/latest/features/quantization/
- vLLM Speculative Decoding: https://docs.vllm.ai/en/latest/features/spec_decode.html
- vLLM OpenAI-compatible server: https://docs.vllm.ai/en/latest/serving/openai_compatible_server.html
- Anyscale, “How continuous batching enables 23x throughput in LLM inference”: https://www.anyscale.com/blog/continuous-batching-llm-inference
- Orca (iteration-level scheduling), OSDI 2022: https://www.usenix.org/conference/osdi22/presentation/yu
Topic 6: Autoscaling
What You’ll Learn
This topic teaches you how to:
- Set up Horizontal Pod Autoscaling (HPA)
- Scale based on CPU/memory metrics
- Scale based on custom metrics (request rate, latency)
- Configure scaling policies
- Handle scale-up and scale-down events
Why Autoscaling?
Benefits
- Cost optimization: Scale down when not needed
- Performance: Scale up under load
- Automation: No manual intervention needed
- Efficiency: Right-size resources
When to Scale
- High CPU/memory: Pods are overloaded
- High request rate: Many incoming requests
- High latency: System struggling
- Queue building: Requests waiting
Types of Autoscaling
1. Horizontal Pod Autoscaler (HPA)
Scales number of pod replicas based on metrics.
2. Vertical Pod Autoscaler (VPA)
Adjusts resource requests/limits (advanced).
3. Cluster Autoscaler
Adds/removes nodes (cloud providers).
HPA Basics
Simple HPA (CPU-based)
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: llm-serving-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: llm-serving
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
How It Works
- HPA checks metrics every 15s (default)
- Compares current vs target
- Calculates desired replicas
- Updates deployment
- K8s creates/destroys pods
Custom Metrics
Request Rate Scaling
Scale based on requests per second:
metrics:
- type: Pods
pods:
metric:
name: http_requests_per_second
target:
type: AverageValue
averageValue: "10"
Latency-based Scaling
Scale when latency is high:
metrics:
- type: Pods
pods:
metric:
name: request_latency_p95
target:
type: AverageValue
averageValue: "500m" # 500ms
Scaling Behavior
Scale-up Policy
behavior:
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Percent
value: 100
periodSeconds: 15
- type: Pods
value: 4
periodSeconds: 15
selectPolicy: Max
Scale-down Policy
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 50
periodSeconds: 60
selectPolicy: Min
Metrics Server
HPA needs metrics. Install metrics server:
kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml
Verify:
kubectl top nodes
kubectl top pods
Prometheus Adapter
For custom metrics, use Prometheus Adapter:
- Install Prometheus
- Install Prometheus Adapter
- Configure custom metrics
- HPA can query Prometheus
See 08_monitoring/ for Prometheus setup.
Complete HPA Example
See hpa.yaml for a complete example with:
- CPU scaling
- Memory scaling
- Custom metrics
- Scaling policies
Testing Autoscaling
Generate Load
# Use Locust or similar
locust -f locust_test.py --host=http://<service-url> \
--users 50 --spawn-rate 5
Watch Scaling
# Watch HPA
kubectl get hpa -w
# Watch pods
kubectl get pods -w
# Watch deployment
kubectl get deployment llm-serving -w
Check Metrics
# CPU/Memory
kubectl top pods
# HPA events
kubectl describe hpa llm-serving-hpa
Best Practices
- Set min replicas: Always have some pods running
- Set max replicas: Prevent runaway scaling
- Use stabilization windows: Avoid flapping
- Monitor scaling events: Track scale-up/down
- Test under load: Verify scaling works
- Consider costs: More pods = more cost
Common Issues
HPA Not Scaling
- Check metrics server is running
- Verify metrics are available
- Check HPA status:
kubectl describe hpa - Ensure deployment has resource requests
Scaling Too Aggressively
- Increase stabilization window
- Adjust scaling policies
- Check for metric spikes
Scaling Too Slowly
- Decrease stabilization window
- Adjust scaling policies
- Check metric collection delay
Exercises
- Basic HPA: Set up CPU-based autoscaling
- Custom metrics: Scale based on request rate
- Scaling policies: Configure scale-up/down behavior
- Load test: Generate load and observe scaling
- Monitor: Track scaling events and metrics
Next Steps
- Topic 7: Canary deployments for safe updates
- Topic 8: Monitor autoscaling with Grafana
- Topic 9: Version management with scaling
Further Reading
Autoscaling LLM Inference
Scaling GPU serving with demand — without wrecking latency or blowing the budget.
Why This Matters
A web service scales cheaply: pods are small, start in seconds, and CPU utilization is a clean proxy for load. LLM inference breaks all three assumptions. A single replica pins one or more GPUs that cost more per hour than an entire fleet of CPU pods. A new replica must pull tens of gigabytes of weights and warm CUDA before it serves a single token, so “add capacity” is a multi-minute operation, not a multi-second one. And traffic is bursty — a Slack integration or a batch job can 10x your request rate in seconds.
Get autoscaling wrong and you fail in one of two expensive directions:
- Under-provision: the queue backs up, time-to-first-token (TTFT) climbs, requests time out, and by the time a new replica is ready the spike is over.
- Over-provision: you pay for idle H100s around the clock to hedge against a spike that comes twice a day.
This chapter is about threading that needle: scaling on the right signals, using the right mechanism (HPA, KEDA, Knative), and mitigating the cold-start tax that makes LLM autoscaling uniquely hard. For the metric definitions and the Prometheus/Grafana stack that feeds these controllers, see the Monitoring chapter.
Core Intuition: Why CPU-Based HPA Is Wrong for LLMs
The default Kubernetes HorizontalPodAutoscaler scales on CPU utilization. For a stateless web app that is a reasonable proxy: more requests → more CPU → scale up. For an LLM server it is actively misleading.
Consider what a vLLM or TGI process actually does. The heavy lifting happens on the GPU; the Python/host process spends most of its time waiting on CUDA kernels and shuffling tensors. So:
- A GPU that is 100% saturated — KV cache full, requests queuing — can show modest host CPU. HPA sees “plenty of headroom” and refuses to scale while your p99 latency melts.
- A freshly loaded replica warming its cache can spike CPU while serving nothing, tricking HPA into scaling up when it shouldn’t.
CPU utilization is decoupled from the thing you actually care about: can I admit another request and still hit my latency SLO? For LLMs the honest answer lives in queue depth, in-flight concurrency, KV-cache pressure, GPU utilization, and TTFT — never in host CPU.
The mental model: scale on the length of the line, and on how long people wait in it — not on how busy the cashier’s hands look.
The scaling control loop
Every mechanism below is the same loop with different parts swapped in. Keep it in your head:
requests ──▶ [ vLLM replicas ] ──▶ metrics (queue depth, GPU util, TTFT)
▲ │
│ ▼
scale up / down [ Prometheus / dcgm-exporter ]
▲ │
│ ▼
[ Deployment ] ◀── HPA / KEDA / KPA ◀── PromQL query vs target
The controller polls a metric, compares it to a target, and nudges the replica count. Everything interesting — which metric, which target, how fast to react, whether zero is allowed — is a knob on that loop.
The Right Scaling Signals
There is no single perfect signal. Each trades responsiveness against noise and against how directly it maps to user-visible latency. Good production setups combine a fast demand signal (queue depth / concurrency) with an SLO guardrail (TTFT or p95 latency) so a breach of either forces a scale-up.
| Signal | Source | Pros | Cons |
|---|---|---|---|
Queue depth (vllm:num_requests_waiting) | vLLM/TGI Prometheus metric | Directly reflects unmet demand; leads latency, so it’s an early signal; cheap to compute | Zero when you’re merely at capacity-but-coping; noisy for spiky traffic without smoothing |
In-flight / concurrency (vllm:num_requests_running) | Engine metric or Knative KPA | Maps cleanly to a per-replica capacity target; stable | Saturates at the batch limit — can’t tell “full” from “overwhelmed” alone |
GPU utilization (DCGM_FI_DEV_GPU_UTIL) | NVIDIA dcgm-exporter | Hardware truth; catches non-vLLM workloads too | Lagging and coarse — 100% util can mean “efficiently batched” or “drowning”; poor sole signal |
KV-cache usage (vllm:gpu_cache_usage_perc) | vLLM metric | Predicts imminent preemption/OOM before latency degrades | vLLM-specific; needs a sensible target (~90%) |
TTFT / p95 latency (vllm:e2e_request_latency_seconds) | Engine histogram → histogram_quantile | Is the SLO — what users actually feel | Lagging: by the time it breaches, users are already hurting. Use as guardrail, not primary |
| Requests per second | Ingress / KPA | Simple, intuitive | Ignores request size; 10 long generations ≠ 10 short ones |
| Batch/token throughput | Engine metrics | Reflects real GPU work | Hard to set a stable target; varies with prompt length |
Rule of thumb: lead with queue depth or concurrency, guard with a latency SLO, and treat GPU util as a sanity cross-check — not as the trigger.
Mechanism 1 — HPA with Custom / External Metrics
Kubernetes’ HPA can scale on more than CPU. Since autoscaling/v2 it supports three metric flavors:
- Resource — CPU/memory (the default; wrong for us).
- Pods — a custom per-pod metric averaged across pods (e.g. queue depth per replica).
- Object / External — a metric attached to another object or pulled from an external system (e.g. a Prometheus query).
To feed HPA a Prometheus metric you install the Prometheus Adapter (prometheus-adapter), which registers the custom.metrics.k8s.io / external.metrics.k8s.io APIs and translates HPA’s metric requests into PromQL. GPU utilization itself comes from NVIDIA’s dcgm-exporter (metric DCGM_FI_DEV_GPU_UTIL), scraped by Prometheus.
Wiring the metric pipeline
HPA does not speak PromQL. The Prometheus Adapter bridges the gap: you give it a rule that maps a Kubernetes metric name to a query. A minimal rule exposing vLLM’s queue depth as a per-pod custom metric looks like:
# prometheus-adapter values.yaml (rules.custom[])
rules:
custom:
- seriesQuery: 'vllm:num_requests_waiting{namespace!="",pod!=""}'
resources:
overrides:
namespace: {resource: "namespace"}
pod: {resource: "pod"}
name:
matches: "vllm:num_requests_waiting"
as: "vllm_num_requests_waiting" # HPA-friendly name (no colon)
metricsQuery: 'sum(<<.Series>>{<<.LabelMatchers>>}) by (<<.GroupBy>>)'
Verify the metric is actually served before pointing HPA at it — a huge fraction of “HPA won’t scale” incidents are just a missing or misnamed metric:
kubectl get --raw \
"/apis/custom.metrics.k8s.io/v1beta1/namespaces/inference/pods/*/vllm_num_requests_waiting" | jq .
If that returns no metrics returned from custom metrics API, the problem is the pipeline (labels, series name, scrape) — not the HPA.
The HPA algorithm
HPA computes desired replicas with a simple ratio:
[ \text{desiredReplicas} = \left\lceil \text{currentReplicas} \times \frac{\text{currentMetricValue}}{\text{desiredMetricValue}} \right\rceil ]
With multiple metrics, HPA computes a target for each and takes the maximum — the metric demanding the most replicas wins. That is exactly why a fast demand signal plus a latency guardrail composes well: whichever is more stressed drives the decision.
Worked HPA manifest — scale on GPU utilization + queue depth
This assumes dcgm-exporter and prometheus-adapter are installed, and the adapter exposes DCGM_FI_DEV_GPU_UTIL as a Pods metric and vllm_num_requests_waiting as an External metric.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: vllm-hpa
namespace: inference
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: vllm-llama3-8b
minReplicas: 2 # provisioned floor — never cold-start the first request
maxReplicas: 12 # capped by GPU quota (see pitfalls)
metrics:
# Primary demand signal: waiting requests per replica
- type: Pods
pods:
metric:
name: vllm_num_requests_waiting
target:
type: AverageValue
averageValue: "5" # aim to keep <5 queued per pod
# Hardware cross-check: average GPU utilization
- type: Pods
pods:
metric:
name: DCGM_FI_DEV_GPU_UTIL
target:
type: AverageValue
averageValue: "75" # scale up past ~75% average util
behavior:
scaleUp:
stabilizationWindowSeconds: 0 # react to spikes immediately
policies:
- type: Pods
value: 4 # add up to 4 pods...
periodSeconds: 60 # ...per minute
- type: Percent
value: 100 # ...or double, whichever is larger
periodSeconds: 60
selectPolicy: Max
scaleDown:
stabilizationWindowSeconds: 300 # wait 5 min of calm before shrinking
policies:
- type: Pods
value: 1 # remove at most 1 pod...
periodSeconds: 120 # ...every 2 min — GPUs are expensive to churn
selectPolicy: Min
Tuning notes.
scaleUp.stabilizationWindowSeconds: 0— scale up on the freshest reading. Because cold starts are slow, hesitating on scale-up is the costliest mistake you can make.scaleDown.stabilizationWindowSeconds: 300— HPA takes the highest recommendation over the window before shrinking, so a 5-minute window prevents a brief traffic lull from tearing down a replica you’ll re-pay a cold start to rebuild.- Asymmetric policies — aggressive up, gentle down (one pod every two minutes). This is the opposite of a cost-first web-app config, and it’s deliberate: for GPUs, flapping is more expensive than a little idle.
- Target values are per-pod averages —
averageValue: "5"means HPA aims for 5 waiting requests per replica, so the ratio math scales linearly with fleet size.
Mechanism 2 — KEDA for Event-Driven & Queue-Based Scaling
HPA + Prometheus Adapter works but is fiddly: you maintain adapter rules, and HPA alone cannot scale to zero. KEDA (Kubernetes Event-Driven Autoscaling) sits on top of HPA and fixes both. It ships 70+ scalers (Prometheus, Kafka, SQS, RabbitMQ, Redis, …) and, crucially, can scale a deployment from 0 → 1 and back to 0.
KEDA introduces two ideas HPA lacks:
activationThreshold— the value that flips a workload from zero to one. This is separate from the scalingthreshold(which governs 1→N). It exists precisely so a single stray request doesn’t wake a cold GPU, and so a trickle doesn’t keep one warm.minReplicaCount: 0— legal in KEDA, impossible in raw HPA.
Worked KEDA ScaledObject — queue depth + latency guardrail
This mirrors the pattern AWS documents for vLLM on EKS: a primary queue-depth trigger and a p95-latency guardrail, scaling to satisfy whichever demands more replicas.
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: vllm-scaler
namespace: inference
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: vllm-llama3-8b
minReplicaCount: 1 # warm floor; set 0 only if cold starts are acceptable
maxReplicaCount: 12
pollingInterval: 15 # query Prometheus every 15s
cooldownPeriod: 300 # after last trigger, wait 5 min before scaling toward min
advanced:
horizontalPodAutoscalerConfig:
behavior: # KEDA passes this straight through to the HPA it manages
scaleDown:
stabilizationWindowSeconds: 300
scaleUp:
stabilizationWindowSeconds: 0
triggers:
# Primary: queue depth (waiting requests), averaged per replica
- type: prometheus
metricType: AverageValue
metadata:
serverAddress: http://kube-prometheus-stack-prometheus.monitoring.svc:9090
query: sum(vllm:num_requests_waiting) or vector(0)
threshold: "5"
activationThreshold: "1" # first waiting request wakes the deployment
# Guardrail: p95 end-to-end latency in seconds
- type: prometheus
metricType: AverageValue
metadata:
serverAddress: http://kube-prometheus-stack-prometheus.monitoring.svc:9090
query: |
histogram_quantile(0.95,
sum(rate(vllm:e2e_request_latency_seconds_bucket[1m])) by (le)) or vector(0)
threshold: "5" # scale up if p95 latency exceeds 5s
Tuning notes.
or vector(0)is not decoration — if the query returns no series (e.g. the deployment is scaled to zero and exporting nothing), KEDA would otherwise error.or vector(0)yields a clean0, which is exactly the “no demand” reading you want.metricType: AverageValuedivides the query result by the current replica count so the target is per-pod; useValueonly when your query already returns a per-pod figure.activationThreshold: "1"vsthreshold: "5"— one waiting request is enough to justify the first replica; you only add more replicas once the per-pod queue passes 5.cooldownPeriodgoverns the final ramp towardminReplicaCount(including the drop to zero); the HPAbehaviorblock governs the 1→N steps.- Prefer a dedicated queue metric (
num_requests_waiting) over latency as the primary — latency lags, and by the time p95 breaches, the SLO is already violated. Latency is the seatbelt, not the accelerator.
Mechanism 3 — Scale-to-Zero, Cold Starts, and Serverless GPU
Scale-to-zero is the dream: pay nothing when idle. For LLMs it collides head-on with the cold-start problem.
Anatomy of an LLM cold start
When a scaled-to-zero deployment gets a request, the clock runs through:
- Scheduling — Kubernetes finds a node with a free GPU (seconds → minutes if the cluster autoscaler must add a node).
- Image pull — the container image is often 5–15 GB (CUDA, PyTorch, vLLM). Seconds to minutes if not cached on the node.
- Weight load — read tens of GB of weights from disk/network into host RAM, then copy to VRAM. This dominates — often the largest single chunk.
- CUDA / engine warm-up — initialize CUDA context, compile/capture CUDA graphs, allocate the KV cache.
For a mid-size model this is routinely 1–5 minutes, and can be far worse if the cluster autoscaler has to boot a fresh GPU node first. That is an eternity for an interactive request. So true scale-to-zero is only acceptable for latency-tolerant workloads (batch, internal tools, dev). For anything user-facing, you keep a warm floor.
Cold-start mitigation comparison
| Mitigation | How it works | Cold-start impact | Cost | Best for |
|---|---|---|---|---|
Provisioned min replicas (minReplicas/minReplicaCount ≥ 1) | Never fully scale down; keep N warm | Eliminates it for the first N concurrent requests | Highest — you pay for idle GPUs | Interactive, SLA-bound traffic |
| Warm pool / over-provision headroom | Keep spare ready replicas ahead of demand (e.g. +1 buffer) | New traffic hits an already-warm pod | Medium — pay for the buffer only | Predictable spikes, autoscaling with slack |
| Faster weight loading (Run:ai Model Streamer, tensorizer, safetensors + fast storage) | Stream weights concurrently from object storage straight to GPU; skip slow deserialization | Cuts the dominant load phase (reported up to ~6x) | Low — engineering only | Every setup; stacks with others |
| Node/image pre-pull & DaemonSet cache | Pre-pull the container image and warm node caches | Removes image-pull phase | Low | Large images, node churn |
| Snapshot / checkpoint-restore (NVIDIA Dynamo snapshot, CUDA checkpoint/CRIU) | Snapshot a warmed process (CUDA context + weights in VRAM) and restore it | Can approach near-zero — skips load and warm-up | Medium; newer/less mature | Aggressive scale-to-zero without the latency tax |
| Smaller/quantized model or smaller shards | Fewer bytes to move and initialize | Proportionally shorter load | Free-ish (accuracy tradeoff) | When quality budget allows |
Knative / serverless GPU. Knative Serving offers request-driven autoscaling with native scale-to-zero. Its default KPA (Knative Pod Autoscaler) scales on concurrency or RPS rather than CPU — a much better fit for LLMs than raw HPA. Key pieces:
containerConcurrency/ target concurrency — the per-replica in-flight target KPA scales to maintain.- The activator buffers requests while a scaled-to-zero service spins up, so requests aren’t dropped — they’re held (and pay the cold-start latency).
- Panic mode / target-burst-capacity — when traffic spikes sharply, KPA enters a short “panic” window and scales on a much shorter horizon to react fast, then relaxes.
Knative is elegant for bursty, latency-tolerant serving, but the activator’s request buffering doesn’t erase the cold start — it just prevents dropped requests. You still pay the minutes. Pair scale-to-zero with a snapshot/fast-load strategy, or keep minScale ≥ 1 for interactive paths.
Worked Knative Service — concurrency-driven with a warm floor
The same workload as a Knative Service, scaling on concurrency with KPA. Note minScale: 1 — a warm floor that dodges the cold start on the interactive path while still capping cost with maxScale.
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
name: vllm-llama3-8b
namespace: inference
spec:
template:
metadata:
annotations:
autoscaling.knative.dev/class: "kpa.autoscaling.knative.dev"
autoscaling.knative.dev/metric: "concurrency"
autoscaling.knative.dev/target: "8" # ~8 in-flight requests per replica
autoscaling.knative.dev/target-utilization-percentage: "80"
autoscaling.knative.dev/min-scale: "1" # warm floor (set 0 for scale-to-zero)
autoscaling.knative.dev/max-scale: "12"
autoscaling.knative.dev/scale-down-delay: "5m" # hold before shrinking
autoscaling.knative.dev/target-burst-capacity: "200" # activator buffers bursts
spec:
containerConcurrency: 16 # hard per-replica ceiling
containers:
- image: vllm/vllm-openai:latest
resources:
limits:
nvidia.com/gpu: "1"
Tuning notes.
target: 8vscontainerConcurrency: 16— KPA aims to keep ~8 concurrent requests per replica (the soft target) while 16 is the hard cap. Keeping the soft target well below the hard cap leaves slack for the seconds before a new replica is ready.target-burst-capacitydecides how much spike the activator absorbs (buffering requests) before KPA has scaled out; higher values route more traffic through the activator, trading a little steady-state latency for burst safety.scale-down-delayis Knative’s answer to flapping — the KPA equivalent of HPA’s scale-down stabilization window.- Set
min-scale: 0only when the workload tolerates the cold start; the activator will hold the request but the user still waits out the load.
Which mechanism should I pick?
| Situation | Reach for | Why |
|---|---|---|
| Simple custom-metric scaling, floor ≥ 1, existing Prometheus | HPA + Prometheus Adapter | Fewest moving parts; native; no scale-to-zero needed |
| Queue/event-driven, scale-to-zero, many metric sources | KEDA | activationThreshold, 0→1, and 70+ scalers wrap HPA cleanly |
| Concurrency-driven serverless with request buffering | Knative (KPA) | Built-in scale-to-zero + activator; concurrency target fits LLMs |
| Bursty, latency-tolerant, want managed cold-start buffering | Knative | Activator holds requests during spin-up so nothing drops |
| Interactive, strict TTFT SLO | Any + warm floor | The mechanism matters less than never cold-starting the hot path |
These aren’t mutually exclusive: a common production shape is KEDA for the demand-driven scale (including 0→1) plus a provisioned floor for the interactive tier, with all three fed by the same Prometheus pipeline.
The Cost vs Latency Tradeoff
Every autoscaling decision is a bet on this tradeoff. Warm replicas cost money every second they’re idle; cold replicas cost latency (and lost requests) every time you’re caught short. You can make the tradeoff explicit.
A worked calculation
Suppose one H100 replica costs about ( $3.50 ) per hour and serves a steady ( 20 ) requests/second at your latency SLO. Your traffic is ( 20 ) req/s for 8 business hours and near-zero the other 16.
Option A — always-on flat fleet. Provision for peak, run 24/7:
[ \text{Cost}_A = 1 \text{ replica} \times $3.50/\text{hr} \times 24 \text{ hr} = $84 \text{ per day} ]
Option B — autoscale with a warm floor. Keep minReplicas = 1 only during the 8 busy hours, scale to zero otherwise. Assume the 16 idle hours truly cost nothing:
[ \text{Cost}_B = 1 \times $3.50 \times 8 = $28 \text{ per day} ]
That’s a 67% saving — but it buys a cold start on the first request each morning and after any midday lull. If the SLO forbids a multi-minute first response, you instead keep a warm floor 24/7 and land back near Option A, or you spend engineering effort on snapshot/fast-load so scale-to-zero becomes safe.
Headroom sizing. To absorb bursts without waiting on a cold start, provision a buffer. If your scale-up (schedule + pull + load + warm) takes ( T_{cold} = 180 ) s and traffic can climb at ( 2 ) req/s², a replica in flight can’t help for 3 minutes. Size the warm buffer to cover demand growth over ( T_{cold} ):
[ \text{buffer replicas} = \left\lceil \frac{\Delta(\text{req/s over } T_{cold})}{\text{capacity per replica}} \right\rceil ]
The general shape: provisioned floor covers the baseline, headroom buffer covers what arrives during a cold start, and autoscaling handles the sustained ramp. Tune the floor and buffer to the cost of a missed SLO, not to a generic utilization target.
Failure Modes & Pitfalls
- Flapping (thrashing). Symmetric or twitchy thresholds scale up and down every few minutes, and each cycle pays a cold start. Fix: long
scaleDown.stabilizationWindowSeconds(300s+), conservative scale-down policies, and a generouscooldownPeriod. For GPUs, err toward stickiness. - Scaling on a lagging metric. If p95 latency or GPU utilization is your primary trigger, you scale after users are already hurting, and because cold starts are slow you stay behind the curve for minutes. Fix: lead with a leading signal (queue depth / concurrency); keep latency as a guardrail only.
- Thundering herd on cold model load. A big spike triggers many replicas at once; they simultaneously hammer the same weights bucket / registry, saturating network and disk, so all of them cold-start slower. Fix: cap
scaleUpstep size (Pods: value/periodSeconds), stagger with fast-load streaming, pre-warm images, and cache weights on nodes. - GPU quota / capacity ceilings.
maxReplicasis a wish; cloud GPU quota and actual availability are the reality. HPA will happily request 12 pods that sitPendingforever because there are no H100s to schedule them on. Fix: setmaxReplicasto your real quota, alert onPendingGPU pods, and combine with cluster-autoscaler node pools sized to quota. - Metric pipeline is a hidden dependency. If Prometheus, the adapter, or
dcgm-exporterhiccups, HPA/KEDA see stale or missing metrics and may freeze or over-react. Fix:or vector(0)guards,ignoreNullValues, alert on the metrics pipeline itself, and a sane default replica count. - Per-pod vs total metric confusion. Using
Valuewhere you meantAverageValue(or a rawsum()without dividing by replicas) makes the target scale wrong as the fleet grows — you either never scale or scale to the moon. Fix: be explicit about per-pod semantics and test with a load generator. - Scale-down mid-generation. Terminating a pod that’s mid-stream kills in-flight long generations. Fix: graceful termination with a drain period longer than your max generation time, and
terminationGracePeriodSecondssized accordingly.
Production Checklist — What an Interviewer Probes
- “Why not CPU-based HPA for an LLM?” — Host CPU is decoupled from GPU saturation; a full GPU can look idle to HPA. Name the real signals: queue depth, concurrency, KV-cache, GPU util, TTFT.
- “What’s your primary scaling signal and why?” — A leading demand signal (
vllm:num_requests_waiting/ concurrency), with a lagging latency SLO as a guardrail, and the max-of-metrics behavior that composes them. - “How do you handle cold starts?” — Quantify the phases (schedule, pull, load, warm), then name concrete mitigations: warm floor, headroom buffer, fast weight streaming, snapshot/checkpoint-restore, image pre-pull.
- “Scale to zero — yes or no?” — “It depends on the SLO.” Fine for batch/internal; dangerous for interactive unless snapshotting makes cold starts sub-second. Explain KEDA’s
activationThreshold. - “How do you stop it flapping?” — Asymmetric behavior: aggressive scale-up (0s window), conservative scale-down (300s+ window, one pod at a time), cooldown.
- “HPA vs KEDA vs Knative — when each?” — HPA+adapter for simple custom-metric scaling; KEDA for event/queue-driven and scale-to-zero on any metric; Knative/KPA for concurrency-driven serverless with request buffering.
- “What breaks under a real traffic spike?” — Thundering herd on weight load, GPU quota ceilings leaving pods
Pending, and lagging-metric lock-step. Have a mitigation for each. - “How do you pick the target value / threshold?” — Derive it from load tests (see the Load Testing chapter): find the per-replica concurrency/queue depth at which TTFT just meets SLO, then set the target below it.
Further Reading
- Kubernetes — Horizontal Pod Autoscaler (v2, behavior & algorithm)
- Kubernetes — HPA Walkthrough with custom metrics
- KEDA — Prometheus scaler and Scaling Deployments (activation vs threshold, scale-to-zero)
- AWS — Autoscale AI inference with HPA and KEDA on EKS (vLLM)
- vLLM — Autoscaling with KEDA (production-stack)
- Knative — Autoscaling (KPA, concurrency, scale-to-zero, panic mode)
- Prometheus Adapter — kubernetes-sigs/prometheus-adapter
- NVIDIA — dcgm-exporter (GPU metrics for Prometheus)
- NVIDIA — Dynamo Snapshot: fast startup for inference on Kubernetes
- Microsoft Azure — Eliminate LLM cold starts: load models up to 6x faster with Run:ai Model Streamer
- KServe — Autoscaler for generative inference
Related chapters: Load Testing to derive your target thresholds, vLLM Serving for the engine metrics, and Monitoring for the Prometheus/Grafana pipeline that feeds every controller above.
Topic 7: Canary Deployments
What You’ll Learn
This topic teaches you how to:
- Deploy new model versions safely
- Split traffic between versions
- Gradually roll out updates
- Rollback quickly if issues occur
- A/B test different models
- Monitor canary performance
Why Canary Deployments?
Benefits
- Risk reduction: Test new version on small traffic
- Gradual rollout: Increase confidence before full deployment
- Quick rollback: Revert if issues detected
- A/B testing: Compare model versions
- Zero downtime: No service interruption
When to Use
- Model updates: New model version
- Configuration changes: Different parameters
- A/B testing: Compare model performance
- Risk mitigation: Critical production systems
Canary Deployment Strategies
1. Traffic Splitting
Route X% of traffic to new version.
2. User-based
Route specific users to new version.
3. Header-based
Route based on HTTP headers.
4. Gradual Rollout
Start with 5%, increase to 100% over time.
Implementation Options
Option 1: Kubernetes with Service Mesh (Istio)
Most flexible, supports advanced routing.
Option 2: Kubernetes with Multiple Deployments
Simple, uses native K8s features.
Option 3: Application-level Routing
Handle in application code.
Simple Canary with K8s
Step 1: Deploy Stable Version
apiVersion: apps/v1
kind: Deployment
metadata:
name: llm-serving-stable
spec:
replicas: 9
selector:
matchLabels:
app: llm-serving
version: stable
Step 2: Deploy Canary Version
apiVersion: apps/v1
kind: Deployment
metadata:
name: llm-serving-canary
spec:
replicas: 1
selector:
matchLabels:
app: llm-serving
version: canary
Step 3: Service Selector
Service routes to both versions:
apiVersion: v1
kind: Service
metadata:
name: llm-serving
spec:
selector:
app: llm-serving # Matches both stable and canary
Traffic splits 90/10 based on replica count.
Istio Canary (Advanced)
VirtualService
Control traffic routing:
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: llm-serving
spec:
hosts:
- llm-serving
http:
- match:
- headers:
canary:
exact: "true"
route:
- destination:
host: llm-serving
subset: canary
weight: 100
- route:
- destination:
host: llm-serving
subset: stable
weight: 90
- destination:
host: llm-serving
subset: canary
weight: 10
DestinationRule
Define subsets:
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
name: llm-serving
spec:
host: llm-serving
subsets:
- name: stable
labels:
version: stable
- name: canary
labels:
version: canary
Gradual Rollout
Phase 1: 5% Traffic (Day 1)
- Deploy canary with 1 replica
- Monitor for 24 hours
- Check metrics: latency, errors, throughput
Phase 2: 25% Traffic (Day 2)
- Scale canary to 3 replicas
- Continue monitoring
Phase 3: 50% Traffic (Day 3)
- Scale canary to 5 replicas
- Monitor closely
Phase 4: 100% Traffic (Day 4)
- Scale canary to 10 replicas
- Remove stable deployment
- Canary becomes new stable
Monitoring Canary
Key Metrics to Compare
- Latency: P50, P95, P99
- Error rate: Failed requests
- Throughput: Requests per second
- GPU utilization: Resource usage
- Model-specific: Tokens/second, quality metrics
Prometheus Queries
# Compare latency
histogram_quantile(0.95,
rate(llm_request_duration_seconds_bucket{version="canary"}[5m])
) /
histogram_quantile(0.95,
rate(llm_request_duration_seconds_bucket{version="stable"}[5m])
)
# Compare error rates
rate(llm_requests_total{version="canary",status="500"}[5m]) /
rate(llm_requests_total{version="canary"}[5m])
Rollback Procedure
Automatic Rollback
Set up alerts that trigger rollback:
- Error rate > threshold
- Latency > threshold
- Custom metrics fail
Manual Rollback
# Scale canary to 0
kubectl scale deployment llm-serving-canary --replicas=0
# Or route all traffic to stable
# Update VirtualService weights
A/B Testing
Compare Models
Deploy two different models:
- Model A: Current production
- Model B: New candidate
Metrics to Track
- Performance: Latency, throughput
- Quality: User feedback, accuracy
- Cost: GPU hours, inference cost
Decision Criteria
- Better performance AND quality → Promote
- Worse performance OR quality → Reject
- Mixed results → Continue testing
Best Practices
- Start small: 5-10% traffic initially
- Monitor closely: Watch metrics during rollout
- Have rollback plan: Know how to revert quickly
- Test thoroughly: Test canary before production
- Document changes: Track what changed
- Communicate: Inform team of canary deployment
Common Issues
Canary Performing Worse
- Check if it’s expected (new model might be slower)
- Compare metrics carefully
- Consider rollback if critical
Traffic Not Splitting Correctly
- Verify service selectors
- Check replica counts
- Verify routing rules (if using Istio)
Canary Not Getting Traffic
- Check service endpoints
- Verify labels match
- Check routing configuration
Exercises
- Basic canary: Deploy canary with 10% traffic
- Gradual rollout: Increase from 10% to 100% over phases
- Monitoring: Set up dashboards to compare versions
- Rollback: Practice rolling back canary
- A/B test: Compare two different models
Next Steps
- Topic 8: Monitor canary with Grafana
- Topic 9: Model versioning system
- Topic 10: Detect drift in canary
Further Reading
Canary Deployments for Model Serving
Safely rolling out new models and versions — when the thing you are canarying can pass every infra gate and still be worse.
Why this matters
Shipping application code and shipping a new model version look identical from thirty thousand feet: build an artifact, put it behind a service, shift traffic, watch dashboards. They are not the same problem.
A web service is mostly right or wrong. It returns a 200 or a 500, it is fast or slow, and your infrastructure metrics — error rate, p95 latency, saturation — catch essentially every regression that matters. A canary that watches those four numbers is a very good canary.
A model is right, wrong, or plausibly-wrong-in-a-way-that-looks-right. A new checkpoint can return HTTP 200 on every request, at lower latency than the old one, while quietly hallucinating more, refusing more legitimate prompts, drifting in tone, or regressing on your hardest 5% of inputs. Every infra gate is green. Your users are unhappy. This is the single most important thing to internalize in this chapter:
Model regressions live in the response body, not in the response envelope. Infra canaries only watch the envelope.
So model serving needs everything a normal progressive-delivery pipeline has — traffic splitting, automated analysis, progressive promotion, automatic rollback — plus a quality gate that reads the body. The rest of this chapter builds that pipeline from the bottom up and then shows you the failure modes that bite teams who forget the italicized sentence above.
Core intuition: models fail in ways infra canaries don’t catch
Picture two versions of a summarization model behind a gateway. You send 10% of live traffic to v2. Over an hour you observe:
| Signal | v1 (stable) | v2 (canary) | Infra verdict |
|---|---|---|---|
| HTTP 5xx rate | 0.02% | 0.02% | ✅ pass |
| p95 latency | 480 ms | 410 ms | ✅ pass (faster!) |
| Pod restarts / OOMs | 0 | 0 | ✅ pass |
| Throughput | 220 rps | 235 rps | ✅ pass |
| Groundedness / factuality | 0.91 | 0.78 | ❌ regression |
| Refusal rate on valid prompts | 1.2% | 6.5% | ❌ regression |
A classic canary — Flagger or Argo Rollouts watching request-success-rate and request-duration — promotes this rollout. It is faster and just as reliable by every number it knows how to read. The two bottom rows are invisible to it because they require scoring the generated text, which is not a Prometheus counter you get for free.
The mental model:
- Infra metrics answer “did the request complete correctly?” — cheap, real-time, always available.
- Quality metrics answer “was the answer good?” — expensive, often delayed, and specific to your task.
Canary analysis for models = the union of both. If your pipeline only has the first, you have built a very sophisticated way to ship regressions with confidence.
The four strategies and when each fits model serving
Before mechanisms, get the taxonomy straight. All four move traffic from an old version to a new one; they differ in how much blast radius a bad version gets and how fast you can undo it.
- Rolling update — replace old pods with new ones a few at a time. There is no “old vs new” concept at the traffic layer; once a pod is new, it serves real users. This is the Kubernetes Deployment default.
- Blue-green — stand up the full new version (green) alongside the full old version (blue), test green out-of-band, then flip 100% of traffic at once. Instant cutover, instant rollback (flip back), but you pay for two full fleets during the overlap.
- Canary — run the new version at small scale, send it a slice of real traffic (1% → 5% → 25% → …), analyze, and promote in steps. Small blast radius, gradual confidence.
- Shadow (mirror) — send the new version a copy of real traffic but discard its responses. Users never see canary output. Pure evaluation, zero user risk.
Strategy comparison
| Strategy | User blast radius if bad | Rollback speed | Extra cost | Catches quality regressions? | Best fit for models |
|---|---|---|---|---|---|
| Rolling | Grows as pods replace; hard to bound | Slow (roll back = another rollout) | ~none | No — new pods serve users immediately | Low-risk config bumps, sidecar updates |
| Blue-green | 100% at the instant of flip | Instant (flip traffic back) | High (2× fleet during overlap) | Only if you gate the flip on an eval suite | Big/atomic version jumps where partial-mix is unacceptable |
| Canary | Bounded to the canary weight (e.g. 5%) | Fast (set weight → 0) | Moderate (small extra fleet) | Only if analysis includes a quality gate | The default for model rollouts |
| Shadow | Zero (responses discarded) | N/A (no user traffic to roll back) | High (full 2nd inference path, doubled GPU) | Yes, offline — no user exposure | Pre-canary validation of risky checkpoints |
Rules of thumb for model serving:
- Never roll out a new model with a bare rolling update. You lose the old/new traffic distinction exactly when you most need it, and GPU pods are slow to spin up/down so a “quick” rollback isn’t quick.
- Canary is the workhorse. Small weight, automated analysis on infra and quality, progressive promotion.
- Shadow first for scary changes (new architecture, new quantization, new base model). It gives you production-distribution eval data with zero user exposure — then canary the survivors.
- Blue-green when the mix itself is the problem — e.g. a prompt-format or tokenizer change where having v1 and v2 answer the same conversation would be incoherent, so you want an atomic switch gated on a full eval run.
Mechanism 1: Traffic splitting
Canary and shadow both need to route a fraction of requests somewhere. There are four common layers to do it, from crudest to most precise.
1a. Kubernetes Service — replica-ratio splitting (crude, avoid)
The oldest trick: two Deployments (model-v1, model-v2) sharing one Service via a common label selector. The Service load-balances across all matching pods, so the traffic split ≈ the replica ratio. Want 10% canary? Run 9 stable pods and 1 canary pod.
apiVersion: v1
kind: Service
metadata:
name: model
spec:
selector:
app: model # matches BOTH v1 and v2 pods
ports:
- port: 80
targetPort: 8000
Why this is bad for models:
- Weight is quantized by replica count. A GPU pod might be an entire A100; you cannot cheaply run “0.5 of one” to get 5%.
- Weight and capacity are coupled — you can’t send 1% of traffic to a canary that has 3 replicas for latency headroom.
- No session affinity, no header-based routing, no clean rollback primitive.
Use it only for a quick-and-dirty test. For anything real, split at the mesh/gateway layer.
1b. Istio VirtualService — weighted routing
Istio decouples weight from replica count. A DestinationRule defines subsets by label; a VirtualService assigns weights that sum to 100.
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata:
name: model
spec:
host: model
subsets:
- name: v1
labels: { version: v1 }
- name: v2
labels: { version: v2 }
---
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
name: model
spec:
hosts:
- model
http:
- route:
- destination:
host: model
subset: v1
weight: 90
- destination:
host: model
subset: v2
weight: 10
To advance the canary you edit the two weight values (90/10 → 75/25 → 0/100). To roll back, set v2 to 0. This is the primitive Argo Rollouts and Flagger drive for you (below) — they rewrite these weights automatically.
1c. Gateway API — the vendor-neutral successor
Gateway API (HTTPRoute) does the same weighting in a mesh-agnostic way. Note the docs’ precise wording: weight is a proportional split, not a percentage — the sum of weights in a rule is the denominator.
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: model-split
spec:
parentRefs:
- name: model-gateway
rules:
- backendRefs:
- name: model-v1
port: 8000
weight: 90
- name: model-v2
port: 8000
weight: 10
90 + 10 = 100 so v2 gets 10%. If you’d written 9 and 1, v2 still gets 10% — the ratio is what matters. Gateway API is where new tooling is converging; prefer it for greenfield.
1d. Header / session-based routing (for stateful serving)
Weighted splitting assumes requests are independent. LLM chat sessions are not — a multi-turn conversation must hit the same model version, or turn 3 answers in v2’s voice after turns 1–2 were v1’s. Route by a stable key instead:
http:
- match:
- headers:
x-canary:
exact: "true" # opt-in cohort, internal users, etc.
route:
- destination: { host: model, subset: v2 }
- route: # everyone else
- destination: { host: model, subset: v1 }
More on session pinning in Failure Modes.
Mechanism 2: Automated canary analysis
Manually staring at Grafana while you bump weights doesn’t scale and doesn’t fire at 3 a.m. Automated analysis makes the promote/rollback decision from metrics. Two dominant tools: Argo Rollouts and Flagger.
The shape of both:
- You declare steps (weights + pauses) and metrics with thresholds.
- The controller shifts traffic to the first step.
- At each step it queries metrics (usually Prometheus) over an interval, a number of times.
- If a metric violates its condition too many times → abort and roll back.
- If all steps pass → promote (canary becomes stable).
Argo Rollouts: AnalysisTemplate
An AnalysisTemplate is a reusable metric-check bundle. This one watches success rate and p95 latency from Istio’s Prometheus metrics:
apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
name: infra-metrics
spec:
args:
- name: service-name
metrics:
- name: success-rate
interval: 1m
count: 5 # take 5 measurements
successCondition: result[0] >= 0.99
failureLimit: 2 # allow 2 bad reads before aborting
provider:
prometheus:
address: http://prometheus.istio-system:9090
query: |
sum(irate(istio_requests_total{
destination_service=~"{{args.service-name}}",
response_code!~"5.."}[1m]))
/
sum(irate(istio_requests_total{
destination_service=~"{{args.service-name}}"}[1m]))
- name: p95-latency
interval: 1m
count: 5
successCondition: result[0] <= 500 # milliseconds
failureLimit: 2
provider:
prometheus:
address: http://prometheus.istio-system:9090
query: |
histogram_quantile(0.95,
sum(irate(istio_request_duration_milliseconds_bucket{
destination_service=~"{{args.service-name}}"}[1m]))
by (le))
Field semantics that trip people up:
interval— how often to run the query.count— how many times total; the analysis runscount × intervalbefore it can succeed.successCondition/failureCondition— a boolean expression overresult(the query’s returned vector). Provide one or the other.failureLimit— how many failed measurements are tolerated before the whole AnalysisRun fails and triggers rollback.failureLimit: 0means one bad read aborts.
Wiring it into a Rollout
The Rollout object replaces your Deployment. Its canary steps interleave setWeight, pause, and analysis:
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
name: model
spec:
replicas: 6
selector:
matchLabels: { app: model }
template:
metadata:
labels: { app: model }
spec:
containers:
- name: server
image: registry.example.com/model:v2
ports:
- containerPort: 8000
resources:
limits: { nvidia.com/gpu: 1 }
strategy:
canary:
canaryService: model-canary # Service pointing only at canary pods
stableService: model-stable # Service pointing only at stable pods
trafficRouting:
istio:
virtualService:
name: model
routes: [primary]
steps:
- setWeight: 5
- pause: { duration: 10m }
- analysis:
templates:
- templateName: infra-metrics
args:
- name: service-name
value: model-canary.default.svc.cluster.local
- setWeight: 25
- pause: { duration: 10m }
- analysis:
templates:
- templateName: infra-metrics
args:
- name: service-name
value: model-canary.default.svc.cluster.local
- setWeight: 50
- pause: { duration: 30m }
- setWeight: 100
What happens on kubectl apply with a new image:
- Argo creates canary pods, points
model-canaryat them, and sets the VirtualService to 5% canary / 95% stable. - Waits 10m, then runs
infra-metricsfive times over five minutes. - Any check fails past
failureLimit→ weight snapped back to 0, rollout marked Degraded, canary pods torn down. Automatic rollback. - All pass → advance to 25%, repeat, then 50%, then 100%. At 100% the canary ReplicaSet becomes stable.
This is a correct, faster, greener rollout of the bad summarizer from the intuition section — because infra-metrics never looks at output quality. Now we fix that.
Adding a model-quality gate
The quality gate is just another metric in the AnalysisTemplate — the trick is where the number comes from. Three patterns, cheapest to strongest:
Pattern A — proxy signals already in Prometheus. Some quality signals are cheap counters if your server emits them: refusal rate, empty-completion rate, average output token count (a proxy for truncation/degeneration), guardrail-filter trigger rate, mean logprob. Gate on those directly:
- name: refusal-rate
interval: 2m
count: 5
failureCondition: result[0] > 0.03 # >3% refusals on valid prompts = bad
failureLimit: 1
provider:
prometheus:
address: http://prometheus.istio-system:9090
query: |
sum(irate(model_refusals_total{version="canary"}[2m]))
/
sum(irate(model_requests_total{version="canary"}[2m]))
Pattern B — an online judge/eval job that writes a gauge. Run an evaluator (LLM-as-judge, a reward model, or a reference-based scorer on prompts that have known-good answers) against a sample of canary responses, and have it push a score to Prometheus (Pushgateway) or an HTTP metrics endpoint. Then:
- name: quality-score
interval: 5m
count: 4
successCondition: result[0] >= 0.85 # judge score, 0..1
failureLimit: 1
provider:
prometheus:
address: http://prometheus.istio-system:9090
query: avg_over_time(canary_quality_score[5m])
Pattern C — a web/job provider that runs an eval suite synchronously. Argo Rollouts also supports web and job metric providers. A job provider spins up a Kubernetes Job that runs your offline eval harness against the canary endpoint and exits non-zero on regression; a web provider hits an eval service that returns JSON you assert on. Use these when the eval is heavy (a full benchmark set) and you want it as a hard gate before promoting past, say, 25%.
- name: eval-suite
provider:
job:
spec:
template:
spec:
containers:
- name: eval
image: registry.example.com/eval-harness:latest
args: ["--endpoint", "http://model-canary:8000", "--suite", "regression-v3"]
restartPolicy: Never
backoffLimit: 0
Reference this template alongside infra-metrics in a later canary step so quality is a blocking condition, not an afterthought. This closes the loop: the fast/green/worse summarizer now fails quality-score at 5% and rolls back automatically.
Tie-in to evaluation: these gates are only as good as the eval behind them. Everything from your offline eval chapter — golden datasets, LLM-as-judge calibration, reference-based metrics, statistical significance on small samples — is exactly what feeds Pattern B and C. A canary quality gate is your offline eval, run online, on a traffic sample, wired to a rollback switch.
Flagger: the same idea, declared on one object
Flagger folds steps + metrics + webhooks into a single Canary resource and drives the mesh for you. Equivalent rollout:
apiVersion: flagger.app/v1beta1
kind: Canary
metadata:
name: model
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: model
service:
port: 8000
analysis:
interval: 1m # analyze every minute
threshold: 5 # roll back after 5 failed checks
maxWeight: 50 # cap canary at 50% before promote-to-100
stepWeight: 10 # +10% each successful interval
metrics:
- name: request-success-rate
thresholdRange: { min: 99 }
interval: 1m
- name: request-duration
thresholdRange: { max: 500 } # ms
interval: 1m
- name: quality-score # custom Prometheus MetricTemplate
thresholdRange: { min: 0.85 }
interval: 5m
webhooks:
- name: eval-suite
type: pre-rollout # must pass before any traffic shifts
url: http://eval-harness.default/run
timeout: 5m
metadata:
endpoint: http://model-canary.default:8000
suite: regression-v3
- name: load-test
type: rollout
url: http://flagger-loadtester.default/
metadata:
cmd: "hey -z 1m -q 20 http://model-canary.default:8000/generate"
Flagger’s control loop: every interval it nudges weight up by stepWeight, checks all metrics, and runs rollout-phase webhooks. Built-in request-success-rate and request-duration come from your mesh’s Prometheus; custom quality metrics are MetricTemplate objects (arbitrary PromQL) referenced by name. A pre-rollout webhook is a hard gate that runs before the first traffic shift — the right place for an expensive full eval suite. Cross threshold failed checks → automatic rollback to primary.
Argo vs Flagger, briefly: Argo Rollouts is imperative-steps + first-class AnalysisTemplate/Experiment (great when you want fine-grained control and blue-green and canary in one tool); Flagger is declarative and convention-driven with batteries-included webhooks (great when you want less YAML and a strong load-test/conformance story). Both roll back automatically on metric breach. Pick one; don’t run both on the same workload.
Mechanism 3: Progressive promotion and automatic rollback
The promotion ladder is the heart of canarying. A sane model ladder:
weight dwell gate
------ ----- ----
1% 10 min infra only (smoke: is it even up?)
5% 15 min infra + cheap quality proxies (refusal, empty, logprob)
25% 30 min infra + online judge sample (quality-score)
50% 60 min infra + full eval-suite job (blocking)
100% — promote; keep old fleet for N minutes before scale-down
Design principles:
- Dwell long enough to see the signal. Quality metrics are noisy on small samples. At 1% traffic you may not accumulate enough judged responses in 10 minutes for a stable estimate — either lengthen the dwell, widen the sample, or don’t gate quality until a higher weight. Gating quality at 1% on 12 samples is how you get flaky rollbacks.
- Rollback must be cheaper than roll-forward. With Argo/Flagger, rollback = set canary weight to 0 and keep serving stable. It’s instantaneous at the traffic layer because you never tore down stable. This is why you keep the old fleet warm until promotion fully completes.
- Automatic beats manual. The controller aborts the moment
failureLimit/thresholdis crossed. Humans add an optionalpause: {}(indefinite) step for a manual approval gate before 100% on high-stakes rollouts — but the failure path should never require a human. - Analysis can also run for the whole rollout, not just per-step. Argo’s
spec.strategy.canary.analysis(background analysis) runs continuously and can abort at any weight the instant a metric breaches — useful for a “circuit breaker” on error rate that shouldn’t wait for the next step boundary.
Shadow / mirror deployments
Shadowing (a.k.a. mirroring, dark launch) sends the new version a copy of real requests and throws away its responses. Users are served entirely by stable; the canary sees production-distribution traffic with zero user risk. This is the safest possible way to evaluate a scary model change.
Istio mirroring
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
name: model
spec:
hosts:
- model
http:
- route:
- destination:
host: model
subset: v1 # 100% of user-visible traffic → stable
weight: 100
mirror:
host: model
subset: v2 # a copy also goes to canary
mirrorPercentage:
value: 100.0 # mirror 100% of it (or dial down for GPU cost)
Semantics that matter:
- Mirrored requests are fire-and-forget — Envoy does not wait for and discards the canary’s response. Canary latency and errors cannot hurt users.
- Istio appends
-shadowto theHost/Authorityheader of mirrored requests, so the canary (and any downstream) can tell it’s shadow traffic. mirrorPercentage.valuecontrols what fraction is copied. GPU inference is expensive; mirror 100% only if you can afford a second full inference path, else sample (e.g.10.0).
Argo Rollouts expresses the same idea with a setMirrorRoute step (mirror by percentage/match), letting you shadow before you canary within one Rollout.
What shadow buys you, and what it can’t
Shadow gives you real prompts against the new model with no user exposure — perfect for:
- Comparing v1 vs v2 outputs on identical live inputs (paired diffing → the strongest quality comparison you can get).
- Load/soak testing on real traffic shape (bursty, long-context, adversarial) that synthetic tests miss.
- Warming caches and JIT/compilation before real traffic arrives (see cache warmup below).
What it cannot do:
- Anything with side effects. If the model call writes to a DB, calls a tool, sends an email, or bills a token budget, the shadow will do it too unless downstream services are shadow-aware (check that
-shadowheader and no-op). A mirrored request that triggers a real tool call is a production incident. Make write paths shadow-aware or don’t mirror them. - Measure user-facing outcomes. Shadow responses are discarded, so you get model-quality signals but not click-through, thumbs-up, or downstream conversion. Those need a real canary.
The mature pattern: shadow → canary → promote. Shadow the risky checkpoint to catch gross regressions with zero risk; the survivors graduate to a small canary with real users and outcome metrics; the ones that pass promote.
Failure modes and pitfalls
1. Quality regression sails through infra gates. The headline failure, restated because it is the whole point: latency/error/saturation are all green while factuality, refusal rate, format-adherence, or tone regress. Mitigation: a quality gate (Patterns A–C) is non-negotiable in any model canary. If you only remember one thing, remember this.
2. Sample size / noise → flaky rollbacks (and flaky promotions). Quality metrics on a 1% slice over 10 minutes are statistically thin. Too tight a threshold → you roll back good models on noise; too loose → you promote bad ones. Mitigation: size the dwell/weight so each analysis has enough judged samples for a stable estimate; use count/failureLimit to require repeated breaches, not one bad read; gate quality at higher weights where volume is sufficient.
3. Session / stateful pinning broken by weighted splitting. Weighted routing assigns each request independently. A multi-turn chat then flips between v1 and v2 mid-conversation — incoherent, and it also corrupts your per-version quality attribution. Mitigation: route by a stable key (session id / user id via consistent-hash or header match) so a conversation stays on one version for its lifetime; only split new sessions by weight.
4. KV-cache / prefix-cache warmup and cold-start cliffs. A freshly started model pod has a cold KV cache, cold prefix cache, cold CUDA graphs / compiled kernels, and possibly a cold model load from disk/network. Its first minutes show inflated latency and lower throughput that have nothing to do with the model’s steady-state quality. A canary that measures latency in that window rolls back a perfectly good model. Mitigation: add a warmup/pause before the first analysis; use a readiness probe that only passes post-warmup; pre-load and pre-compile (shadow traffic is great for this); exclude the warmup window from analysis.
5. Cost of running two model copies. Canary and shadow both mean a second inference path on scarce, expensive accelerators. Shadow at 100% mirror = 2× GPU for the whole shadow period; blue-green = 2× fleet during overlap. Mitigation: right-size the canary (small replica count is fine at 5% weight — but watch pitfall #4, too few replicas + cold cache skews latency); sample shadow traffic (mirrorPercentage) instead of mirroring everything; keep overlap windows tight; scale the old fleet down promptly after promotion is confirmed (but not before — you need it for instant rollback).
6. Metric attribution bleed. If canary and stable share a Service/Prometheus label, your “canary success rate” query silently averages both and hides the regression. Mitigation: distinct version labels and separate canaryService/stableService; always scope analysis PromQL to the canary subset.
7. Rollback that isn’t actually fast. Teams assume rollback is instant, then discover the old fleet was already scaled to zero, so “rollback” means cold-starting GPUs for minutes under a live incident. Mitigation: keep stable fully warm until promotion completes; make weight→0 the rollback, never a redeploy.
8. Shadow side effects. Covered above — mirrored traffic hitting real write paths. Mitigation: shadow-aware downstreams keyed on the -shadow header; never mirror non-idempotent paths blindly.
Production checklist — what an interviewer probes
- “Your canary is green on latency and error rate — how do you know the new model is actually good?” Answer must name a quality gate: cheap proxies in Prometheus (refusal/empty/logprob), an online judge/eval-run writing a metric, or a blocking eval-suite job/webhook. If your answer stops at latency+errors, you’ve failed the question.
- Traffic-split mechanism and why. Can you go beyond replica-ratio Service splitting to mesh/Gateway weights? Do you know weight is proportional, not percentage, in Gateway API? Do you handle session pinning for multi-turn?
- Automatic rollback path. What exact condition fires it (
failureLimit/threshold), how fast is it, and why is it fast (old fleet stays warm; rollback = weight→0, not redeploy)? - Shadow vs canary tradeoff. When do you shadow first? What can shadow not tell you (user outcomes), and what’s the side-effect hazard?
- Statistical soundness of the gate. How do you avoid flaky rollbacks from thin quality samples? Dwell time, sample size, repeated-breach thresholds.
- Cold-start / cache warmup handling. How do you keep KV/prefix-cache and kernel-compile cold starts from skewing the first analysis window?
- Cost. How much extra GPU does your canary/shadow burn, and how do you bound it (mirror sampling, tight overlap, prompt post-promotion scale-down)?
- Blue-green vs canary decision. When is an atomic flip (tokenizer/prompt-format change) actually the right call over a mixed canary?
Further reading
- Argo Rollouts — Analysis, AnalysisTemplate & metrics: https://argo-rollouts.readthedocs.io/en/stable/features/analysis/
- Argo Rollouts — Canary strategy & steps: https://argo-rollouts.readthedocs.io/en/stable/features/canary/
- Argo Rollouts — Istio traffic management: https://argo-rollouts.readthedocs.io/en/stable/features/traffic-management/istio/
- Flagger — How it works (Canary spec, promotion/rollback): https://docs.flagger.app/usage/how-it-works
- Flagger — Metrics analysis (MetricTemplate, thresholds): https://docs.flagger.app/usage/metrics
- Flagger — Webhooks (pre-rollout gates, load testing): https://docs.flagger.app/usage/webhooks
- Flagger — Deployment strategies (canary, blue-green, mirroring): https://docs.flagger.app/usage/deployment-strategies
- Istio — Traffic shifting (weighted VirtualService): https://istio.io/latest/docs/tasks/traffic-management/traffic-shifting/
- Istio — Traffic mirroring (shadow): https://istio.io/latest/docs/tasks/traffic-management/mirroring/
- Gateway API — Traffic splitting (HTTPRoute weights): https://gateway-api.sigs.k8s.io/guides/traffic-splitting/
- Kubernetes — Deployment rolling updates: https://kubernetes.io/docs/concepts/workloads/controllers/deployment/
Topic 8: Monitoring & Observability with Grafana
What You’ll Learn
This topic teaches you how to:
- Set up Prometheus for metrics collection
- Create Grafana dashboards for visualization
- Monitor LLM serving performance
- Track GPU utilization
- Set up alerts for anomalies
Why We Need This
Business Need
- SLA compliance: Meet 99.9% uptime SLAs
- Cost optimization: Identify expensive operations
- User experience: Detect issues before users complain
- Compliance: Audit trails for regulated industries
Technical Need
- Debugging: Understand why system is slow/failing
- Capacity planning: Know when to scale
- Performance optimization: Identify bottlenecks
- Incident response: Quick detection and resolution
Real-World Impact
Without monitoring:
- ❌ Issues discovered by users (too late!)
- ❌ Can’t debug production problems
- ❌ Don’t know when to scale
- ❌ No visibility into system health
Industry Use Cases
1. Production ML Platforms
Company: OpenAI, Anthropic, HuggingFace Use Case:
- Monitor API performance 24/7
- Alert on latency spikes, errors
- Track cost per request
Example:
# Alert if P95 latency > 1s
histogram_quantile(0.95, rate(llm_request_duration_seconds_bucket[5m])) > 1
2. Enterprise ML Infrastructure
Company: Banks, healthcare, finance Use Case:
- Compliance monitoring
- Audit trails
- Performance SLAs
Example:
# Track all requests for audit
count(llm_requests_total)
3. Cost Management
Company: All companies with ML infrastructure Use Case:
- Track GPU costs
- Identify expensive operations
- Optimize resource usage
Example:
# Cost per request
gpu_cost_per_hour / rate(llm_requests_total[1h])
4. Incident Response
Company: All production systems Use Case:
- Detect issues immediately
- Alert on-call engineers
- Quick root cause analysis
Example:
# Alert on high error rate
alert: HighErrorRate
expr: rate(llm_requests_total{status="500"}[5m]) > 0.05
5. Performance Optimization
Company: All companies Use Case:
- Identify slow endpoints
- Find bottlenecks
- Optimize based on data
Example:
# Find slowest endpoints
topk(10, histogram_quantile(0.95, rate(llm_request_duration_seconds_bucket[5m])))
Industry-Standard Boilerplate Code
Complete Monitoring Setup (Industry Standard)
"""
Production monitoring setup
Used by: All production ML systems
"""
from prometheus_client import Counter, Histogram, Gauge, generate_latest
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
import time
# Metrics (industry standard names)
REQUEST_COUNT = Counter(
'llm_requests_total',
'Total requests',
['method', 'endpoint', 'status', 'model_version']
)
REQUEST_DURATION = Histogram(
'llm_request_duration_seconds',
'Request duration',
['method', 'endpoint'],
buckets=[0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0]
)
ACTIVE_REQUESTS = Gauge(
'llm_active_requests',
'Active requests',
['endpoint']
)
TOKENS_GENERATED = Counter(
'llm_tokens_generated_total',
'Total tokens generated',
['model_name']
)
GPU_UTILIZATION = Gauge(
'llm_gpu_utilization_percent',
'GPU utilization',
['gpu_id']
)
app = FastAPI()
@app.middleware("http")
async def metrics_middleware(request: Request, call_next):
"""Collect metrics for every request"""
endpoint = request.url.path
method = request.method
# Skip metrics endpoint
if endpoint == "/metrics":
return await call_next(request)
# Track active requests
ACTIVE_REQUESTS.labels(endpoint=endpoint).inc()
# Measure duration
start = time.time()
status_code = 200
try:
response = await call_next(request)
status_code = response.status_code
return response
except Exception:
status_code = 500
raise
finally:
duration = time.time() - start
# Record metrics
REQUEST_DURATION.labels(
method=method,
endpoint=endpoint
).observe(duration)
REQUEST_COUNT.labels(
method=method,
endpoint=endpoint,
status=str(status_code),
model_version="v1.0.0"
).inc()
ACTIVE_REQUESTS.labels(endpoint=endpoint).dec()
@app.get("/metrics")
async def metrics():
"""Prometheus metrics endpoint"""
return Response(
content=generate_latest(),
media_type="text/plain"
)
Prometheus Alerts (Industry Standard)
# alerts.yml
# Used by: Production monitoring systems
groups:
- name: llm_serving_alerts
interval: 30s
rules:
# High latency alert
- alert: HighLatency
expr: histogram_quantile(0.95, rate(llm_request_duration_seconds_bucket[5m])) > 1
for: 5m
labels:
severity: warning
annotations:
summary: "P95 latency > 1s"
description: "Latency is {{ $value }}s"
# High error rate
- alert: HighErrorRate
expr: rate(llm_requests_total{status="500"}[5m]) / rate(llm_requests_total[5m]) > 0.05
for: 5m
labels:
severity: critical
annotations:
summary: "Error rate > 5%"
# GPU high utilization
- alert: GPUHighUtilization
expr: llm_gpu_utilization_percent > 95
for: 10m
labels:
severity: warning
annotations:
summary: "GPU utilization very high"
# Service down
- alert: ServiceDown
expr: up{job="llm-serving"} == 0
for: 1m
labels:
severity: critical
annotations:
summary: "LLM serving is down"
Grafana Dashboard JSON (Industry Standard)
{
"dashboard": {
"title": "LLM Serving Dashboard",
"panels": [
{
"title": "Request Rate",
"targets": [{
"expr": "rate(llm_requests_total[5m])",
"legendFormat": "{{endpoint}}"
}]
},
{
"title": "Latency (P95)",
"targets": [{
"expr": "histogram_quantile(0.95, rate(llm_request_duration_seconds_bucket[5m]))",
"legendFormat": "P95"
}]
},
{
"title": "Error Rate",
"targets": [{
"expr": "rate(llm_requests_total{status=\"500\"}[5m]) / rate(llm_requests_total[5m])",
"legendFormat": "Error Rate"
}]
},
{
"title": "GPU Utilization",
"targets": [{
"expr": "llm_gpu_utilization_percent",
"legendFormat": "GPU {{gpu_id}}"
}]
}
]
}
}
Key Concepts
Monitoring Stack
- Prometheus: Metrics collection and storage
- Grafana: Visualization and dashboards
- Exporters: Collect metrics from applications
- Alertmanager: Handle alerts
Key Metrics to Monitor
Application Metrics
- Request rate: Requests per second
- Latency: P50, P95, P99 response times
- Error rate: Failed requests percentage
- Queue size: Pending requests
System Metrics
- GPU utilization: How much GPU is being used
- GPU memory: Memory usage
- CPU usage: CPU utilization
- Memory usage: RAM usage
Model Metrics
- Tokens generated: Tokens per second
- Batch size: Current batch size
- Model version: Which model is running
Architecture
[LLM Server] → [Prometheus Exporter] → [Prometheus] → [Grafana]
↓
[GPU Metrics] → [Node Exporter] → [Prometheus]
Setup
1. Install Prometheus
# Download Prometheus
wget https://github.com/prometheus/prometheus/releases/download/v2.45.0/prometheus-2.45.0.linux-amd64.tar.gz
tar xvfz prometheus-*.tar.gz
cd prometheus-*
# Or use Docker
docker run -d -p 9090:9090 prom/prometheus
2. Install Grafana
# Docker
docker run -d -p 3000:3000 grafana/grafana
# Or install locally
# See: https://grafana.com/docs/grafana/latest/setup-grafana/installation/
3. Set Up Metrics Export
Add Prometheus metrics to your serving application (see prometheus_exporter.py).
Running the Stack
Option 1: Docker Compose
cd 08_monitoring
docker-compose up -d
This starts:
- Prometheus on http://localhost:9090
- Grafana on http://localhost:3000
- Node Exporter (system metrics)
Option 2: Manual Setup
- Start Prometheus:
./prometheus --config.file=prometheus.yml - Start Grafana:
./grafana-server - Configure data source in Grafana
Grafana Dashboards
Pre-built Dashboards
- LLM Serving Overview: Request rate, latency, errors
- GPU Monitoring: GPU utilization, memory, temperature
- System Metrics: CPU, memory, disk, network
- Model Performance: Throughput, tokens/second
Creating Custom Dashboards
- Open Grafana (http://localhost:3000)
- Login (default: admin/admin)
- Add Prometheus data source
- Create new dashboard
- Add panels for metrics you care about
Key Metrics Queries
Request Rate
rate(http_requests_total[5m])
Latency (P95)
histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))
Error Rate
rate(http_requests_total{status="error"}[5m]) / rate(http_requests_total[5m])
GPU Utilization
nvidia_gpu_utilization_gpu
GPU Memory
nvidia_gpu_memory_used_bytes / nvidia_gpu_memory_total_bytes
Alerts
Example Alert Rules
groups:
- name: llm_serving
rules:
- alert: HighLatency
expr: histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m])) > 1
for: 5m
annotations:
summary: "High latency detected"
- alert: HighErrorRate
expr: rate(http_requests_total{status="error"}[5m]) / rate(http_requests_total[5m]) > 0.05
for: 5m
annotations:
summary: "High error rate"
- alert: GPUHighUtilization
expr: nvidia_gpu_utilization_gpu > 95
for: 10m
annotations:
summary: "GPU utilization very high"
Exercises
- Set up monitoring: Deploy Prometheus and Grafana
- Create dashboard: Build a dashboard for your metrics
- Set up alerts: Configure alerts for high latency
- Monitor GPU: Add GPU metrics to dashboard
- Compare performance: Monitor before/after optimization
Next Steps
- Topic 6: Use metrics for autoscaling
- Topic 10: Set up drift detection alerts
- Topic 9: Monitor model version performance
Further Reading
Monitoring LLM Serving — Observability for GPU Inference in Production
Why this matters. A classic web service is healthy when latency and error rate look good. An LLM server can pass both of those checks and still be quietly on fire: the KV cache is 98% full, requests are piling up in a queue you never graphed, and your p50 looks fine only because the p99 users already gave up and disconnected. Serving LLMs introduces metrics that ordinary dashboards don’t have — token-level latency, cache pressure, batch dynamics — and if you don’t measure them you cannot run the system. This chapter is about the metrics that actually matter for GPU inference, where they come from, and how to wire Prometheus, Grafana, DCGM, and tracing together into an observability stack you can put on-call against.
Core intuition: LLM serving has latency your web stack never had
A REST endpoint has essentially one latency: request in, response out. An LLM endpoint has three latencies, and users feel all of them differently.
-
Time To First Token (TTFT) — how long until the first token appears. This is the prefill cost: the model processes the whole prompt before it can emit anything. Long prompts, cold caches, and queue waiting all inflate TTFT. For a chat UI this is the “is it thinking?” delay and it dominates perceived responsiveness.
-
Time Per Output Token (TPOT), a.k.a. inter-token latency (ITL) — the gap between subsequent tokens during decode. This sets the “typing speed” of the stream. A user reads at maybe 5–10 tokens/sec; if TPOT is 100 ms (10 tok/s) the stream feels fluid, at 300 ms it feels painful.
-
End-to-end latency — total wall-clock for the whole response. This is roughly ( \text{TTFT} + (N_{\text{output}} - 1) \times \text{TPOT} ), so a long answer amplifies a small per-token regression into a large total.
The second thing that’s different: the bottleneck is a fixed pool of GPU memory, not CPU or connection count. Modern engines (vLLM, TGI) batch many sequences together and store each sequence’s attention state in a KV cache carved out of GPU HBM. When the cache fills, the scheduler stops admitting new sequences — they wait in a queue — or it preempts running ones and recomputes them later. So the health signals that predict a latency cliff are queue depth and KV-cache utilization, not GPU-percent-busy. A GPU can read 100% utilized while the real problem is that it’s thrashing the cache.
Keep two mental models side by side:
- RED (Rate, Errors, Duration) — the request-centric view. Good for the API surface users touch.
- USE (Utilization, Saturation, Errors) — the resource-centric view. Good for the GPU and the KV cache. Saturation — the queue and the cache — is where LLM serving lives or dies, and it’s the box most teams forget.
Metrics catalog — what to measure and why
| Metric | What it means | Why it matters | Healthy range / notes |
|---|---|---|---|
| TTFT p50/p95/p99 | Time until first token | Perceived responsiveness; captures prefill + queue wait | Interactive chat: p95 < 1–2 s. Rising p95 with flat p50 = queue building |
| TPOT / inter-token latency | Steady-state gap between output tokens | Stream “typing speed”; regressions multiply over long outputs | 10–50 ms/token typical; > 100 ms feels slow |
| E2E latency p50/p95/p99 | Full request wall-clock | The SLO users actually sign; skewed by output length | Always report percentiles, never the mean |
| Throughput — requests/s | Completed requests per second | Capacity planning, autoscaling signal | Compare against offered load; gap = queue growth |
| Throughput — tokens/s | Generated tokens per second (decode) | The real work rate of the GPU; the currency of cost | Track output tok/s separately from prompt tok/s |
| Queue depth / waiting seqs | Requests admitted-but-waiting | Leading indicator of a latency cliff | Should hover near 0; sustained > 0 = under-provisioned |
| Running sequences | Sequences decoding right now | Effective batch size; drives GPU efficiency | Low + full queue = memory-bound, not compute-bound |
| GPU utilization | % time GPU had work scheduled | Coarse “is the GPU busy” signal | High util ≠ efficient; can be high while thrashing |
| GPU memory used / free | HBM in use (framebuffer) | OOM risk; headroom for larger batches/KV | Leave headroom; OOM crashes the whole replica |
| KV-cache utilization | Fraction of paged KV blocks in use | The saturation signal for LLM serving | > 90% sustained → preemption, TTFT spikes |
| Batch size | Sequences processed per step | Throughput vs latency tradeoff knob | Larger = more throughput, higher TPOT |
| Preemptions | Sequences evicted & recomputed | Direct evidence of cache pressure | Any sustained rate is a red flag |
| Error rate | Failed / total requests | Availability SLI; 5xx, OOM, timeouts, truncations | Alert on rate, not raw count |
| Cost per 1k tokens | $ per 1000 tokens served | Turns efficiency into money; the exec-facing number | Derived: GPU $/hr ÷ (tokens/s × 3.6) |
The rule of thumb: latency metrics are the SLIs; queue, KV-cache, and preemptions are the leading indicators; GPU/memory are the resource ceiling; cost is the business translation.
Mechanism 1 — Scraping engine metrics
Both major open-source engines expose Prometheus metrics natively. You don’t instrument the model; you scrape the server.
vLLM
vLLM publishes a /metrics endpoint on its OpenAI-compatible API server (same
port as the API, default 8000). Every metric is prefixed vllm:. The ones
that matter, by type
(vLLM production metrics,
metrics design):
Histograms (latency — these give you percentiles):
vllm:time_to_first_token_seconds— TTFTvllm:time_per_output_token_seconds— TPOT / inter-token latencyvllm:e2e_request_latency_seconds— full request latencyvllm:request_prompt_tokens— prompt length distributionvllm:request_generation_tokens— output length distribution
Gauges (instantaneous system state):
vllm:num_requests_running— sequences currently decodingvllm:num_requests_waiting— sequences queued (the saturation signal)vllm:num_requests_swapped— swapped to CPU under pressurevllm:gpu_cache_usage_perc— KV-cache utilization (a fraction 0–1, so multiply by 100 to get a percent — the name is misleading)vllm:gpu_prefix_cache_hit_rate— prefix-cache reuse rate
Counters (cumulative — take rate() of these):
vllm:prompt_tokens_total— prompt tokens processedvllm:generation_tokens_total— output tokens generated (throughput source)vllm:request_success_total— successful requests (has afinished_reasonlabel so you can separatestopvslengthvsabort)vllm:num_preemptions_total— cache-pressure evictions
Histograms are exposed as three series each: _bucket (cumulative, labelled by
le), _sum, and _count. You compute percentiles from _bucket and averages
from _sum / _count.
TGI (Text Generation Inference)
Hugging Face TGI exposes /metrics with a tgi_ prefix
(TGI metrics reference):
tgi_request_duration— end-to-end latency (histogram)tgi_request_inference_duration— inference time excluding queue (histogram)tgi_request_queue_duration— time spent waiting in queue (histogram)tgi_request_mean_time_per_token_duration— inter-token latency (histogram)tgi_batch_current_size— current batch size (gauge)tgi_batch_current_max_tokens— token budget of current batch (gauge)tgi_queue_size— requests waiting (gauge)tgi_request_count/tgi_request_success— request counters
Note the naming gap: TGI does not ship a single metric literally named “TTFT.”
You approximate it as tgi_request_queue_duration + the prefill portion, or you
capture first-token timing at the client / gateway. This is a common source of
dashboard confusion — always confirm which engine you’re scraping and map its
names onto your canonical SLIs.
Mechanism 2 — GPU metrics with DCGM
Engine metrics tell you about requests. They don’t tell you the GPU is at 90 °C, throttling its clocks, or that another process is stealing HBM. For that you run NVIDIA’s DCGM exporter, which reads the Data Center GPU Manager and exposes Prometheus metrics on port 9400 (NVIDIA/dcgm-exporter, DCGM exporter docs).
Key fields (all prefixed DCGM_FI_):
| Metric | Meaning |
|---|---|
DCGM_FI_DEV_GPU_UTIL | GPU utilization (% of time a kernel was resident) |
DCGM_FI_DEV_FB_USED | Framebuffer (HBM) memory used, MiB |
DCGM_FI_DEV_FB_FREE | Framebuffer memory free, MiB |
DCGM_FI_DEV_POWER_USAGE | Board power draw, watts |
DCGM_FI_DEV_GPU_TEMP | GPU die temperature, °C |
DCGM_FI_DEV_SM_CLOCK | SM clock, MHz (watch for throttling) |
DCGM_FI_PROF_GR_ENGINE_ACTIVE | Graphics/compute engine active ratio |
DCGM_FI_PROF_PIPE_TENSOR_ACTIVE | Tensor-core pipe active ratio (real compute intensity) |
DCGM_FI_PROF_DRAM_ACTIVE | Memory-bandwidth active ratio |
Two subtleties worth internalizing:
DCGM_FI_DEV_GPU_UTILis a liar for LLM decode. It reports “a kernel was scheduled,” which is nearly always true during autoregressive decode even when the GPU is memory-bandwidth-bound and compute-idle. UseDCGM_FI_PROF_PIPE_TENSOR_ACTIVEandDCGM_FI_PROF_DRAM_ACTIVEto see whether you’re compute-bound or bandwidth-bound.- Every DCGM series carries a
gpu(index) and usuallyUUID/modelNamelabel, so on a multi-GPU node you aggregate or break down per device.
Mechanism 3 — Prometheus + Grafana wiring
Prometheus pulls metrics on an interval from targets you list; Grafana queries Prometheus with PromQL to draw panels. For LLM serving you point Prometheus at three kinds of targets: the inference engines, the DCGM exporters, and (optionally) your gateway/load balancer.
A worked, correct scrape config (prometheus.yml):
global:
scrape_interval: 15s # pull every 15s
evaluation_interval: 15s # evaluate alert rules every 15s
rule_files:
- "alerts/llm_serving.yml" # alert rules loaded below
scrape_configs:
# vLLM / TGI inference servers (engine metrics)
- job_name: "vllm"
metrics_path: /metrics
static_configs:
- targets:
- "vllm-0.inference.svc:8000"
- "vllm-1.inference.svc:8000"
labels:
engine: vllm
model: "llama-3-8b-instruct"
# DCGM exporter (one per GPU node), port 9400
- job_name: "dcgm"
static_configs:
- targets:
- "gpu-node-0:9400"
- "gpu-node-1:9400"
# In Kubernetes you'd usually replace static_configs with
# kubernetes_sd_configs + relabeling, or annotate pods with
# prometheus.io/scrape and let the k8s SD discover them.
Shell tip: to sanity-check a target before wiring it up, just
curl -s http://vllm-0:8000/metrics | grep vllm:— the$you see in a prompt is literal.
PromQL: the queries that earn their keep
These are copy-pasteable against the metric names above. Histogram percentiles
use histogram_quantile over the _bucket series, summed by the le label.
TTFT p95 over the last 5 minutes:
histogram_quantile(
0.95,
sum by (le) (rate(vllm:time_to_first_token_seconds_bucket[5m]))
)
Inter-token latency (TPOT) p99:
histogram_quantile(
0.99,
sum by (le) (rate(vllm:time_per_output_token_seconds_bucket[5m]))
)
Output-token throughput (tokens/s), the real work rate:
sum(rate(vllm:generation_tokens_total[1m]))
Request throughput (req/s), broken down by outcome:
sum by (finished_reason) (rate(vllm:request_success_total[1m]))
KV-cache utilization as a percent (remember it’s a 0–1 fraction):
avg(vllm:gpu_cache_usage_perc) * 100
Queue depth (waiting sequences) — your saturation early warning:
sum(vllm:num_requests_waiting)
GPU utilization vs. real tensor activity, per device:
avg by (gpu) (DCGM_FI_DEV_GPU_UTIL)
avg by (gpu) (DCGM_FI_PROF_PIPE_TENSOR_ACTIVE) * 100
GPU memory used percent:
100 * DCGM_FI_DEV_FB_USED
/ (DCGM_FI_DEV_FB_USED + DCGM_FI_DEV_FB_FREE)
Cost per 1k tokens — combine a static price with live throughput. With a recording rule holding the GPU hourly price, cost per 1k output tokens is:
[ \text{cost}{1k} = \frac{\text{price}{$/\text{hr}}}{\text{tokens/s} \times 3.6} ]
# price_per_gpu_hour is a constant series you set (e.g. via a recording rule)
(sum(price_per_gpu_hour))
/ (sum(rate(vllm:generation_tokens_total[5m])) * 3.6)
(The 3.6 converts tokens/second into thousands-of-tokens/hour:
( \text{tok/s} \times 3600,\text{s/hr} \div 1000 = \text{tok/s} \times 3.6 ).)
Grafana
Build one dashboard per concern and template it with a $model /
$engine / $gpu variable so a single dashboard serves every replica:
- Latency row — TTFT p50/p95/p99, TPOT p95, E2E p95/p99 (time-series).
- Throughput row — req/s and tokens/s, with offered-vs-served overlaid.
- Saturation row — waiting sequences, running sequences, KV-cache %, preemption rate. This row is what tells you why latency moved.
- Resource row — GPU util, tensor-active, HBM used %, power, temp, SM clock from DCGM.
- Cost row — $/1k tokens and $/hr per replica.
Always plot percentiles as separate series; never a single “avg latency” line.
Mechanism 4 — SLIs, SLOs, and alerting
An SLI is a measured signal; an SLO is the target you promise; an alert fires when you’re at risk of missing it. For interactive LLM serving a reasonable starting SLO set:
| SLI | Example SLO |
|---|---|
| TTFT p95 | < 1.5 s over rolling 5 min |
| TPOT p95 | < 80 ms/token |
| E2E availability (non-error rate) | ≥ 99.9% over 30 days |
| Error rate | < 0.1% of requests |
Alert on symptoms users feel (SLO burn) and on leading indicators
(saturation), not on raw resource gauges. A full example rule file
(alerts/llm_serving.yml):
groups:
- name: llm_serving
rules:
# ---- Symptom: TTFT SLO breach ----
- alert: TTFTHighP95
expr: |
histogram_quantile(
0.95,
sum by (le) (rate(vllm:time_to_first_token_seconds_bucket[5m]))
) > 1.5
for: 10m
labels:
severity: page
annotations:
summary: "TTFT p95 above 1.5s SLO"
description: "p95 first-token latency is {{ $value | humanizeDuration }} on {{ $labels.model }}."
# ---- Leading indicator: queue building ----
- alert: RequestQueueBuilding
expr: sum(vllm:num_requests_waiting) > 20
for: 5m
labels:
severity: warning
annotations:
summary: "Requests queueing at the engine"
description: "{{ $value }} sequences waiting — scale out or shed load before TTFT breaches."
# ---- Leading indicator: KV cache saturation ----
- alert: KVCacheSaturated
expr: avg(vllm:gpu_cache_usage_perc) * 100 > 90
for: 5m
labels:
severity: warning
annotations:
summary: "KV cache > 90%"
description: "Cache pressure imminent; expect preemptions and TTFT spikes."
# ---- Resource: GPU memory near OOM ----
- alert: GPUMemoryHigh
expr: |
100 * DCGM_FI_DEV_FB_USED
/ (DCGM_FI_DEV_FB_USED + DCGM_FI_DEV_FB_FREE) > 95
for: 5m
labels:
severity: warning
annotations:
summary: "GPU {{ $labels.gpu }} HBM > 95%"
# ---- Symptom: error budget burn ----
- alert: HighErrorRate
expr: |
sum(rate(vllm:request_success_total{finished_reason="abort"}[5m]))
/ sum(rate(vllm:request_success_total[5m])) > 0.01
for: 5m
labels:
severity: page
annotations:
summary: "Request abort rate > 1%"
The for: clause suppresses flapping — the condition must hold continuously
before it pages. Pair symptom pages (wake someone up) with leading-indicator
warnings (fix it before it pages). Advanced teams add multi-window
multi-burn-rate error-budget alerts so a fast burn pages immediately and a
slow burn opens a ticket.
RED and USE, applied to inference
RED — instrument the request surface:
- Rate —
rate(vllm:request_success_total[1m])(req/s). - Errors — the abort / failure ratio shown above.
- Duration — TTFT, TPOT, and E2E histograms. LLM serving splits “Duration” into three because users feel three.
USE — instrument the constrained resource (the GPU and its cache):
- Utilization —
DCGM_FI_DEV_GPU_UTIL, and more honestlyDCGM_FI_PROF_PIPE_TENSOR_ACTIVE/DCGM_FI_PROF_DRAM_ACTIVE; HBM used %. - Saturation —
vllm:num_requests_waiting(queue) andvllm:gpu_cache_usage_perc(KV cache). This is the LLM-specific box. Classic USE saturation is CPU run-queue; here it’s sequences waiting for cache blocks. - Errors — OOM kills, CUDA errors, preemption-driven recompute
(
vllm:num_preemptions_total).
Run both: RED catches the user-facing symptom, USE tells you which resource caused it. The link between them is almost always the saturation row — queue and cache — which is exactly the pair that generic dashboards omit.
Distributed tracing with OpenTelemetry
Metrics tell you that p99 TTFT is bad; a trace tells you where the time
went for one slow request as it crossed gateway → queue → prefill → decode.
vLLM ships OpenTelemetry support: start it with
--otlp-traces-endpoint <collector:4317> and it emits spans with attributes for
queue time, TTFT, and per-request token counts, exported over OTLP to a
collector (Jaeger, Tempo, etc.)
(vLLM OpenTelemetry example).
Propagate a traceparent header from your gateway through to the engine so the
model’s spans nest under the user request. The payoff: for a single tail-latency
request you can see whether the 4 seconds was 3.8 s of queue wait (scale out),
prefill on an 8k-token prompt (input-length problem), or slow decode (batch /
memory-bandwidth problem). Metrics aggregate; traces let you debug one victim.
In practice you sample traces (e.g. 1–5%, plus always-sample on error) to
keep cost and cardinality sane.
Failure modes and pitfalls
-
Alerting on averages. A mean latency of 400 ms can hide a p99 of 12 s. Averages hide the tail, and the tail is who churns. Always alert on percentiles from
_bucketseries, and compute them withhistogram_quantileover arate(), never over a raw counter. -
No queue or KV-cache metrics. The single most common LLM-monitoring gap. Without
num_requests_waitingandgpu_cache_usage_percyou get no warning before the latency cliff — GPU util reads high and everything “looks fine” right up until TTFT triples. These are your leading indicators; graph and alert on them. -
Trusting
DCGM_FI_DEV_GPU_UTIL. It says “a kernel ran,” not “the GPU did useful work.” During decode it sits near 100% while the device is memory-bandwidth-bound and compute-starved. Cross-check withPIPE_TENSOR_ACTIVE/DRAM_ACTIVEbefore concluding you’re compute-bound. -
Cardinality explosions. Labelling metrics with unbounded values —
request_id, raw prompt text, user IDs, full model paths — multiplies time series until Prometheus OOMs. Keep labels low-cardinality (model,engine,gpu,finished_reason). Push per-request detail into traces/logs, not metric labels. -
Misreading
gpu_cache_usage_perc. It’s a 0–1 fraction despite the_percsuffix. Forgetting the* 100silently makes a “90% full” alert fire at 9000% or never fire at all. -
Percentiles over the wrong window.
histogram_quantileon a[5m]rate is a 5-minute view; too short and it’s noisy, too long and it lags an incident. Match the window to the SLO evaluation period. -
Averaging pre-computed percentiles across replicas. You cannot average p95s. Sum the
_bucketseries across replicas first, then take the quantile. Aggregating already-quantiled numbers gives a wrong answer. -
No cost visibility. If nobody graphs $/1k tokens, efficiency regressions (a bad batch-size change, an underutilized replica) go unnoticed until the cloud bill arrives. Cost is the metric leadership reads; derive it from tokens/s and GPU price.
-
Blind spot between gateway and engine. If you only scrape the engine you miss load-balancer queuing and network time. Measure TTFT at the edge too, and reconcile the two.
Production checklist — what an interviewer probes
- “Which latency metrics for an LLM, and why not just one?” — Name TTFT, TPOT/ITL, and E2E; explain prefill vs decode and that E2E ≈ TTFT + (N−1)·TPOT.
- “How do you know before users do?” — Point to queue depth
(
num_requests_waiting) and KV-cache utilization as leading indicators, not GPU util. - “Show me the PromQL for TTFT p95.” —
histogram_quantile(0.95, sum by (le) (rate(vllm:time_to_first_token_seconds_bucket[5m]))), and know why you sum_bucketbylefirst. - “GPU util is 100% — are you compute-bound?” — Not necessarily; decode is
often memory-bandwidth-bound. Cross-check
PIPE_TENSOR_ACTIVE/DRAM_ACTIVE. - “What do you alert on?” — Symptoms (SLO burn on TTFT/error rate) plus
leading indicators (queue, KV cache), with
for:to debounce; ideally multi-burn-rate error budgets. - “How do you avoid a Prometheus cardinality blowup?” — Low-cardinality labels only; per-request detail goes to traces/logs.
- “How do you debug one slow request?” — OpenTelemetry tracing with
propagated
traceparent, sampled, to split queue vs prefill vs decode time. - “What’s your cost metric?” — $/1k tokens derived from GPU $/hr and
rate(generation_tokens_total), tracked per model/replica.
Further reading
- vLLM — Production Metrics: https://docs.vllm.ai/en/v0.6.1/serving/metrics.html
- vLLM — Metrics design (V1): https://docs.vllm.ai/en/latest/design/metrics/
- vLLM — OpenTelemetry example: https://docs.vllm.ai/en/v0.9.0/examples/online_serving/opentelemetry.html
- TGI — Metrics reference: https://huggingface.co/docs/text-generation-inference/main/en/reference/metrics
- NVIDIA DCGM exporter (GitHub): https://github.com/NVIDIA/dcgm-exporter
- NVIDIA DCGM exporter docs: https://docs.nvidia.com/datacenter/cloud-native/gpu-telemetry/latest/dcgm-exporter.html
- Prometheus — Querying /
histogram_quantile: https://prometheus.io/docs/prometheus/latest/querying/functions/#histogram_quantile - Prometheus — Alerting rules: https://prometheus.io/docs/prometheus/latest/configuration/alerting_rules/
- Grafana — The RED Method (Tom Wilkie): https://grafana.com/blog/the-red-method-how-to-instrument-your-services/
- Brendan Gregg — The USE Method: https://www.brendangregg.com/usemethod.html
- Google SRE Workbook — Alerting on SLOs: https://sre.google/workbook/alerting-on-slos/
- OpenTelemetry — Documentation: https://opentelemetry.io/docs/
Topic 9: Model Versioning
What You’ll Learn
This topic teaches you how to:
- Manage multiple model versions
- Implement model registry
- Version models semantically
- Track model metadata
- Enable quick rollbacks
- Support A/B testing
Why Model Versioning?
Benefits
- Reproducibility: Know exactly which model is running
- Rollback: Quickly revert to previous version
- A/B testing: Compare model versions
- Audit trail: Track model changes
- Compliance: Meet regulatory requirements
Challenges
- Storage: Multiple versions take space
- Complexity: Managing versions adds overhead
- Testing: Need to test each version
- Deployment: Coordinate version updates
Versioning Strategies
1. Semantic Versioning
v1.0.0 # Major.Minor.Patch
v1.1.0 # Minor update
v2.0.0 # Major update
2. Git-based
Use Git tags/commits for versions.
3. Timestamp-based
model-2024-01-15-10-30-00
4. Hash-based
Use model hash as version identifier.
Model Registry
What to Store
- Model files: Weights, tokenizer, config
- Metadata: Training date, metrics, dataset
- Code: Training script, preprocessing
- Environment: Dependencies, requirements
Storage Options
- S3/GCS: Object storage
- HuggingFace Hub: Model hosting
- MLflow: Model registry
- Local filesystem: For development
Implementation
Simple File-based Registry
models/
v1.0.0/
model.bin
tokenizer.json
config.json
metadata.json
v1.1.0/
model.bin
tokenizer.json
config.json
metadata.json
Metadata Schema
{
"version": "v1.0.0",
"created_at": "2024-01-15T10:30:00Z",
"model_name": "gpt2",
"training_date": "2024-01-10",
"metrics": {
"accuracy": 0.95,
"latency_ms": 150
},
"dataset": "dataset-v1",
"git_commit": "abc123"
}
Version Management API
List Versions
GET /api/v1/models/versions
Get Version Info
GET /api/v1/models/versions/v1.0.0
Deploy Version
POST /api/v1/models/versions/v1.0.0/deploy
Rollback
POST /api/v1/models/rollback
{
"target_version": "v1.0.0"
}
Integration with Serving
Environment Variable
env:
- name: MODEL_VERSION
value: "v1.0.0"
ConfigMap
apiVersion: v1
kind: ConfigMap
metadata:
name: model-config
data:
model_version: "v1.0.0"
model_path: "/models/v1.0.0"
Dynamic Loading
Load model version at runtime based on config.
Rollback Procedure
Quick Rollback
- Identify current version
- Identify target version
- Update deployment
- Verify health
- Monitor metrics
Automated Rollback
Set up alerts that trigger rollback:
- Error rate spike
- Latency increase
- Quality degradation
A/B Testing Support
Deploy Multiple Versions
# Version A
deployment-a:
model_version: "v1.0.0"
# Version B
deployment-b:
model_version: "v1.1.0"
Compare Metrics
Track metrics per version:
- Performance (latency, throughput)
- Quality (accuracy, user feedback)
- Cost (GPU hours)
Best Practices
- Version everything: Models, code, configs
- Tag releases: Use semantic versioning
- Store metadata: Track training info
- Test before deploy: Validate new versions
- Document changes: Changelog for each version
- Automate: Use CI/CD for versioning
Exercises
- Create registry: Set up model version storage
- Version model: Tag and store model version
- Deploy version: Deploy specific version
- Rollback: Practice rolling back
- A/B test: Compare two versions
Next Steps
- Topic 7: Use versions in canary deployments
- Topic 10: Detect drift per version
- Topic 8: Monitor version performance
Further Reading
Model Versioning & Registry — A Deep Dive
Tracking, promoting, and rolling back model versions in production serving.
Why This Matters
A colleague pings you: “Prod is giving different answers than last week, but nobody
deployed a new model.” You check the model name in the config. It reads
chatbot-llm. Same as always. You are certain nothing changed.
Something changed. Maybe a teammate re-ran the fine-tune and pushed to the same
Hugging Face repo. Maybe the base image bumped transformers from 4.44 to 4.46 and
the tokenizer now splits emoji differently. Maybe latest on your object store now
points at a different set of weights. Maybe the vLLM version changed and the sampling
RNG behaves differently. Each of these silently mutates behavior while the name you
pinned stays identical.
An un-pinned “same” model is not the same model. Reproducibility in LLM serving is not a nice-to-have — it is the difference between “we can explain and roll back this regression in five minutes” and “we have no idea what we are running.” This chapter is about making a served model version an exact, immutable, auditable thing: what it comprises, how a registry tracks and promotes it, how the server resolves “current prod,” and how you roll back when it goes wrong.
Core Intuition
Think of a model version the way you think of a container image digest, not a tag.
- A tag (
myapp:latest,chatbot-llm) is a mutable pointer. It can be repointed at any time. It tells you a name, not an identity. - A digest (
sha256:9f86d0...) is content-addressed. It names the bytes. If the bytes change, the digest changes. Two people who pull the same digest get the same thing, forever.
Good model versioning gives you both layers, and keeps them separate:
- Immutable identity — a content hash or an append-only version number that never moves. This is what you record in logs, evals, and audit trails.
- Mutable pointers (aliases/stages) — human-friendly names like
@champion,@production,stagingthat point at an immutable version and can be repointed during a promotion or rollback.
The server should resolve a mutable pointer once, at load time, and then pin the
resolved immutable identity for the life of the process — logging it on every request.
Rollback then becomes “repoint the alias and reload,” and provenance becomes “which
exact version answered request X.”
What Must Be Versioned Together
The single most common LLM-serving mistake is versioning only the weights. A served model is a bundle. Change any component and outputs can move. Pin all of it or you have not pinned anything.
| Component | Why it changes outputs | Failure if unpinned |
|---|---|---|
| Weights (safetensors/GGUF/etc.) | The model itself | Different answers; the obvious one |
Model config (config.json, arch, rope/context, dtype) | Defines how weights are interpreted | Wrong context length, silent truncation |
| Tokenizer (vocab, merges, special tokens, chat template) | Maps text ↔ tokens | Prompt/formatting drift, off-by-one special tokens |
| Generation config (temp, top_p, stop, max_new_tokens defaults) | Shapes sampling | “Same prompt, different vibe” |
| Serving/adapter code (pre/post-processing, prompt template, LoRA merge) | Wraps the model | Prompt template drift is a top silent regression |
| Inference engine + version (vLLM, TGI, TensorRT-LLM, llama.cpp) | Kernels, sampling RNG, quant handling, batching | Numeric drift, different quant results |
| Quantization recipe (AWQ/GPTQ/FP8 params, calibration set) | Alters the effective weights | Quality cliff that “weights hash” alone won’t catch |
Runtime deps (torch, CUDA, transformers, flash-attn) | Kernel/numeric behavior | Reproducibility gaps across hosts |
| Hardware/precision assumptions (GPU arch, bf16 vs fp16) | Numeric results differ | Cross-host non-determinism |
Reproducibility checklist — a version is not reproducible unless you can answer all of:
- Exact weights identified by content hash (not a moving tag)
-
config.json+tokenizer.*+ chat template captured with the weights -
generation_config.json/ default sampling params captured - Inference engine name and exact version recorded
- Quantization recipe + calibration data recorded (if quantized)
- Serving-code commit SHA recorded
- Runtime deps pinned (lockfile / image digest)
- Training/lineage: source run, data snapshot, base model revision
- Eval results linked to this version id
Practical shortcut: bake weights + tokenizer + config + engine into a container image referenced by digest, and register that digest as the version’s artifact. The image digest content-addresses most of the bundle in one shot.
Immutable, Content-Addressed Artifacts
An artifact is content-addressed when its identifier is a cryptographic hash of its bytes. Two properties fall out for free:
- Integrity — re-download and re-hash; if it matches, the bytes are intact.
- Deduplication & identity — identical artifacts share an id; different bytes get different ids. You cannot accidentally overwrite version 5 with new content and keep the id.
You can express a version identity as the hash over the ordered set of component digests:
[ \text{version_id} = H\big( H(\text{weights}) ,|, H(\text{tokenizer}) ,|, H(\text{config}) ,|, \text{engine_ver} ,|, \text{code_sha} \big) ]
where ( H ) is a strong hash (SHA-256) and ( | ) is concatenation. If any input byte changes, ( \text{version_id} ) changes. This is exactly how Docker image digests, Git commit SHAs, and safetensors integrity checks work.
Semantic version vs content hash — use both, for different jobs.
- A semantic/registry version (
v3,2.1.0, or MLflow’s auto-incremented integer) is human-ordered: it tells you “newer than v2,” carries release intent, and is what people talk about. It does not guarantee the bytes are unique or unchanged. - A content hash is machine-truth: it guarantees identity but is unordered and unreadable. It is what you log and verify against.
Best practice: assign a monotonic registry version for humans, and record the content hash(es) as immutable metadata/tags on that version. Never reuse a version number for different bytes.
Artifact Storage
LLM artifacts are big — a 70B model in bf16 is ~140 GB; even a 7B is ~14 GB. Storage choices are shaped by size:
- Object storage (S3, GCS, Azure Blob) is the default backing store. It is cheap, durable, versioned, and content-addressable if you key objects by hash. Registries (MLflow, SageMaker, Vertex) all store metadata in a database and artifacts in object storage.
- Enable object-versioning / immutability (S3 Object Lock, GCS object versioning) so a bucket write cannot silently mutate an existing version’s bytes.
- Deduplicate with content-addressed keys:
s3://models/by-hash/<sha256>; the registry version just references the hash. Identical LoRA adapters, tokenizers, and base weights are stored once. - Mind egress and cold-start. Pulling 140 GB per pod on autoscale is slow and expensive. Common mitigations: node-local caches, a shared read-only volume (EFS/Filestore), pre-warmed images, or a peer-to-peer distributor. The version id must stay stable regardless of where it is cached.
- Git LFS underpins the Hugging Face Hub: each repo is a Git repo, large files go to LFS, and every commit is a content-addressed revision (this is why HF pinning works — more below).
Registries & the Promotion Workflow
A model registry is the source of truth that maps human-facing pointers to immutable versions, records lineage/metadata, and gates promotion. Two pointer models exist; modern registries favor the second:
Stages vs Aliases
- Stages (classic): a version lives in one of
None → Staging → Production → Archived. Exactly one stage per version; transitions move a version between buckets. Simple, but coarse — you get one “Production” slot and rigid semantics. MLflow has deprecated stages in favor of aliases + tags. - Aliases (modern): named, repointable pointers (
@champion,@challenger,@production,@canary) that each point at exactly one version. A version can carry many aliases; you can have@championand@shadowsimultaneously. Aliases decouple “what code loads” from “which version is behind it.” MLflow, Vertex, and (effectively) HF branches all use this model.
Gated Promotion Workflow (dev → staging → prod)
A promotion is a pointer move guarded by evidence, not a rebuild:
register (immutable version N, content-hashed)
│
▼
[dev] ──► automated evals + smoke tests ──► set alias @staging → N
│ (metrics logged and LINKED to version N)
▼
[staging] ──► shadow / offline evals / human approval (gate)
│ approver signs off; validation_status=approved
▼
[prod] ──► set alias @champion → N (server reloads / picks up)
│
▼
rollback ──► set alias @champion → N-1 (previous version still intact)
The critical properties:
- Promotion never mutates bytes. It only moves a pointer to an already-registered, immutable version. This is what makes rollback trivial and instant.
- Gates are enforced, not advisory. A version should be blocked from
@championunless eval metrics on that version id pass thresholds and (for prod) an approver signed off. Encode gates in CI/CD, not tribal knowledge. - Approvals are recorded on the version (who, when, against which eval run).
- The previous prod version stays registered and warm-able, so rollback is a pointer move back, not a rebuild-and-redeploy.
Fully Worked Example: MLflow Registry Workflow
This is a real, correct MLflow (3.x) workflow: log + register a transformers model with
its tokenizer, link eval metrics, use aliases as gates, promote to @champion, and load
by alias in the server. It also shows how the server resolves “current prod version.”
1. Log the full bundle and register a version
import mlflow
from mlflow import MlflowClient
from transformers import AutoModelForCausalLM, AutoTokenizer
mlflow.set_tracking_uri("http://mlflow:5000")
mlflow.set_experiment("chatbot-llm")
MODEL_NAME = "chatbot-llm" # registered model (the "name")
BASE = "meta-llama/Llama-3.1-8B-Instruct"
BASE_REVISION = "0e9e39f" # pin the base model commit (see HF section)
model = AutoModelForCausalLM.from_pretrained(BASE, revision=BASE_REVISION)
tokenizer = AutoTokenizer.from_pretrained(BASE, revision=BASE_REVISION)
with mlflow.start_run() as run:
# Log weights + tokenizer TOGETHER so they can never drift apart,
# and register a new immutable version in one call.
info = mlflow.transformers.log_model(
transformers_model={"model": model, "tokenizer": tokenizer},
name="model",
registered_model_name=MODEL_NAME, # -> creates/append version
# Pin the runtime so the bundle is reproducible:
pip_requirements=[
"transformers==4.44.2",
"torch==2.4.0",
"accelerate==0.33.0",
],
)
# Capture lineage + the exact engine/code we intend to serve with.
mlflow.set_tag("git_sha", "a1b2c3d")
mlflow.set_tag("base_model_revision", BASE_REVISION)
mlflow.set_tag("serving_engine", "vllm==0.6.2")
version = info.registered_model_version # e.g. "7" — immutable, monotonic
print("registered", MODEL_NAME, "version", version)
2. Link eval results to this version, and gate with an alias
client = MlflowClient()
# Run your eval harness against THIS version id, then record results
# ON the version so promotion decisions are auditable.
eval_exact_match = 0.712
eval_toxicity = 0.004
client.set_model_version_tag(MODEL_NAME, version, "eval_exact_match", str(eval_exact_match))
client.set_model_version_tag(MODEL_NAME, version, "eval_toxicity", str(eval_toxicity))
client.set_model_version_tag(MODEL_NAME, version, "validation_status", "pending")
# First gate: expose as challenger for staging/shadow traffic.
client.set_registered_model_alias(MODEL_NAME, "challenger", version)
3. Gated promotion to production
def promote_to_champion(name: str, version: str,
min_em: float = 0.68, max_tox: float = 0.01) -> None:
mv = client.get_model_version(name, version)
em = float(mv.tags.get("eval_exact_match", "0"))
tox = float(mv.tags.get("eval_toxicity", "1"))
if em < min_em or tox > max_tox:
raise RuntimeError(f"gate failed: em={em} tox={tox}")
# (human approval would be checked here too, e.g. an approved tag)
client.set_model_version_tag(name, version, "validation_status", "approved")
# Atomically repoint the production pointer at the new version.
client.set_registered_model_alias(name, "champion", version)
print(f"{name} @champion -> v{version}")
promote_to_champion(MODEL_NAME, version)
4. The server loads BY ALIAS and pins the resolved version
# --- serving process, at startup ---
import mlflow
from mlflow import MlflowClient
MODEL_NAME = "chatbot-llm"
ALIAS = "champion"
client = MlflowClient()
# Resolve the alias ONCE to an immutable version + source, and pin it.
mv = client.get_model_version_by_alias(MODEL_NAME, ALIAS)
RESOLVED_VERSION = mv.version # e.g. "7"
RESOLVED_SOURCE = mv.source # artifact URI / storage location
RESOLVED_RUN = mv.run_id
print(f"serving {MODEL_NAME} @{ALIAS} = v{RESOLVED_VERSION} (run {RESOLVED_RUN})")
# Load the exact bundle (weights + tokenizer). Loading by @alias is convenient,
# but we resolved+logged the concrete version above so every request is auditable.
pipeline = mlflow.transformers.load_model(f"models:/{MODEL_NAME}@{ALIAS}")
def handle(request_text: str) -> dict:
out = pipeline(request_text)
# Stamp the immutable version on every response/log line.
return {"model": MODEL_NAME, "version": RESOLVED_VERSION, "output": out}
How the server resolves “current prod version”: it asks the registry for the version
behind the @champion alias (get_model_version_by_alias) at load time, records the
returned immutable version id, and serves that. It does not re-resolve per request —
otherwise a mid-flight promotion would split traffic across versions unpredictably.
Instead, a promotion signals a controlled reload (rolling restart, or a
watch-and-drain), and until then the process keeps serving its pinned version and logs
it on every request.
5. Rollback is a pointer move
# Something regressed in v7. Point production back at the known-good v6.
client.set_registered_model_alias(MODEL_NAME, "champion", "6")
# Trigger the servers to reload (rolling restart). v7 stays registered for forensics.
Load-by-version equivalent (fully pinned, no alias indirection):
mlflow.transformers.load_model("models:/chatbot-llm/7"). Use aliases for operability; use explicit versions when you want the config file itself to be the pin.
Registry Comparison
| Feature | MLflow Model Registry | SageMaker Model Registry | Vertex AI Model Registry | Hugging Face Hub |
|---|---|---|---|---|
| Version unit | Registered model + integer version | Model Package Group + Model Package | Model resource + version id | Git repo + commit (revision) |
| Pointer mechanism | Aliases + tags (stages deprecated) | Approval status (Pending/Approved/Rejected) | Version aliases (incl. default) | Branches / tags / commit SHA |
| Immutable id | Version number + logged artifact hash | Model Package ARN | Version id (immutable) | Commit hash (content-addressed via Git/LFS) |
| Gated promotion | Alias move + tag gates in CI | Approval status flip (EventBridge-triggerable) | Alias reassignment | PR/branch merge; manual convention |
| Artifact store | Pluggable (S3/GCS/Azure/local) | S3 (+ ECR image) | Google Cloud Storage | Git LFS on the Hub |
| Lineage/metadata | Runs, params, metrics, tags | Metrics, data lineage, source pipeline | Metadata, eval, dataset links | Model card (README.md + YAML) |
| Approval/audit | Tags + external gate | Native approval workflow + audit | IAM + Cloud Audit Logs | Repo history / commits |
| Best when | Open-source, self-hosted MLOps | Deep AWS + Pipelines/EventBridge | Deep GCP + Vertex Endpoints | Public/OSS models, git-native pinning |
Key nuances:
- MLflow deprecated the
None/Staging/Production/Archivedstages in favor of named aliases + tags — repoint an alias to promote or roll back. Load withmodels:/<name>@<alias>ormodels:/<name>/<version>. - SageMaker organizes versions under a Model Package Group; promotion is a flip
of approval status (
PendingManualApproval → Approved), which can trigger downstream deploys via EventBridge. TheModelPackageArnis the immutable handle. - Vertex AI keeps versions under one model resource; aliases (e.g. the built-in
default) point at versions. Referencemodel@defaultor a version id; update the alias target to promote without touching client code. - Hugging Face Hub is git-native: every push is a commit, and
revision=onfrom_pretrainedpins a commit hash, branch, or tag. The commit hash is a true content address; a baremainis a mutable pointer — never rely on it in prod.
Reproducibility & Rollback Mechanics
Reproducibility means: given a version id, you can reconstruct the exact serving behavior on a fresh host. Mechanically:
- Resolve the version id → immutable artifact reference (hash/ARN/commit).
- Fetch bytes from object storage; re-hash and verify against the recorded digest.
- Load with the pinned engine + pinned runtime (image digest or lockfile).
- Apply the captured generation/serving config (not the engine defaults).
- Re-run the version’s eval suite; confirm metrics match the recorded numbers within tolerance. If they don’t, something in the bundle was not actually pinned.
Rollback works because the previous version was never destroyed and the pointer is cheap to move:
- Registry-level: repoint
@champion(MLflow), flip approval / redeploy previousModelPackageArn(SageMaker), reassigndefaultalias (Vertex), or pin the prior commit (HF). O(1) metadata operation. - Serving-level: the server must actually reload. Options: rolling restart of pods, a sidecar that watches the alias and drains+reloads, or blue/green where the old version’s replicas are kept warm until the new one is confirmed. Keeping N-1 warm turns rollback into a traffic shift measured in seconds.
- Forensics: because every request logged its immutable version id, you can bound the blast radius exactly — “requests between 14:03 and 14:31 hit v7” — and attach that to the incident.
Rollback SLO worth stating out loud: repoint + drain + serve previous version in under a few minutes, with zero rebuild. If your rollback requires re-running a training or build pipeline, you do not have rollback; you have a second forward deploy.
A/B and Shadow of Versions
Aliases make multi-version serving natural because several pointers can coexist:
- A/B (canary): split live traffic across
@championand@challenger. Users see responses from both; you compare online metrics (latency, thumbs-up, task success) by the version id stamped on each request. Promote the winner by repointing@champion. (See the Canary Deployments chapter for traffic-splitting mechanics.) - Shadow (mirror): send a copy of production traffic to
@shadow(the candidate) but do not return its output to the user. You capture the candidate’s responses and latency for offline comparison with zero user risk — ideal for validating an engine upgrade or a re-quantized version before it ever touches a user.
Both require the same discipline: the response/log record must carry the exact version that produced it, or the comparison is meaningless. Shadow is the safest way to catch tokenizer/engine drift before promotion, because you diff the candidate against prod on identical inputs.
Model Cards & Lineage
A model card is the human-readable documentation of a version: intended use, training
data, eval results, limitations, biases, and license (Mitchell et al., 2019, Model
Cards for Model Reporting). On the Hugging Face Hub the card is the repo’s README.md
with a YAML metadata header; it renders on the model page and is machine-parsable.
Lineage is the machine-readable provenance graph: this version came from this training run, on this data snapshot, from this base-model revision, built by this pipeline commit. Registries capture lineage as run links (MLflow), source pipeline references (SageMaker), or metadata edges (Vertex).
Why both matter for serving: when an incident, a compliance request, or a “which-data-did-this-see” question lands, the card answers what and why, and lineage answers from what. A version with neither is unauditable — you cannot prove what it is or where it came from, which is exactly the state the opening anecdote describes.
Failure Modes & Pitfalls
- Mutable
latest/mainin prod. Servingchatbot-llm:latestor HFmainmeans behavior can change under you with no deploy and no diff. Pin a digest, alias→version, or commit hash.latestis for dev laptops, never production. - Weights not content-addressed. If a bucket write can overwrite version 5’s bytes and keep the id, your “immutable” version is a lie. Key by hash and enable object immutability/versioning.
- Tokenizer/engine drift. Weights pinned, but the tokenizer or chat template comes
from a different revision, or the base image bumped
transformers/vLLM. Same weights, different tokens, different outputs. Version the whole bundle and record the engine version; catch it with shadow. - Prompt/serving-code drift. The prompt template or post-processing lives in app code that deploys on its own cadence, decoupled from the model version. Pin the serving-code SHA into the version.
- No link between served version and eval results. Metrics logged against a run but not the version id, or evals run on a different bundle than what ships. Promotion gates then guard nothing. Attach eval numbers to the version, run them on the exact artifact.
- Alias re-resolved per request. Resolving
@championon every request makes a promotion split traffic mid-flight and makes logs ambiguous. Resolve once at load, pin, and reload on change. - Semantic version reused for different bytes. Re-tagging
v3after a hotfix silently changes identity. Versions are append-only; new bytes get a new number. - Rollback that rebuilds. If reverting requires re-running the build/train pipeline, incidents last hours. Keep N-1 registered and warm-able.
- Config drift between registry and serving. The registry says v7 but the pod mounted a stale cached artifact. Verify the loaded hash against the registry at startup and fail closed on mismatch.
Production Checklist — What an Interviewer Probes
- “What exactly is a model version to you?” — Expect the full bundle: weights + config + tokenizer + generation config + serving code + engine version + runtime, all pinned. Naming only the weights is a red flag.
- “How does the server know which version is prod, right now?” — Alias/approval
resolved once at load, pinned, and stamped on every request; not
latest, not per-request re-resolution. - “Walk me through promoting dev → staging → prod.” — Immutable register, evals linked to the version, enforced gates, approval recorded, pointer move — never a rebuild.
- “A regression is in prod. Roll it back.” — Repoint alias / redeploy prior ARN / pin prior commit; drain + reload; previous version still registered and warm; target minutes, no rebuild.
- “How do you guarantee the model is byte-for-byte what you think?” — Content hash / commit digest, re-verified on load; object-store immutability; fail closed on mismatch.
- “Same weights but outputs changed — how did that happen and how do you prevent it?” — Tokenizer/engine/config/prompt drift; version the whole bundle, record engine version, catch with shadow.
- “How do you compare two versions safely in production?” — Shadow for zero-risk diffing, A/B/canary for online metrics, with the version id stamped on every record.
- “How would you audit which model answered a given request three weeks ago?” — Immutable version id logged per request + model card + lineage back to run/data.
Further Reading
- MLflow — Model Registry (aliases, tags, workflow): https://mlflow.org/docs/latest/ml/model-registry/workflow/
- MLflow — Registry concepts (stages deprecated in favor of aliases): https://mlflow.org/docs/latest/model-registry/
- MLflow — Load a registered model (
models:/name@alias,models:/name/version): https://mlflow.org/docs/latest/getting-started/registering-first-model/step3-load-model/ - Amazon SageMaker — Register a model & Model Package Groups: https://docs.aws.amazon.com/sagemaker/latest/dg/model-registry.html
- Amazon SageMaker — Update model approval status: https://docs.aws.amazon.com/sagemaker/latest/dg/model-registry-approve.html
- Vertex AI — Model Registry introduction: https://cloud.google.com/vertex-ai/docs/model-registry/introduction
- Vertex AI — Model version aliases: https://docs.cloud.google.com/gemini-enterprise-agent-platform/machine-learning/model-registry/model-alias
- Hugging Face — Sharing & the
revisionargument (pin a commit): https://huggingface.co/docs/transformers/model_sharing - Hugging Face — Model Cards (format & metadata): https://huggingface.co/docs/hub/en/model-cards
- Mitchell et al., 2019 — Model Cards for Model Reporting: https://arxiv.org/abs/1810.03993
- Docker — image digests vs tags (the mental model): https://docs.docker.com/dhi/core-concepts/digests/
Topic 10: Drift Detection
What You’ll Learn
This topic teaches you how to:
- Detect data drift (input distribution changes)
- Detect concept drift (model performance degrades)
- Set up monitoring with Evidently AI
- Create alerts for anomalies
- Analyze drift patterns
- Take corrective action
Why Drift Detection?
The Problem
Models degrade over time because:
- Data drift: Input data distribution changes
- Concept drift: Relationship between input/output changes
- Model decay: Model becomes outdated
Impact
- Reduced accuracy: Model performs worse
- Business impact: Wrong predictions cost money
- User experience: Poor quality outputs
- Compliance: May violate regulations
Types of Drift
1. Data Drift
Input data distribution changes.
Example:
- Training: 80% English, 20% Spanish
- Production: 60% English, 40% Spanish
Detection: Compare input distributions.
2. Concept Drift
Relationship between input and output changes.
Example:
- Training: “hot” = positive sentiment
- Production: “hot” = negative sentiment (context changed)
Detection: Monitor prediction accuracy.
3. Prediction Drift
Model predictions distribution changes.
Example: Model starts predicting more positive labels.
Detection: Compare prediction distributions.
Evidently AI
What is Evidently?
Open-source tool for ML monitoring and drift detection.
Features
- Data drift detection: Statistical tests
- Model performance: Accuracy monitoring
- Data quality: Missing values, outliers
- Dashboards: Visual reports
Setup
Installation
pip install evidently
Basic Usage
from evidently import ColumnMapping
from evidently.report import Report
from evidently.metrics import DataDriftTable
# Compare reference (training) vs current (production)
report = Report(metrics=[DataDriftTable()])
report.run(
reference_data=train_data,
current_data=production_data
)
report.show()
Monitoring Pipeline
1. Collect Data
Store production inputs and predictions.
2. Compute Metrics
Calculate drift metrics periodically.
3. Compare to Baseline
Compare against training/reference data.
4. Alert on Drift
Send alerts when drift detected.
5. Take Action
Retrain model or investigate cause.
Implementation
Data Collection
# Store production data
def log_prediction(input_data, prediction, model_version):
store.append({
"timestamp": datetime.now(),
"input": input_data,
"prediction": prediction,
"model_version": model_version
})
Drift Detection
# Run drift detection daily
def detect_drift():
reference = load_reference_data()
current = load_recent_production_data()
report = Report(metrics=[
DataDriftTable(),
DatasetDriftMetric(),
PredictionDriftMetric()
])
report.run(reference_data=reference, current_data=current)
return report
Alerting
# Check if drift detected
if report.get_metric(DataDriftTable()).drift_detected:
send_alert("Data drift detected!")
Metrics to Monitor
Data Drift Metrics
- PSI (Population Stability Index): Distribution similarity
- Kolmogorov-Smirnov test: Distribution differences
- Chi-square test: Categorical distribution
Model Performance
- Accuracy: Overall correctness
- Precision/Recall: Per-class metrics
- F1 score: Balanced metric
Prediction Drift
- Prediction distribution: How predictions change
- Prediction by segment: Per-group analysis
Dashboards
Evidently Dashboard
from evidently.ui.dashboards import Dashboard
dashboard = Dashboard("Drift Monitoring")
dashboard.add_report(drift_report)
dashboard.show()
Integration with Grafana
Export metrics to Prometheus, visualize in Grafana.
Alerting Rules
Data Drift Alert
alert: DataDriftDetected
expr: drift_score > 0.2
for: 1h
annotations:
summary: "Data drift detected"
Performance Degradation
alert: ModelPerformanceDegraded
expr: accuracy < 0.8
for: 2h
annotations:
summary: "Model accuracy below threshold"
Best Practices
- Establish baseline: Use training data as reference
- Monitor continuously: Check drift regularly
- Set thresholds: Define what’s “drift”
- Investigate causes: Understand why drift occurs
- Document actions: Track responses to drift
- Automate responses: Auto-retrain or alert
Common Scenarios
Gradual Drift
Slow change over time → Retrain periodically
Sudden Drift
Rapid change → Investigate cause immediately
Seasonal Drift
Predictable patterns → Account for seasonality
Exercises
- Set up Evidently: Install and configure
- Detect data drift: Compare training vs production
- Create dashboard: Visualize drift metrics
- Set up alerts: Alert on drift detection
- Investigate drift: Analyze why drift occurred
Next Steps
- Topic 8: Integrate drift detection with monitoring
- Topic 9: Track drift per model version
- Topic 7: Use drift detection in canary deployments
Further Reading
Drift Detection for Deployed LLMs — A Practical Guide
Noticing when the inputs your model sees, or the outputs it produces, have quietly stopped looking like what you tested against.
Why this matters
You shipped a model. Evals were green, latency was fine, the demo delighted the VP. Then three weeks later support tickets spike, a red-team screenshot lands in Slack, and someone asks the question you cannot answer: did the model change, or did the world change?
An LLM endpoint is a static function deployed into a non-stationary environment. The weights are frozen at a checkpoint. But the prompts arriving at 3pm on a Tuesday in month four are drawn from a different distribution than the ones you curated for your eval set. Users discover new use cases. A marketing launch sends a new persona your way. An upstream service starts truncating context. A competitor publishes a jailbreak. None of these touch your weights, and none of them show up in a unit test — but all of them change what your system does in production.
Drift detection is the monitoring discipline that makes this observable. It answers three operational questions:
- Are inputs shifting? (Are we being asked things we were not built for?)
- Are outputs shifting? (Is quality, length, refusal rate, or latency decaying?)
- Is the input→output relationship shifting? (Concept drift — the right answer changed even though the question looks the same.)
Get this right and you catch regressions before your users file them. Get it wrong and you either miss real degradation or drown in false alarms until the on-call engineer mutes the channel. Both failure modes are common, and both are avoidable with a small amount of statistics applied with judgment.
This chapter is deliberately intuition-first: every method gets a plain-English picture before a formula, then a worked micro-example with numbers you can reproduce, then the honest caveat about where it breaks.
Core intuition: the model is static, the world is not
Hold one picture in your head for the whole chapter.
At training/eval time you sampled a reference distribution ( P_{\text{ref}} ) — the prompts, embeddings, and outputs you validated against. In production you observe a stream that, windowed, gives you a live distribution ( P_{\text{live}} ). Drift is simply:
[ P_{\text{live}} \ne P_{\text{ref}} ]
Every technique in this chapter is a way to measure a distance between these two distributions from finite samples, and then decide whether that distance is large enough to act on. That is the whole game:
- Pick what you measure (a feature: prompt length, embedding, refusal flag, latency).
- Pick a distance / test (PSI, KS, MMD, embedding distance, a classifier).
- Pick a window and a threshold.
- Decide what happens when the threshold trips.
The subtlety — and where most production systems fail — is not the math. It is choosing a reference window that means something, choosing a live window that is neither too jittery nor too laggy, and resisting the urge to page a human every time noise crosses a line. A drift monitor that fires ten times a week is worse than no monitor, because it trains everyone to ignore it.
One more framing that matters for LLMs specifically: you usually have no labels. In classic ML monitoring you eventually learn the ground truth (the loan defaulted, the click happened) and can measure real performance. For a chat endpoint, “was this answer good?” often never arrives, or arrives weeks later as a thumbs-down on 0.3% of turns. So drift detection on the inputs and the observable outputs is frequently the only early-warning signal you get. That raises its stakes — and it means you must be honest that an input-drift alarm is smoke, not a diagnosis.
A drift taxonomy
Four kinds of drift, in the order you typically detect them:
| Type | What shifts | LLM example | Typically detected via |
|---|---|---|---|
| Input / prompt drift | The distribution of raw inputs ( P(x) ) | Prompts get longer; a new language appears; topic mix changes | PSI / KS on scalar features (length, token count, language ID); topic classifiers |
| Embedding / semantic drift | The distribution of inputs (or outputs) in vector space ( P(\phi(x)) ) | Users start asking about a product feature that did not exist at launch | MMD, domain classifier, centroid / cosine distance on embeddings |
| Output / quality drift | The distribution of outputs ( P(y) ) or a quality proxy | Answers get shorter, refusals rise, latency creeps up, tone changes | PSI/KS on output length, refusal rate, latency; LLM-judge scores |
| Concept drift | The conditional ( P(y \mid x) ) — the correct mapping | “Best model” now points to a newer model; a policy changed so the right answer flipped | Requires labels or re-eval; input/output drift can be flat while this moves |
Two things worth internalizing:
- Input drift and output drift can move independently. Inputs can look identical while outputs decay (e.g., a silent upstream change to your system prompt, or a provider swapping the model behind an alias). Outputs can look identical while inputs shift (the model gracefully handles new topics — good!). Monitor both, and monitor them separately so you can tell which one moved.
- Concept drift is the dangerous one and the hardest to see. ( P(x) ) can be perfectly stable while ( P(y \mid x) ) rots underneath you. Detecting it genuinely requires ground truth or periodic re-evaluation — no unsupervised distance on inputs will find it. Say this out loud in an interview; it separates people who have run monitoring from people who have read about it.
A useful mental decomposition: the joint ( P(x,y) = P(x),P(y\mid x) ). Input/embedding drift is a change in ( P(x) ); concept drift is a change in ( P(y\mid x) ). Output drift is a change in the marginal ( P(y) ), which can be caused by either — which is exactly why an output-drift alarm alone cannot tell you whether users changed or the model rotted. You have to look at inputs and outputs together.
Detection methods in depth
For each method: the intuition, the precise formula, and a small worked example with real numbers.
1. Population Stability Index (PSI)
Intuition. Bin a feature. Compare the share of traffic in each bin now vs. at reference time. If mass sloshed from one bin to another, PSI grows. It is a symmetric, binned relative-entropy-flavored score, and it is the workhorse of tabular drift monitoring because it produces a single interpretable number with battle-tested thresholds.
Formula. With ( B ) bins, reference proportion ( r_b ) and live proportion ( l_b ) in bin ( b ):
[ \text{PSI} = \sum_{b=1}^{B} \left( l_b - r_b \right), \ln!\frac{l_b}{r_b} ]
Each term is ( \ge 0 ) (a bin that moves in either direction adds positively), so PSI is a non-negative divergence. It is the symmetrized KL contribution per bin: ( (l_b - r_b)\ln(l_b/r_b) = \text{KL term}{l|r} + \text{KL term}{r|l} ) collapsed into one expression. Because it sums per-bin contributions, PSI also localizes drift — you can read off which bin is driving the score, which KS cannot do. Empty bins blow up the log, so clamp proportions to a small ( \epsilon ) (e.g. ( 10^{-6} )) or add a pseudo-count.
Standard thresholds (from credit-risk practice, widely reused):
- ( \text{PSI} < 0.1 ): no meaningful shift.
- ( 0.1 \le \text{PSI} < 0.2 ): moderate shift — investigate.
- ( \text{PSI} \ge 0.2 ): significant shift — act. (Some shops use 0.25 as the “major” line.)
Worked micro-example. Four equal reference bins, so ( r_b = 0.25 ) each. Live proportions drift toward the top bin: ( l = (0.10,, 0.20,, 0.30,, 0.40) ).
| Bin | ( r_b ) | ( l_b ) | ( l_b - r_b ) | ( \ln(l_b/r_b) ) | term |
|---|---|---|---|---|---|
| 1 | 0.25 | 0.10 | (-0.15) | (-0.916) | 0.1375 |
| 2 | 0.25 | 0.20 | (-0.05) | (-0.223) | 0.0112 |
| 3 | 0.25 | 0.30 | (+0.05) | (+0.182) | 0.0091 |
| 4 | 0.25 | 0.40 | (+0.15) | (+0.470) | 0.0705 |
[ \text{PSI} = 0.1375 + 0.0112 + 0.0091 + 0.0705 = 0.2282 ]
Above 0.2 — a significant shift. Notice the top bin dominates the score: PSI is most sensitive where a large relative change lands, which is exactly why quantile bins (equal mass at reference) behave better than equal-width bins for skewed features like token counts. With equal-width bins on a heavy-tailed feature, the tail bins are nearly empty at reference, so a handful of new samples there produce a huge ( \ln(l_b/r_b) ) and a jumpy, unreliable score.
Data type: scalar / categorical features. Not for raw high-dimensional embeddings — you would have to bin per-dimension and lose all cross-dimensional structure.
2. Kolmogorov–Smirnov (KS) two-sample test
Intuition. Forget bins. Compare the two empirical cumulative distribution functions directly, and take the single point of maximum vertical gap between them. Big gap ⇒ the distributions differ. KS is non-parametric (assumes nothing about shape) and needs no binning choice, which makes it a clean default for continuous features.
Formula. For empirical CDFs ( F_{\text{ref}} ) and ( F_{\text{live}} ):
[ D = \sup_{x} \bigl| F_{\text{live}}(x) - F_{\text{ref}}(x) \bigr| ]
( D \in [0,1] ). The p-value comes from the Kolmogorov distribution; for sample sizes ( n, m ) you reject “same distribution” at level ( \alpha ) when
[ D > c(\alpha),\sqrt{\frac{n+m}{n,m}}, \qquad c(0.05) \approx 1.36 . ]
Worked micro-example. Reference sample ( {1,2,3,4} ), live sample ( {2,3,4,5} ) (each shifted up by 1). Step through the pooled sorted values and read both CDFs:
| ( x ) | ( F_{\text{ref}} ) | ( F_{\text{live}} ) | gap |
|---|---|---|---|
| 1 | 0.25 | 0.00 | 0.25 |
| 2 | 0.50 | 0.25 | 0.25 |
| 3 | 0.75 | 0.50 | 0.25 |
| 4 | 1.00 | 0.75 | 0.25 |
| 5 | 1.00 | 1.00 | 0.00 |
( D = 0.25 ). With ( n=m=4 ) that is nowhere near significant (the critical value is enormous for four points) — a reminder that KS on tiny windows is uninformative, and that the statistic and its significance are different things. On the realistic 5000-vs-1500 example below, ( D = 0.26 ) with a p-value around ( 10^{-67} ): same statistic magnitude, wildly different verdict, because sample size collapses the noise band.
Caveats. KS is most sensitive near the center of the distribution and comparatively blind in the tails. With very large windows it becomes hypersensitive — trivial, operationally irrelevant differences produce ( p < 0.001 ). That is why you pair the p-value with an effect-size threshold on ( D ) itself (say, alert only if ( D > 0.1 ) and ( p < 0.01 )). For categorical features KS does not apply — use a chi-square test of the count table instead.
3. Maximum Mean Discrepancy (MMD)
Intuition. The right tool when your feature is a vector (an embedding), not a scalar. Map every sample through a kernel into a high-dimensional space, take the mean of each set there, and measure the distance between those means. If the distributions are identical, the mean embeddings coincide and MMD is zero. Unlike PSI/KS it is inherently multivariate — no binning, no per-dimension decomposition — which is why it shows up in embedding-drift toolkits.
Formula. With kernel ( k ) (commonly RBF, ( k(a,b)=\exp(-\gamma\lVert a-b\rVert^2) )), the (biased) empirical squared MMD between reference ( X={x_i}{i=1}^m ) and live ( Y={y_j}{j=1}^n ):
[ \widehat{\text{MMD}}^2 = \frac{1}{m^2}\sum_{i,i’} k(x_i,x_{i’}) + \frac{1}{n^2}\sum_{j,j’} k(y_j,y_{j’}) - \frac{2}{mn}\sum_{i,j} k(x_i,y_j) ]
Read it as (within-reference similarity) + (within-live similarity) − 2·(cross similarity). When the two clouds overlap, the cross term matches the within terms and everything cancels toward zero. Significance comes from a permutation test: shuffle the pooled labels many times, recompute MMD each time to build the null distribution, and see where your observed value falls in that null.
Worked micro-example. 1-D, RBF with ( \gamma = 0.5 ). Reference ( X={0,1,2} ), live ( Y={3,4,5} ). Computing the three kernel-matrix means:
- within-reference mean ( = 0.633 )
- within-live mean ( = 0.633 ) (same spacing, so same self-similarity)
- cross mean ( = 0.101 ) (clouds are far apart, so kernel values are small)
[ \widehat{\text{MMD}}^2 = 0.633 + 0.633 - 2(0.101) = 1.0635, \qquad \widehat{\text{MMD}} = 1.031 ]
The large value reflects two clearly separated clouds. Pitfall: MMD’s scale is meaningless in the abstract — a value of 1.03 is only “large” relative to the permutation null for your data and your ( \gamma ). The kernel bandwidth ( \gamma ) matters a lot; a common heuristic sets it from the median pairwise distance of the pooled sample. Always calibrate the threshold empirically; never hard-code an MMD number. Cost is ( O((m+n)^2) ) per window, so subsample for large windows.
4. Embedding distance & clustering
Intuition. The cheapest embedding-drift signals. Summarize each set of embeddings by its centroid (mean vector) and measure how far the centroids moved, either by Euclidean distance or by cosine of the angle. Fast, streaming-friendly, and interpretable — but coarse.
Formulas. Centroids ( \bar\phi_{\text{ref}} = \frac1m\sum_i \phi(x_i) ) and ( \bar\phi_{\text{live}} ):
[ d_{\text{euclid}} = \lVert \bar\phi_{\text{ref}} - \bar\phi_{\text{live}} \rVert_2, \qquad d_{\cos} = 1 - \frac{\bar\phi_{\text{ref}} \cdot \bar\phi_{\text{live}}}{\lVert \bar\phi_{\text{ref}}\rVert,\lVert \bar\phi_{\text{live}}\rVert} ]
Worked micro-example. Two 2-D centroids, normalized: ( \bar\phi_{\text{ref}} = (0.8,0.6) ) and ( \bar\phi_{\text{live}} = (0.6,0.8) ) (both already unit-norm).
[ \cos = 0.8(0.6) + 0.6(0.8) = 0.96 \Rightarrow d_{\cos} = 0.04, \qquad d_{\text{euclid}} = \lVert(0.2,-0.2)\rVert = 0.2828 ]
The failure mode you must know: centroid distance is blind to variance and multimodal shifts. If half your traffic moves far left and half moves far right, the centroid can sit exactly where it started while the distribution has torn in two. This is why serious embedding-drift setups prefer a domain classifier (train a binary model to tell reference from live; if it achieves ROC-AUC meaningfully above 0.5, the sets are distinguishable ⇒ drift, and the classifier’s important features tell you why) or MMD, both of which see distributional shape, not just the mean. Centroid distance is a good first alarm, never the only one.
5. Wasserstein distance & chi-square (honorable mentions)
Two more you should be able to name:
- Wasserstein (earth-mover’s) distance on a scalar feature: the minimum “work” to reshape one distribution into the other, ( W_1 = \int |F_{\text{ref}}(x) - F_{\text{live}}(x)|,dx ). Unlike KS (a single sup gap) it integrates the whole difference and is reported in the feature’s real units (tokens, milliseconds), which makes thresholds interpretable. Evidently uses it per-dimension in one of its embedding-drift methods.
- Chi-square test for categorical features (topic labels, language, refusal/no-refusal): compares observed vs. expected counts, ( \chi^2 = \sum_b (O_b - E_b)^2 / E_b ). This is the categorical analogue of KS — reach for it whenever the feature is a label rather than a number.
A fully worked example: PSI + KS on a live window
A drop-in monitor over one scalar feature — here prompt length in tokens. The reference is captured at deploy time; the live window is the last ( N ) requests. The numbers in the comments are the actual output of this code (seed fixed), so you can run it and reproduce them exactly.
import numpy as np
from scipy import stats
# ----- Two windows of a real feature: prompt length (tokens) -----
# Reference: captured at deploy/eval time.
# Live: users are now pasting more context -> longer prompts.
rng = np.random.default_rng(42)
ref = rng.gamma(shape=4.0, scale=30.0, size=5000) # mean ~120 tokens
live = rng.gamma(shape=4.0, scale=42.0, size=1500) # mean ~168 tokens
def psi(ref, live, bins=10, eps=1e-6):
"""PSI with quantile bins fixed by the REFERENCE distribution.
Quantile (equal-mass) bins are the right default for skewed
features like token counts: equal-width bins would leave the
long tail nearly empty and make the score unstable.
"""
# Bin edges = reference deciles; outer edges pushed to +/- inf
edges = np.quantile(ref, np.linspace(0, 1, bins + 1))
edges[0], edges[-1] = -np.inf, np.inf
r_counts, _ = np.histogram(ref, bins=edges)
l_counts, _ = np.histogram(live, bins=edges)
# Clip to avoid log(0) / divide-by-zero on empty live bins
r_prop = np.clip(r_counts / r_counts.sum(), eps, None)
l_prop = np.clip(l_counts / l_counts.sum(), eps, None)
return float(np.sum((l_prop - r_prop) * np.log(l_prop / r_prop)))
# ----- Compute both signals -----
psi_val = psi(ref, live)
ks = stats.ks_2samp(ref, live) # returns (statistic D, p-value)
print(f"PSI = {psi_val:.4f}") # PSI = 0.4345
print(f"KS statistic D = {ks.statistic:.4f}") # KS statistic D = 0.2567
print(f"KS p-value = {ks.pvalue:.2e}") # KS p-value = 2.51e-67
# ----- Turn signals into an alert -----
PSI_ALERT = 0.20 # significant-shift threshold
KS_D_ALERT = 0.10 # minimum effect size we care about
KS_P_ALERT = 0.01 # significance level
psi_fires = psi_val >= PSI_ALERT
ks_fires = (ks.statistic >= KS_D_ALERT) and (ks.pvalue < KS_P_ALERT)
if psi_fires and ks_fires:
print("ALERT: prompt-length drift (PSI + KS agree). "
"Inputs are longer than at deploy time; "
"check truncation, context limits, and eval coverage.")
elif psi_fires or ks_fires:
print("WATCH: one signal tripped; monitor next windows before paging.")
else:
print("OK: no meaningful prompt-length drift.")
Output:
PSI = 0.4345
KS statistic D = 0.2567
KS p-value = 2.51e-67
ALERT: prompt-length drift (PSI + KS agree). ...
Both signals agree — PSI 0.43 is well past the 0.2 line, and KS gives ( D=0.26 ) with an astronomically small p-value. The AND of an effect-size gate and a significance gate is the pattern that keeps this from crying wolf: on a huge window KS alone would flag a 2-token difference; requiring ( D \ge 0.10 ) suppresses that. PSI alone, on a tiny window, would be jumpy; requiring both cross-checks it. Two cheap, independent tests on the same feature is a good default posture.
Note the two design choices that make this production-safe: (1) bin edges are frozen from the reference — if you re-derive quantile edges from the live window each time, both histograms are uniform by construction and PSI collapses to zero, hiding the drift; (2) the alert message is actionable — it names the likely causes and next checks, not just “drift detected.”
Extending to embeddings: MMD + a domain classifier
Scalars are the easy case. The moment you want semantic drift — “are users asking about different things?” — the feature is an embedding vector and you need a multivariate method. Here is a compact, correct monitor that runs both MMD (with a median-heuristic bandwidth and a permutation p-value) and a domain classifier, on a synthetic 16-dim embedding stream where four dimensions have shifted.
import numpy as np
from scipy.spatial.distance import pdist
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score
rng = np.random.default_rng(0)
d = 16
ref = rng.normal(0.0, 1.0, size=(2000, d))
live = rng.normal(0.0, 1.0, size=(600, d))
live[:, :4] += 0.6 # 4 of 16 dims drift (a new topic cluster)
def median_gamma(Z):
"""RBF bandwidth from the median pairwise distance (standard heuristic)."""
med = np.median(pdist(Z))
return 1.0 / (2.0 * med ** 2)
def rbf_mmd2(X, Y, gamma):
"""Biased empirical squared MMD with an RBF kernel."""
XX = np.exp(-gamma * ((X[:, None, :] - X[None, :, :]) ** 2).sum(-1))
YY = np.exp(-gamma * ((Y[:, None, :] - Y[None, :, :]) ** 2).sum(-1))
XY = np.exp(-gamma * ((X[:, None, :] - Y[None, :, :]) ** 2).sum(-1))
return XX.mean() + YY.mean() - 2.0 * XY.mean()
def mmd_permutation_test(X, Y, n_perm=200, sub=300, seed=0):
"""MMD^2 plus a permutation p-value. Subsample first — MMD is O(n^2)."""
r = np.random.default_rng(seed)
X = X[r.choice(len(X), min(sub, len(X)), replace=False)]
Y = Y[r.choice(len(Y), min(sub, len(Y)), replace=False)]
Z = np.vstack([X, Y])
gamma = median_gamma(Z)
n = len(X)
obs = rbf_mmd2(X, Y, gamma)
null = np.empty(n_perm)
for i in range(n_perm):
idx = r.permutation(len(Z)) # shuffle labels under H0
null[i] = rbf_mmd2(Z[idx[:n]], Z[idx[n:]], gamma)
pval = (1 + (null >= obs).sum()) / (1 + n_perm)
return obs, pval
def domain_classifier_auc(ref, live):
"""Train ref-vs-live; AUC ~0.5 => indistinguishable, ~1.0 => strong drift."""
X = np.vstack([ref, live])
y = np.r_[np.zeros(len(ref)), np.ones(len(live))]
clf = LogisticRegression(max_iter=1000)
return cross_val_score(clf, X, y, cv=5, scoring="roc_auc").mean()
mmd2, mmd_p = mmd_permutation_test(ref, live)
auc = domain_classifier_auc(ref, live)
# Representative run:
# MMD^2 = 0.037, permutation p = 0.005
# domain classifier AUC = 0.80
print(f"MMD^2 = {mmd2:.3f} perm p = {mmd_p:.3f}")
print(f"domain classifier AUC = {auc:.2f}")
AUC_ALERT = 0.65 # AUC this far above 0.5 => sets are clearly separable
if mmd_p < 0.01 and auc >= AUC_ALERT:
print("ALERT: embedding drift (MMD + classifier agree). "
"Cluster the live embeddings to find the new topic(s).")
Two takeaways. First, the domain-classifier AUC is the most interpretable embedding-drift number you can report — 0.80 means a simple model tells reference from live 80% of the time, which is unambiguous drift, and the classifier’s coefficients point at which dimensions moved. Second, MMD’s raw value (0.037) is meaningless without the permutation p-value — the same shift under a different bandwidth produces a different MMD magnitude, so always report significance, never the bare statistic. When these two disagree, trust the classifier for “is there drift?” and use MMD as a cheaper continuous tripwire between retrains.
Methods comparison
| Method | Data type | Output | Sensitivity | Pros | Cons |
|---|---|---|---|---|---|
| PSI | Scalar / categorical | Unbounded score, standard thresholds (0.1 / 0.2) | Sensitive where relative mass changes; binning-dependent | One interpretable number; no p-value plumbing; localizes to bins; industry-standard cutoffs | Needs binning choice; unstable with sparse bins; not for raw vectors |
| KS test | Continuous scalar | ( D\in[0,1] ) + p-value | Strong at distribution center, weak in tails | Non-parametric, no binning, principled p-value | Hypersensitive on huge windows; univariate only; tail-blind; not for categoricals |
| MMD | Vectors / embeddings | ( \ge 0 ), null via permutation | Detects general distributional shape shifts | Truly multivariate; kernel-flexible; theoretically grounded | ( O(n^2) ) cost; threshold not intuitive; bandwidth ( \gamma ) tuning matters |
| Domain classifier | Vectors / embeddings | ROC-AUC ( \in [0.5,1] ) | Sees any separable shift, incl. multimodal | Interpretable (AUC + feature importance), robust across embedding types, a strong default | Needs training per window; can overfit small windows |
| Centroid / cosine dist | Vectors / embeddings | Distance ( \ge 0 ) | Only mean shift | Cheap, streaming, interpretable | Blind to variance & multimodal splits; threshold hand-tuned |
| Wasserstein (per-dim) | Scalar / per-embedding-dim | ( \ge 0 ), in feature units | Sensitive to any shift incl. tails | Metric with real units; tail-aware | Univariate per dim; needs aggregation across dims |
| Chi-square | Categorical | ( \chi^2 ) + p-value | Any count-table change | Right tool for labels; simple | Needs adequate expected counts per cell |
Rule of thumb: scalars ⇒ PSI or KS; categoricals ⇒ chi-square or PSI; embeddings ⇒ domain classifier (default) or MMD; want a cheap tripwire ⇒ centroid distance.
LLM-specific drift signals
Generic distribution tests get you far, but LLM serving has signals you should monitor by name. In every case the feature is LLM-specific; the test is the same PSI/KS/MMD/chi-square machinery.
- Topic / intent shift. Run a lightweight topic or intent classifier (or cluster embeddings) and watch the category mix with PSI/chi-square over the topic histogram. A launch, a season, or an outage upstream shows up here first.
- Rising refusals. Track the fraction of outputs that are refusals or safety deflections (“I can’t help with that”). A climbing refusal rate on stable-looking inputs often means a system-prompt or provider-side model change, not user behavior. Alert on the rate, and segment by topic — a global rise and a single-topic rise mean very different things.
- Jailbreak / adversarial probing. A rising share of prompts matching known jailbreak patterns, or a spike in prompt-injection markers, is an attack signal masquerading as input drift. Monitor it as its own series so it doesn’t get averaged away in overall input drift.
- Quality decay. With no labels, use proxies: an LLM-as-judge score on a sampled slice, self-consistency across samples, retrieval-hit rate for RAG, or user thumbs-down rate. Treat judge scores as a drifting feature themselves and run PSI/KS on them window over window.
- Output length shift. Sudden shortening often signals truncation, a max-tokens change, or the model bailing early; sudden lengthening can signal rambling or a prompt-template regression. Cheap to compute, high signal.
- Latency / cost shift. p50/p95 latency and tokens-per-request are drift signals too. A provider swapping the model behind an alias frequently shows up as a latency and length change before anyone notices quality — sometimes it is your earliest signal of a silent model change.
- Language / encoding shift. A new language appearing, or a jump in non-ASCII / emoji ratio, changes tokenization and can silently degrade a model tuned for English.
- Format / schema conformance. For structured-output endpoints, monitor the rate of JSON-parse failures or schema-validation errors. A creeping failure rate is quality drift you can measure without labels.
Windowing & thresholds
The math is easy; the windowing is where judgment lives.
- Reference window. Prefer a fixed, curated reference (your eval-time distribution or a known-good production period), not a rolling one — a rolling reference lets slow drift redefine “normal” and hides exactly the gradual decay you care about. Refresh it deliberately, versioned, when you re-eval or retrain, and keep the old one around so you can diff.
- Live window. Big enough to be stable, small enough to be timely. Two common shapes: a tumbling window (disjoint hourly/daily batches — clean, but bursty) and a sliding window (smoother, but overlapping samples correlate, so consecutive readings are not independent). Size it to your traffic: a KS test wants hundreds-to-thousands of points to be meaningful; below ~50 the statistic is mostly noise.
- Thresholds. Start from priors (PSI 0.2, KS ( D>0.1 ) with ( p<0.01 )), then calibrate against your own history: replay past known-good weeks, look at the natural spread of each metric, and set thresholds a few standard deviations above that baseline. For embedding methods with no intrinsic scale (MMD, centroid distance), thresholds must be empirical (permutation null or historical quantiles) — a literature number is meaningless for your data.
- Require agreement / persistence. Two independent signals agreeing, or one signal tripping for ( k ) consecutive windows, cuts false alarms dramatically versus a single-window single-metric trigger. This is the cheapest reliability lever you have.
- Segment before you aggregate. A global metric averages away a severe shift confined to one client, region, or topic. Compute drift per meaningful segment; alert on the worst segment, not the mean.
Response playbook: what to do on drift
An alert is a question, not a verdict. Work the ladder:
- Confirm it’s real. Is it persistent across windows or a single-window blip? Do independent signals agree? Rule out a data-pipeline bug (a logging change, a tokenizer upgrade, a new client version that reformats prompts looks exactly like drift) before anything else. This is the single most common “drift” root cause.
- Localize it. Which feature, which segment, which topic, which customer, which region? Slice by metadata. “Overall PSI up” is useless; “prompt-length drift confined to the mobile client after the 4.2 release” is actionable.
- Classify it. Input drift, output drift, or (suspected) concept drift? Benign (new-but-handled use case) or harmful (quality decay)? Input drift with stable outputs may need nothing but a note and an eval-coverage ticket.
- Re-evaluate. Run your eval suite against a fresh sample of current traffic, not last quarter’s fixtures. This is the only way to convert “the distribution moved” into “quality actually dropped.” If you have any labels or can label a slice, do it now — even 100 hand-labeled current examples beats zero.
- Mitigate, cheapest first:
- Prompt / template fix if a system-prompt or template regression is the cause (fastest, most common).
- Roll back the model, provider alias, or config change if the drift lines up with a deploy.
- Guardrail / route — add a filter or route the drifted segment to a fallback or a stronger model.
- Expand evals to cover the new distribution so it stops being a blind spot next time.
- Retrain / fine-tune / update RAG index — the heaviest, slowest lever; reserve it for genuine, persistent concept drift, not a one-week anomaly.
- Close the loop. Update the reference window (versioned) once you have accepted the new normal, and write down what the alert meant so the next on-call doesn’t re-derive it at 2am. A drift runbook with past incidents is worth more than any single dashboard.
Failure modes & pitfalls
- Seasonality masquerading as drift. Traffic looks different at 3am, on weekends, on the 1st of the month. A reference captured Tuesday-midday will “drift” every Saturday. Compare like-for-like windows (same weekday/hour), or model the seasonality out; otherwise you train the team to ignore alerts.
- Wrong / stale reference window. Rolling references silently absorb slow decay. Too-short references are noisy. A reference from a broken period bakes the breakage into “normal” and you will never see the fault.
- Big-window hypersensitivity. On millions of requests, KS and chi-square reject everything — every trivial difference is “significant.” Pair p-values with effect-size gates, always.
- High-dimensional embedding drift is subtle. Centroid distance can read zero while the distribution splits in two; per-dimension tests miss cross-dimensional structure; and in high dimensions everything is far from everything (distance concentration), so raw Euclidean thresholds are treacherous. Prefer domain-classifier or MMD, and reduce dimensionality thoughtfully — drift can hide in the components you discarded, so don’t PCA blindly.
- No ground-truth labels. You can prove the inputs moved; you cannot prove quality dropped without evaluation. Unsupervised drift is a smoke alarm, not a diagnosis — never auto-remediate off it alone.
- Alerting on everything. One metric per feature per window with a tight threshold = a muted channel within a week. Aggregate, require persistence/agreement, and route by severity.
- Multiple comparisons. Running KS on 200 features guarantees ~10 “significant” hits at ( \alpha=0.05 ) by pure chance. Correct for it (Bonferroni / Benjamini–Hochberg FDR) or you will chase ghosts daily.
- Confusing statistic with significance. A tiny p-value on a ( D=0.02 ) shift is real but irrelevant; a large ( D ) on 8 samples is irrelevant noise. Report and gate on both the effect size and the p-value.
- Reference/live binning mismatch. Re-deriving quantile bin edges from each window makes every histogram uniform and PSI identically zero. Freeze edges from the reference — a subtle bug that silently disables the whole monitor.
Production checklist / what an interviewer probes
- “What exactly do you monitor, and why those features?” — Expect a named list: prompt length/token count, topic mix, refusal rate, output length, latency, format-conformance, and at least one embedding-based signal. Bonus for explaining input vs. output vs. concept coverage.
- “PSI vs. KS vs. MMD — when each?” — Scalars → PSI/KS; categoricals → chi-square; embeddings → MMD or domain classifier. Know PSI’s 0.1/0.2 thresholds and that KS is a supremum-of-CDF-gap statistic.
- “How do you pick the reference window?” — Fixed, curated, versioned; not rolling; refreshed deliberately. Red flag if they roll it automatically.
- “How do you avoid false alarms?” — Effect-size + significance gates, persistence across windows, multi-signal agreement, seasonality handling, per-segment analysis, multiple-comparison correction.
- “You have no labels — how do you know quality actually dropped?” — Must acknowledge unsupervised drift ≠ quality drop; re-eval on current traffic, LLM-judge on a slice, thumbs-down rate, canary/labeled sample.
- “Drift fires — walk me through the response.” — Confirm (rule out pipeline bug) → localize → classify → re-eval → mitigate cheapest-first (prompt fix / rollback before retrain) → update reference.
- “How would you detect embedding drift, and what breaks the naive approach?” — Domain classifier / MMD; centroid distance is blind to variance and multimodal splits; distance concentration in high dimensions.
- “Concept drift with stable inputs — how do you catch it?” — Honest answer: unsupervised input monitoring won’t; you need labels, periodic re-eval, or downstream outcome tracking.
Where this sits in the serving stack
A drift monitor is a small, boring pipeline that runs beside the hot path, never in it:
- Log at the edge. For every request/response, emit cheap features — token counts, latency, refusal flag, topic label, format-valid flag — plus a sampled subset of embeddings. Sample embeddings (they are expensive to store); log scalars in full.
- Snapshot a reference. At each deploy/eval, freeze a reference profile: bin edges per scalar feature, a held-out embedding sample, and the baseline rates. Version it alongside the model.
- Window & score offline. On a schedule (hourly/daily), pull the latest window, run PSI/KS/chi-square on scalars and MMD/classifier on embeddings, per segment.
- Gate & alert. Apply effect-size + significance gates, require persistence or multi-signal agreement, then route by severity to a dashboard (low) or a page (high).
- Feed the runbook. Every alert links to the playbook above and appends to an incident log so patterns become institutional knowledge.
The key architectural property: the monitor is asynchronous and best-effort. It must never add latency to inference, and a monitor outage must never take down serving. Compute drift from logs, not inline.
Further reading
- Fiddler AI — Measuring Data Drift with the Population Stability Index (PSI): https://www.fiddler.ai/blog/measuring-data-drift-population-stability-index
- GeeksforGeeks — Population Stability Index (PSI): https://www.geeksforgeeks.org/data-science/population-stability-index-psi/
- Gretton et al. — A Kernel Two-Sample Test (JMLR 2012, the MMD paper): https://www.jmlr.org/papers/volume13/gretton12a/gretton12a.pdf
- TorchDrift — Intuition for the Maximum Mean Discrepancy two-sample test: https://torchdrift.org/notebooks/note_on_mmd.html
- Evidently AI — 5 methods to detect drift in ML embeddings: https://www.evidentlyai.com/blog/embedding-drift-detection
- Evidently AI — Data drift algorithm (how thresholds/tests are chosen): https://docs-old.evidentlyai.com/reference/data-drift-algorithm
- Evidently AI — Monitoring embeddings drift (open-source course module): https://learn.evidentlyai.com/ml-observability-course/module-3-ml-monitoring-for-unstructured-data/monitoring-embeddings-drift
- NannyML — Multivariate Drift Detection (PCA reconstruction error): https://nannyml.readthedocs.io/en/stable/how_it_works/multivariate_drift.html
- NannyML — Monitoring data drift: univariate & multivariate methods: https://www.nannyml.com/blog/monitoring-data-drift
- SciPy —
ks_2sampreference: https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.ks_2samp.html - Real Statistics — Two-sample Kolmogorov–Smirnov test: https://real-statistics.com/non-parametric-tests/goodness-of-fit-tests/two-sample-kolmogorov-smirnov-test/
- WhyLabs — whylogs (data logging & drift profiling, open source): https://github.com/whylabs/whylogs
Topic 12: Multi-Model Serving with Triton
What You’ll Learn
This topic teaches you how to:
- Deploy models with NVIDIA Triton Inference Server
- Serve multiple models simultaneously
- Use dynamic batching
- Optimize for different frameworks (PyTorch, TensorFlow, ONNX)
- Create model ensembles
- Monitor Triton performance
Why Triton?
Benefits
- Multi-framework: PyTorch, TensorFlow, ONNX, TensorRT
- Dynamic batching: Automatic request batching
- Model ensembles: Chain multiple models
- High performance: Optimized inference
- Production-ready: Used by major companies
Use Cases
- Multiple models: Serve different models on same server
- Model pipelines: Chain models together
- A/B testing: Easy model switching
- Resource efficiency: Share GPU across models
Triton Architecture
Components
- Triton Server: Main inference server
- Model Repository: Storage for models
- Model Config: Configuration per model
- Scheduler: Request scheduling and batching
Request Flow
Client → Triton Server → Model Backend → GPU → Response
Installation
Docker (Recommended)
docker pull nvcr.io/nvidia/tritonserver:23.10-py3
Local Installation
# See Triton documentation for your platform
Model Repository Structure
model_repository/
model1/
config.pbtxt
1/
model.pt
model2/
config.pbtxt
1/
model.onnx
Model Configuration
Basic Config
name: "gpt2"
platform: "pytorch_libtorch"
max_batch_size: 8
input [
{
name: "input_ids"
data_type: TYPE_INT64
dims: [ -1 ]
}
]
output [
{
name: "output"
data_type: TYPE_FP32
dims: [ -1, 50257 ]
}
]
Dynamic Batching
dynamic_batching {
max_queue_delay_microseconds: 100
preferred_batch_size: [ 4, 8 ]
max_batch_size: 16
}
Starting Triton Server
Basic
tritonserver --model-repository=/path/to/models
With GPU
docker run --gpus all \
-v /path/to/models:/models \
nvcr.io/nvidia/tritonserver:23.10-py3 \
tritonserver --model-repository=/models
Client API
Python Client
import tritonclient.http as httpclient
client = httpclient.InferenceServerClient("localhost:8000")
# Prepare input
inputs = [httpclient.InferInput("input_ids", [1, 10], "INT64")]
inputs[0].set_data_from_numpy(input_ids)
# Infer
result = client.infer("gpt2", inputs)
output = result.as_numpy("output")
REST API
curl -X POST http://localhost:8000/v2/models/gpt2/infer \
-H "Content-Type: application/json" \
-d '{
"inputs": [{
"name": "input_ids",
"shape": [1, 10],
"datatype": "INT64",
"data": [[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]]
}]
}'
Dynamic Batching
How It Works
- Requests arrive at Triton
- Triton queues requests
- When batch ready (size or timeout), process together
- Return responses
Configuration
dynamic_batching {
max_queue_delay_microseconds: 100000 # 100ms
preferred_batch_size: [ 4, 8, 16 ]
max_batch_size: 32
}
Model Ensembles
Chain Models
name: "pipeline"
platform: "ensemble"
input [
{ name: "input", data_type: TYPE_STRING }
]
output [
{ name: "output", data_type: TYPE_STRING }
]
ensemble_scheduling {
step [
{
model_name: "tokenizer"
model_version: -1
input_map { key: "input" value: "input" }
output_map { key: "output" value: "tokens" }
},
{
model_name: "llm"
model_version: -1
input_map { key: "tokens" value: "input_ids" }
output_map { key: "output" value: "generated" }
}
]
}
Monitoring
Metrics Endpoint
curl http://localhost:8000/metrics
Key Metrics
- Request count
- Inference latency
- Queue size
- GPU utilization
- Batch size
Best Practices
- Organize models: Clear repository structure
- Optimize configs: Tune batching parameters
- Monitor performance: Track metrics
- Version models: Use versioning in repository
- Test thoroughly: Validate before production
Common Issues
Model Not Loading
- Check model repository path
- Verify model format
- Check config.pbtxt syntax
Low Throughput
- Increase batch size
- Tune dynamic batching
- Check GPU utilization
Out of Memory
- Reduce batch size
- Use smaller models
- Add more GPUs
Exercises
- Deploy model: Set up Triton with one model
- Multiple models: Serve multiple models
- Dynamic batching: Configure and test batching
- Model ensemble: Chain models together
- Monitor: Set up monitoring
Next Steps
- Topic 8: Monitor Triton with Grafana
- Topic 6: Autoscale Triton deployments
- Topic 9: Version models in Triton
Further Reading
NVIDIA Triton Inference Server: Production Multi-Model Serving
Why this matters
Most inference tutorials show you how to serve one model with one framework. Production is rarely that tidy. You have a PyTorch embedding model, an ONNX classifier, a TensorRT vision model, and — increasingly — a large language model, and they all need to sit behind stable HTTP/gRPC endpoints, share expensive GPUs, batch requests to keep those GPUs busy, expose Prometheus metrics, and version cleanly.
NVIDIA Triton Inference Server is the piece that does all of that in one process. It is a serving runtime, not a model format: you point it at a directory of models, each with a small config file, and it loads them across whatever backends they need, batches incoming requests, runs multiple copies concurrently, and serves them on standardized endpoints. For LLMs specifically, Triton pairs with the TensorRT-LLM backend to deliver in-flight (continuous) batching and paged KV cache — the same class of technique that makes vLLM fast — with NVIDIA’s most aggressive kernel optimizations underneath.
If you have exactly one LLM and nothing else, vLLM or TGI is often simpler. If you have a fleet of heterogeneous models, or you want NVIDIA’s fastest LLM path with unified ops tooling, Triton is the standard answer. This chapter explains what it is, how the model repository and config.pbtxt work, the backend landscape, the two flavors of batching, and how it stacks up against vLLM and TGI.
Core intuition
Hold one sentence in your head:
Triton is a serving runtime that hosts many models across many backends, with request batching and per-model concurrency built in.
Everything else is detail hanging off that sentence:
- Many models — a model repository (a directory) holds every model. Add a folder, Triton serves it. Triton can hot-load, hot-unload, and version them.
- Many backends — each model declares a
backend(orplatform). TensorRT-LLM, vLLM, Python, ONNX Runtime, PyTorch (LibTorch), TensorRT, and more all run inside the same server process behind the same API. - Batching built in — Triton groups small requests into larger batches to feed the GPU efficiently. For non-LLM models this is dynamic batching; for LLMs it is in-flight batching.
- Concurrency built in — instance groups let you run N copies of a model on one or more GPUs so requests overlap instead of queueing.
The payoff of the runtime abstraction: your infra team learns one server, one metrics format, one deployment story — and every model, whatever framework trained it, fits into it.
Architecture and the model repository
The big picture
A single tritonserver process contains:
- Frontends — HTTP/REST on port 8000, gRPC on port 8001, and a Prometheus metrics endpoint on port 8002.
- The core — request routing, the scheduler (which does batching), model management (load/unload/versioning), and the shared-memory / pinned-memory machinery for zero-copy tensor passing.
- Backends — shared libraries that actually execute a model. Each backend adapts one framework to Triton’s C API. Multiple backends coexist in one server.
Requests arrive at a frontend, get routed to the named model, land in that model’s scheduler queue, are (optionally) batched, dispatched to a model instance (a loaded copy on a specific device), and the response flows back out.
The model repository
Triton is started with one or more repositories:
tritonserver --model-repository=/models
The layout is strict and load-bearing — Triton discovers models by walking this tree:
/models/
├── text_classifier/
│ ├── config.pbtxt
│ ├── 1/
│ │ └── model.onnx
│ └── 2/
│ └── model.onnx
├── image_embedder/
│ ├── config.pbtxt
│ └── 1/
│ └── model.pt
└── llama3_trtllm/
├── config.pbtxt
└── 1/
└── ...engine files...
Rules that trip people up:
- The top-level directory name is the model name clients use in requests (
text_classifier, not the file inside). - Version subdirectories are integers (
1/,2/). Non-integer or0directories are ignored. By default Triton serves the highest numbered version; aversion_policyin the config changes that (latest N, all, or specific). - The model file name is fixed per backend —
model.onnxfor ONNX Runtime,model.ptfor PyTorch/LibTorch,model.planfor TensorRT,model.pyfor the Python backend, etc. - Repositories can live on local disk, S3, GCS, or Azure Blob (
--model-repository=s3://bucket/models).
config.pbtxt — the model configuration
Every model gets a config.pbtxt (protobuf text format) that declares its backend, tensor shapes, batching, and concurrency. For many framework backends Triton can auto-generate the config (--strict-model-config=false), but in production you write it explicitly so nothing is a surprise.
A minimal ONNX classifier config:
name: "text_classifier"
backend: "onnxruntime"
max_batch_size: 32
input [
{
name: "input_ids"
data_type: TYPE_INT64
dims: [ 128 ]
}
]
output [
{
name: "logits"
data_type: TYPE_FP32
dims: [ 5 ]
}
]
Key fields:
| Field | Meaning |
|---|---|
name | Must match the directory name (optional if it does). |
backend / platform | Which backend executes the model (onnxruntime, python, pytorch/platform: "pytorch_libtorch", tensorrt/platform: "tensorrt_plan", vllm, tensorrtllm). |
max_batch_size | Largest batch Triton will assemble. 0 means the model does not support Triton’s batching (first dim is not a batch dim). |
input / output | Tensor name, data_type (TYPE_FP32, TYPE_INT64, TYPE_STRING, TYPE_BF16, …), and dims. When max_batch_size > 0, the batch dimension is implicit — you list only the per-sample shape. Use -1 for dynamic dims. |
instance_group | How many copies, on what devices (see below). |
dynamic_batching | Enables server-side batching (see below). |
version_policy | { latest: { num_versions: 1 } }, { all: {} }, or { specific: { versions: [1,3] } }. |
Backends: pick the right engine
A backend is the plug-in that runs a model. Choosing the wrong one for LLMs is the single most common Triton mistake, so internalize this table:
| Backend | backend/platform | Best for | Batching model | Notes |
|---|---|---|---|---|
| TensorRT-LLM | tensorrtllm | Production LLM inference on NVIDIA GPUs | In-flight (continuous) | Fastest LLM path; requires compiling the model into a TensorRT-LLM engine. Paged KV cache, tensor/pipeline parallel. |
| vLLM | vllm | LLMs you want to run with minimal conversion | Continuous (vLLM’s own) | Wraps vLLM’s AsyncLLMEngine; PagedAttention; no engine build step. Easiest LLM onramp inside Triton. |
| Python | python | Pre/post-processing, tokenization, glue, custom logic, BLS | Dynamic (if you enable it) | You write model.py with TritonPythonModel. The universal escape hatch; also hosts Business Logic Scripting. |
| ONNX Runtime | onnxruntime | Classifiers, embedders, small/medium models exported to ONNX | Dynamic | Portable, CPU or GPU, good default for non-LLM models. |
| PyTorch (LibTorch) | pytorch / pytorch_libtorch | TorchScript / traced models | Dynamic | Serve model.pt directly without re-exporting. |
| TensorRT | tensorrt / tensorrt_plan | Vision/CNN/transformer engines compiled to a .plan | Dynamic | Extremely fast for non-generative models; needs a TensorRT build. |
The one rule to remember: do not try to serve an LLM’s token-by-token generation loop through a plain ONNX/PyTorch backend with dynamic_batching. Autoregressive decoding has variable-length outputs and per-request state; naive dynamic batching stalls the whole batch on the slowest sequence. LLMs need in-flight batching, which means the TensorRT-LLM or vLLM backend.
Dynamic batching vs in-flight batching
Batching is how you keep a GPU — which loves large parallel matmuls — busy when requests trickle in one at a time. Triton has two mechanisms, and the distinction is the heart of LLM serving.
Dynamic batching (for fixed-shape models)
For a classifier or embedder, every request does the same amount of work and produces a fixed-shape output. Triton’s dynamic batcher waits a tiny, bounded window, collects whatever requests arrived, forms one batch, runs it once, and splits the results back out.
dynamic_batching {
preferred_batch_size: [ 8, 16 ]
max_queue_delay_microseconds: 1000
}
preferred_batch_size— batch sizes the scheduler prefers to form (often a power of two the engine is tuned for).max_queue_delay_microseconds— the most time a request will wait to be batched. This is the core latency/throughput knob: bigger delay → fuller batches → more throughput but more tail latency.1000µs = 1 ms.- Optional
preserve_ordering, andpriority_levelsfor QoS.
The mental model: one batch in, one batch out, everyone waits for the slowest member. That is fine when all members do equal work. It is a disaster for generation, where one request might emit 5 tokens and another 500.
In-flight (continuous) batching (for LLMs)
LLM decoding is iterative: each forward pass produces one token per active sequence, then loops. In-flight batching (a.k.a. continuous or iteration-level batching) exploits this. Instead of freezing a batch for its whole lifetime, the scheduler operates per decoding iteration:
- Finished sequences leave the batch immediately and return to the client.
- Newly arrived requests join the running batch at the next iteration, filling the freed slots.
- The batch composition changes every step — the GPU is never idle waiting for the slowest sequence.
Paired with a paged KV cache (the attention key/value cache stored in fixed-size blocks, like OS virtual memory pages), this eliminates the memory fragmentation and rigid padding that kill naive LLM batching. This is exactly the vLLM PagedAttention idea; the TensorRT-LLM backend implements the same class of technique with NVIDIA-optimized kernels.
In the TensorRT-LLM backend you turn it on in the tensorrt_llm model’s config:
parameters: { key: "gpt_model_type" value: { string_value: "inflight_fused_batching" } }
parameters: { key: "batching_strategy" value: { string_value: "inflight_fused_batching" } }
parameters: { key: "kv_cache_free_gpu_mem_fraction" value: { string_value: "0.9" } }
The engine itself must be built with paged KV cache (--kv_cache_type paged at engine-build time). Get this wrong — build a static-batch engine, or leave batching_strategy as v1 — and you have thrown away the entire point of using TensorRT-LLM.
Instance groups and concurrent execution
Batching fills a single GPU pass. Instance groups decide how many independent passes can be in flight at once, and where.
instance_group [
{
count: 2
kind: KIND_GPU
gpus: [ 0 ]
}
]
count— number of loaded copies (instances) of the model.kind—KIND_GPU,KIND_CPU, orKIND_MODEL(let the backend decide device placement — used by the vLLM and TensorRT-LLM backends).gpus— which physical GPUs to place instances on.
Two instances on one GPU means Triton can execute two requests concurrently on that GPU (overlapping compute and memory transfer via CUDA streams), improving utilization when a single request under-fills the device. Instances across multiple GPUs give you data-parallel scale-out of the same model.
Combining knobs:
- Dynamic batching + multiple instances — each instance has its own batch scheduler; requests spread across instances, each forms batches. Great for high-throughput fixed-shape models.
- For LLMs, you usually do not stack many small instances. One instance owns the GPU (or several GPUs via
tensor_parallel_size/world_size) and in-flight batching handles concurrency internally. Multiple TensorRT-LLM instances only make sense across separate GPU sets.
# Spread three instances across two GPUs
instance_group [
{ count: 1 kind: KIND_GPU gpus: [ 0 ] },
{ count: 2 kind: KIND_GPU gpus: [ 1 ] }
]
Ensembles and Business Logic Scripting (BLS)
Real inference is a pipeline: tokenize → run model → detokenize; or embed → search → rerank. Triton gives you two ways to compose models server-side so the client makes one call.
Ensembles (declarative DAG)
An ensemble is a model with platform: "ensemble" and no code — just a config describing how tensors flow between other models. Triton executes the graph internally, passing tensors in GPU/shared memory without extra network hops.
name: "llm_pipeline"
platform: "ensemble"
max_batch_size: 8
input [ { name: "text_input" data_type: TYPE_STRING dims: [ 1 ] } ]
output [ { name: "text_output" data_type: TYPE_STRING dims: [ 1 ] } ]
ensemble_scheduling {
step [
{
model_name: "preprocessing"
model_version: -1
input_map { key: "QUERY" value: "text_input" }
output_map { key: "input_ids" value: "ids" }
},
{
model_name: "tensorrt_llm"
model_version: -1
input_map { key: "input_ids" value: "ids" }
output_map { key: "output_ids" value: "gen_ids" }
},
{
model_name: "postprocessing"
model_version: -1
input_map { key: "output_ids" value: "gen_ids" }
output_map { key: "OUTPUT" value: "text_output" }
}
]
}
This is exactly the canonical TensorRT-LLM layout: a preprocessing Python model (string → input_ids), the tensorrt_llm engine model, and a postprocessing Python model (output_ids → string), stitched by an ensemble.
Ensembles are static graphs. They cannot express loops or data-dependent branching.
Business Logic Scripting (BLS)
When you need conditionals, loops, or calling model B based on model A’s output, use BLS: a Python-backend model that issues inference requests to other Triton models from inside its execute():
import triton_python_backend_utils as pb_utils
class TritonPythonModel:
def execute(self, requests):
responses = []
for request in requests:
prompt = pb_utils.get_input_tensor_by_name(request, "text_input")
# Call the tokenizer model
tok = pb_utils.InferenceRequest(
model_name="preprocessing",
requested_output_names=["input_ids"],
inputs=[prompt],
)
ids = tok.exec().output_tensors()[0]
# ... branch on content, loop, call the LLM, etc.
responses.append(pb_utils.InferenceResponse(output_tensors=[...]))
return responses
For LLMs, the TensorRT-LLM backend ships a tensorrt_llm_bls model as an alternative to the ensemble — same pipeline, but expressed in Python so you can add guardrails, retries, or multi-model routing. Rule of thumb: ensemble for a fixed DAG, BLS when logic depends on runtime data.
HTTP/gRPC endpoints and metrics
Endpoints (KServe v2 / “predict” protocol)
- HTTP/REST on
:8000, gRPC on:8001. - Inference:
POST /v2/models/{model}/infer(and/versions/{v}/infer). - Health/readiness:
GET /v2/health/ready,/v2/health/live. - Metadata:
GET /v2/models/{model}, and repository/config introspection. - LLM convenience: the generate endpoint
POST /v2/models/{model}/generate(and/generate_streamfor token streaming with decoupled models).
A generic infer call:
curl -s localhost:8000/v2/models/text_classifier/infer -d '{
"inputs": [
{ "name": "input_ids", "shape": [1, 128], "datatype": "INT64",
"data": [ 101, 2054, 2003, ... ] }
]
}'
An LLM generate call (vLLM or TensorRT-LLM ensemble):
curl -s -X POST localhost:8000/v2/models/vllm_model/generate -d '{
"text_input": "What is Triton Inference Server?",
"parameters": { "stream": false, "temperature": 0, "max_tokens": 128 }
}'
Streaming token-by-token needs a decoupled model — one that returns many responses per request — declared with model_transaction_policy { decoupled: true }, and is consumed over gRPC streaming or /generate_stream.
Metrics
Triton exposes Prometheus metrics at :8002/metrics (curl localhost:8002/metrics). Core series:
| Metric | Meaning |
|---|---|
nv_inference_request_success / _failure | Request counts. |
nv_inference_count | Inferences performed (includes batching effects). |
nv_inference_queue_duration_us | Time requests spend queued — your batching-pressure signal. |
nv_inference_compute_infer_duration_us | Actual model compute time. |
nv_inference_compute_input_duration_us / _output_ | Tensor marshalling time. |
nv_gpu_utilization, nv_gpu_memory_used_bytes | Per-GPU device metrics. |
The TensorRT-LLM backend adds custom metrics for KV cache block usage and in-flight batching (active/scheduled request counts) via Triton’s custom-metrics API. Watch queue duration and KV-cache utilization together: rising queue time with KV cache near 100% means you are memory-bound and should raise kv_cache_free_gpu_mem_fraction, shorten max sequence length, or scale out.
For LLM-aware benchmarking, use GenAI-Perf (part of Perf Analyzer), which reports LLM-specific numbers: time to first token (TTFT), inter-token latency (ITL), output tokens/sec, and request throughput — the metrics that actually matter for chat workloads.
Fully worked example: an ONNX classifier with batching + concurrency
This is a complete, runnable non-LLM deployment. (The LLM path via TensorRT-LLM is shown right after — it uses the ensemble above.)
Repository layout
/models/
└── sentiment/
├── config.pbtxt
└── 1/
└── model.onnx
config.pbtxt
name: "sentiment"
backend: "onnxruntime"
max_batch_size: 32
input [
{
name: "input_ids"
data_type: TYPE_INT64
dims: [ 128 ]
},
{
name: "attention_mask"
data_type: TYPE_INT64
dims: [ 128 ]
}
]
output [
{
name: "logits"
data_type: TYPE_FP32
dims: [ 2 ]
}
]
dynamic_batching {
preferred_batch_size: [ 8, 16, 32 ]
max_queue_delay_microseconds: 2000
}
instance_group [
{
count: 2
kind: KIND_GPU
gpus: [ 0 ]
}
]
version_policy { latest { num_versions: 1 } }
This serves the ONNX model with up-to-32 dynamic batches (waiting at most 2 ms to fill one) and two concurrent GPU instances.
Launch the server (Docker)
docker run --gpus all --rm -it \
-p 8000:8000 -p 8001:8001 -p 8002:8002 \
--shm-size=1G --ulimit memlock=-1 --ulimit stack=67108864 \
-v /models:/models \
nvcr.io/nvidia/tritonserver:24.08-py3 \
tritonserver --model-repository=/models --strict-model-config=true
You should see sentiment reported READY in the startup table, and GET localhost:8000/v2/health/ready returns 200.
Call it
curl -s localhost:8000/v2/models/sentiment/infer -d '{
"inputs": [
{ "name": "input_ids", "shape": [1,128], "datatype": "INT64", "data": [101, 2023, 2003, 6659, 999, 102, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
"attention_mask": [] }
]
}'
The LLM path (TensorRT-LLM), in brief
For an LLM you do not hand-write a decode loop. You:
- Convert the HF checkpoint to TensorRT-LLM format and build an engine with paged KV cache and in-flight batching enabled (
trtllm-build ... --kv_cache_type paged). - Assemble the ensemble repository:
preprocessing/(tokenizer, Python backend),tensorrt_llm/(the engine +config.pbtxtwithbatching_strategy: inflight_fused_batching,engine_dir,kv_cache_free_gpu_mem_fraction,decoupled_mode),postprocessing/(detokenizer), andensemble/. NVIDIA’sfill_template.pypopulates these configs from the engine. - Launch across GPUs:
python3 /app/scripts/launch_triton_server.py \
--world_size=2 --model_repo=/models/trtllm_repo
- Call
POST /v2/models/ensemble/generate(or stream via/generate_streamwithdecoupled_mode: true).
The vLLM backend is the lower-effort alternative: skip steps 1–2, drop a model.json ({"model": "...", "tensor_parallel_size": 1, "gpu_memory_utilization": 0.9}) and a config.pbtxt with backend: "vllm" and model_transaction_policy { decoupled: true }, and launch the -vllm-python-py3 image.
Triton + TensorRT-LLM vs vLLM vs TGI
| Dimension | Triton + TensorRT-LLM | vLLM (standalone) | TGI (Text Generation Inference) |
|---|---|---|---|
| Primary goal | Multi-model serving fleet + fastest NVIDIA LLM path | Fast, simple LLM serving | HuggingFace-native LLM serving |
| Continuous batching | Yes (in-flight, fused) | Yes (native) | Yes (native) |
| KV cache | Paged | PagedAttention (originator) | Paged |
| Setup effort | High — engine build + ensemble wiring | Low — pip install, point at HF model | Low–medium — Docker + model id |
| Peak throughput on NVIDIA | Highest (tuned TensorRT kernels) | Very high | High |
| Hardware | NVIDIA only | NVIDIA-first (some others) | NVIDIA-first (some others) |
| Non-LLM models | Yes — same server hosts ONNX/PyTorch/TensorRT | No | No |
| Ops surface | One server, unified metrics, ensembles/BLS | Simple, LLM-scoped | Simple, LLM-scoped |
| Streaming | Decoupled + /generate_stream | OpenAI-compatible SSE | SSE, OpenAI-compatible |
| Best when | You run many models and/or want max NVIDIA LLM perf with unified ops | You want the least-effort fast LLM server | You are all-in on the HF stack |
Honest summary: for a single LLM, vLLM or TGI is faster to stand up and gets you 90% of the throughput with 10% of the effort. Triton + TensorRT-LLM wins when you need a heterogeneous model fleet under one runtime, or when you have squeezed everything else and need the last increment of GPU efficiency and are willing to pay the engine-build tax. Note also that Triton can host the vLLM backend, giving you vLLM’s ergonomics inside Triton’s ops framework — a common middle ground.
Failure modes and pitfalls
- Wrong backend for LLMs. Serving generation through ONNX/PyTorch +
dynamic_batchingproduces terrible throughput and head-of-line blocking. LLMs require the TensorRT-LLM or vLLM backend with in-flight batching. This is the number-one mistake. - Static-batch TensorRT-LLM engine. Building the engine without paged KV cache, or leaving
batching_strategy/gpt_model_typeatv1, silently disables continuous batching. You paid the conversion cost and got none of the benefit. - Misconfigured dynamic batching.
max_queue_delay_microsecondstoo high → latency spikes; too low → tiny batches and idle GPU.preferred_batch_sizemismatched to what the engine was tuned for → padding waste. Tune against GenAI-Perf, not by guessing. max_batch_sizevs shape confusion. Withmax_batch_size > 0the batch dim is implicit — listing it explicitly indimsdouble-counts it and breaks shape checks. Setmax_batch_size: 0only for models whose first dim is not a batch dimension.- KV-cache OOM.
kv_cache_free_gpu_mem_fractiontoo aggressive (or too many concurrent LLM instances) OOMs under load; too conservative wastes capacity. Watch KV-cache utilization metrics. - Version / container mismatches. The TensorRT-LLM engine, the tensorrtllm_backend, and the Triton container are a matched set. Building an engine with one TensorRT-LLM version and loading it in a mismatched Triton image fails to load or crashes. Pin versions together.
- Model name / directory mismatch.
nameinconfig.pbtxtdisagreeing with the directory, or a non-integer version folder, makes the model silently not load. Read the startup READY/UNAVAILABLE table. - Conversion complexity underestimated. The TensorRT-LLM path (convert → build → template the ensemble → launch across
world_size) is genuinely involved and model-specific. Budget for it; do not promise a one-day LLM deploy on TensorRT-LLM. - Forgetting
--shm-size/ decoupled streaming. Python-backend ensembles need adequate shared memory; streaming needsdecoupled: trueor you get one blob at the end instead of tokens.
Production checklist — what an interviewer probes
- “You have five models in four frameworks sharing two GPUs. Design it.” — Expect: one Triton server, one model repository, per-model
backendandinstance_group, dynamic batching on the fixed-shape models, an LLM on the TensorRT-LLM/vLLM backend. Tests whether you understand Triton’s core value proposition. - “Dynamic vs in-flight batching — when each, and why?” — Fixed-shape/equal-work → dynamic; autoregressive LLM → in-flight/continuous, because variable output length makes static batches stall on the slowest sequence. Bonus for paged KV cache.
- “Walk me through the TensorRT-LLM deployment.” — Convert +
trtllm-buildwith paged KV cache → ensemble (pre/trtllm/post) →fill_template.py→launch_triton_server.py --world_size→/generate. Honesty about the conversion complexity scores points. - “How do you tune the latency/throughput tradeoff?” —
max_queue_delay_microsecondsandpreferred_batch_sizefor dynamic; instance count for concurrency;kv_cache_free_gpu_mem_fractionand max sequence length for LLMs — validated with GenAI-Perf (TTFT, ITL, tokens/s). - “What do you monitor, and what does a bad number mean?” —
nv_inference_queue_duration_us(batching pressure), compute duration,nv_gpu_utilization, KV-cache utilization. Rising queue + full KV cache = memory-bound; scale out or trim context. - “Ensemble vs BLS?” — Ensemble for a static DAG (tokenize→infer→detokenize); BLS when the pipeline branches or loops on runtime data.
- “When would you NOT use Triton?” — A single LLM where vLLM/TGI is dramatically simpler; no heterogeneous fleet; team without NVIDIA-stack depth. Knowing when the simpler tool wins signals seniority.
- “How do you roll out a new model version safely?” — Version subdirectories +
version_policy, load the new version alongside the old, shift traffic, and hot-unload — no server restart.
Further reading
- Triton model configuration (config.pbtxt reference): https://docs.nvidia.com/deeplearning/triton-inference-server/user-guide/docs/user_guide/model_configuration.html
- Triton model repository layout: https://github.com/triton-inference-server/server/blob/main/docs/user_guide/model_repository.md
- Dynamic batching & concurrent model execution (conceptual guide): https://docs.nvidia.com/deeplearning/triton-inference-server/user-guide/docs/tutorials/Conceptual_Guide/Part_2-improving_resource_utilization/README.html
- TensorRT-LLM backend (in-flight batching, paged KV cache, ensemble): https://github.com/triton-inference-server/tensorrtllm_backend
- TensorRT-LLM backend docs: https://docs.nvidia.com/deeplearning/triton-inference-server/user-guide/docs/tensorrtllm_backend/README.html
- vLLM backend for Triton: https://github.com/triton-inference-server/vllm_backend
- Deploying a vLLM model in Triton (tutorial): https://docs.nvidia.com/deeplearning/triton-inference-server/user-guide/docs/tutorials/Quick_Deploy/vLLM/README.html
- Python backend (custom logic + BLS): https://docs.nvidia.com/deeplearning/triton-inference-server/user-guide/docs/python_backend/README.html
- Metrics reference: https://github.com/triton-inference-server/server/blob/main/docs/user_guide/metrics.md
- GenAI-Perf (LLM benchmarking): https://docs.nvidia.com/deeplearning/triton-inference-server/user-guide/docs/perf_analyzer/genai-perf/README.html
- TensorRT-LLM: https://github.com/NVIDIA/TensorRT-LLM