Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

LLM Serving & Inference: Complete Learning Repository

License: MIT Python 3.9+ FastAPI Kubernetes

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:

  1. Each topic is self-contained with code, docs, and exercises
  2. Build incrementally - each topic builds on previous concepts
  3. Learn by doing - run code, modify it, break it, fix it
  4. 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

  1. Start with LEARNING_PATH.md - Understand the structure
  2. Read the fundamentals - docs/llm_inference_fundamentals.md
  3. Work through topics in order - Each numbered topic builds on the last
  4. Experiment and modify - Don’t just run code, change it and learn
  5. Read the code comments - They explain the “why” not just the “what”
  6. Check solutions - See EXERCISE_SOLUTIONS.md for exercise answers
  7. 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

  1. New to this? → Read HOW_TO_START.md for step-by-step instructions
  2. Want overview? → Read LEARNING_PATH.md to see all topics
  3. Ready to code? → Start with 01_basic_serving/
  4. Need help? → Check EXERCISE_SOLUTIONS.md for exercise answers
  5. Interview prep? → Review INTERVIEW_QA.md for 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

FilePurpose
HOW_TO_START.md👉 START HERE - Step-by-step guide to begin learning
LEARNING_PATH.mdOverview of all topics and learning approach
README.mdRepository 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

  1. LLM Inference Fundamentals - How LLMs work
  2. Basic Serving - FastAPI + HuggingFace
  3. Containerization - Docker
  4. Kubernetes - K8s deployment
  5. Load Testing - Performance measurement
  6. vLLM Serving - High-performance serving
  7. Autoscaling - Scale automatically
  8. Canary Deployments - Safe rollouts
  9. Monitoring - Metrics and dashboards
  10. Model Versioning - Version management
  11. Drift Detection - Detect issues
  12. Triton - Multi-model serving

💡 Learning Approach

  1. Read the documentation
  2. Study the code
  3. Run the examples
  4. Modify and experiment
  5. 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 used
  • app.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:

  1. 01_basic_serving ✅ (You just did this!)
  2. 02_docker - Containerize your app
  3. 03_kubernetes - Deploy to K8s
  4. 04_load_testing - Test performance
  5. 05_vllm_serving - High-performance serving
  6. 06_autoscaling - Scale automatically
  7. 07_canary_deployments - Safe rollouts
  8. 08_monitoring - Track metrics
  9. 09_model_versioning - Manage versions
  10. 10_drift_detection - Detect issues
  11. 11_triton - Multi-model serving

For each topic:

  1. Read the README.md
  2. Study the code
  3. Run the examples
  4. Modify and experiment
  5. 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.txt again

📊 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

  1. Right now: Complete Steps 1-5 above
  2. Today: Read through 01_basic_serving/README.md and understand the code
  3. This week: Work through topics 2-4 (Docker, K8s, Load Testing)
  4. This month: Complete topics 5-8 (vLLM, Scaling, Monitoring)
  5. Ongoing: Topics 9-11 (Advanced production topics)

❓ Questions?

If you get stuck:

  1. Check the README.md in each topic
  2. Read error messages carefully
  3. Check the docs/ directory for detailed explanations
  4. 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:

  1. Read the documentation - Understand the concepts
  2. Study the code - See how it’s implemented
  3. Run the examples - Get hands-on experience
  4. Modify and experiment - Break things, fix them, learn
  5. 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

  1. Open Grafana: http://localhost:3000
  2. Login: admin/admin
  3. Add data source: Prometheus (http://prometheus:9090)
  4. 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 requests
  • llm_request_duration_seconds - Request latency
  • llm_active_requests - Currently processing
  • llm_tokens_generated_total - Tokens generated

System Metrics

  • llm_gpu_utilization_percent - GPU usage
  • llm_gpu_memory_used_bytes - GPU memory
  • llm_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 /metrics endpoint 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:

  1. Start: Read HOW_TO_START.md
  2. Fundamentals: Read docs/llm_inference_fundamentals.md
  3. Basic Serving: Work through 01_basic_serving/
  4. Containerize: Learn Docker in 02_docker/
  5. Deploy: Deploy to K8s with 03_kubernetes/
  6. Test: Measure performance with 04_load_testing/
  7. Optimize: Use vLLM in 05_vllm_serving/
  8. Scale: Set up autoscaling in 06_autoscaling/
  9. Monitor: Add monitoring in 08_monitoring/
  10. Deploy Safely: Learn canary in 09_canary_deployments/
  11. Version: Manage versions in 10_model_versioning/
  12. Detect Issues: Add drift detection in 11_drift_detection/
  13. 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

  1. LLM Inference Fundamentals
  2. Model Serving
  3. Performance Optimization
  4. Kubernetes & Deployment
  5. Monitoring & Observability
  6. Production Best Practices
  7. System Design

LLM Inference Fundamentals

Q1: Explain how LLM inference works step-by-step.

Answer:

  1. Tokenization: Input text is converted to token IDs using the model’s tokenizer
  2. Embedding: Token IDs are converted to dense vectors (embeddings)
  3. Forward Pass:
    • Input passes through transformer layers
    • Each layer applies self-attention and feed-forward networks
    • Attention mechanism computes relationships between tokens
  4. Output Projection: Final layer projects to vocabulary size
  5. Sampling: Next token is sampled from probability distribution
  6. 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:

AspectTrainingInference
ModeTraining mode (gradients computed)Evaluation mode (no gradients)
BatchLarge batches (32-128)Small batches or single requests
MemoryStores activations for backpropOnly forward pass needed
SpeedSlower (backprop overhead)Faster (forward only)
OptimizationGradient descentSampling/decoding strategies
HardwareMultiple GPUs commonSingle 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:

  1. Request Validation: Validate inputs (prompt length, parameters)
  2. Model Loading: Load model once at startup (not per request)
  3. Tokenization: Convert text to tokens
  4. Generation: Run inference with parameters
  5. 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:

FeatureHuggingFacevLLM
BatchingStatic batchingContinuous batching
Throughput1-5 req/s50-200 req/s
MemoryStandardPagedAttention (efficient)
GPU Utilization20-40%80-95%
Ease of UseVery easyModerate
FlexibilityHighModerate

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:

  1. Wait for batch to fill (e.g., 8 requests)
  2. Process entire batch
  3. Wait for all to complete
  4. Start next batch

Continuous Batching:

  1. Start processing batch
  2. Add new requests as they arrive
  3. Remove completed requests
  4. Continue processing remaining requests
  5. 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:

  1. Divide KV cache into fixed-size “pages” (like OS memory pages)
  2. Allocate pages on-demand as tokens are generated
  3. Free pages when sequences complete
  4. 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:

  1. Batching:

    • Larger batches → Higher throughput, higher latency
    • Smaller batches → Lower latency, lower throughput
  2. Model Size:

    • Larger model → Higher quality, higher latency
    • Smaller model → Lower latency, potentially lower quality
  3. Quantization:

    • Lower precision → Faster, lower memory, potential accuracy loss
    • Higher precision → Slower, more memory, better accuracy
  4. 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:

  1. HPA checks metrics every 15 seconds (default)
  2. Compares current metric value to target
  3. Calculates desired number of replicas
  4. Updates deployment replica count
  5. 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:

  1. Deploy canary: Deploy new version alongside stable
  2. Route traffic: Split traffic (e.g., 90% stable, 10% canary)
  3. Monitor: Compare metrics (latency, errors, quality)
  4. Gradual increase: If good, increase canary traffic (25%, 50%, 100%)
  5. 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:

  1. Performance: Latency, throughput, errors
  2. Resource: GPU, CPU, memory usage
  3. Model: Version performance comparison

Q15: How would you detect model drift in production?

Answer:

Types of Drift:

  1. Data drift: Input distribution changes
  2. Concept drift: Input-output relationship changes
  3. 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:

  1. Auto-scaling: HPA should scale up automatically
  2. Load balancing: Distribute across pods
  3. Queue management: Queue requests if needed
  4. Rate limiting: Protect backend from overload

Prevention:

  1. Capacity planning: Understand max capacity
  2. Load testing: Test under expected load
  3. Auto-scaling: Configure HPA properly
  4. Circuit breakers: Prevent cascade failures

Monitoring:

  • Watch pod count
  • Monitor latency (P95, P99)
  • Check error rates
  • GPU utilization

If Overwhelmed:

  1. Degrade gracefully: Return cached responses
  2. Rate limit: Reject excess requests
  3. Scale manually: If auto-scaling insufficient
  4. 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:

  1. Semantic versioning: v1.0.0, v1.1.0, v2.0.0
  2. Model registry: Store models with metadata
  3. 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:

  1. Compute: GPU/CPU instances
  2. Storage: Model storage
  3. Network: Data transfer
  4. 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

  1. Be specific: Use numbers and examples
  2. Show trade-offs: Understand pros/cons
  3. Think system-wide: Consider all components
  4. Ask clarifying questions: Understand requirements
  5. Draw diagrams: Visualize architecture
  6. Discuss monitoring: Always mention observability
  7. 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:

  1. Open Grafana
  2. Add Prometheus data source
  3. Create new dashboard
  4. 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

  1. Test locally first: Always test changes locally before deploying
  2. Monitor metrics: Watch metrics during exercises
  3. Read errors: Error messages often contain solutions
  4. Experiment: Try different values and see what happens
  5. 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:

  1. Downloading weights (if not cached) - These are the learned parameters
  2. Loading into memory - CPU RAM or GPU VRAM
  3. Initializing tokenizer - Converts text ↔ tokens
  4. 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

  1. Tokenize input prompt
  2. Run forward pass through model (autoregressive)
  3. Sample next token
  4. Repeat until max_length or stop token
  5. 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 text
  • prompt: Your input (echoed back)
  • num_tokens: Number of tokens generated
  • latency_ms: Time taken in milliseconds
  • model_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

  1. Change the model: Try different HuggingFace models (gpt2, distilgpt2, etc.)
  2. Modify parameters: Experiment with temperature, top_p, max_length
  3. Add logging: Log every request with timing information
  4. Error handling: Add try/except for different error cases
  5. 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:

  1. 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.
  2. 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.

dtypebytes/paramTypical use
FP324Rarely for inference; training reference
FP16 / BF162Standard inference precision on GPU
INT81Quantized inference (small quality hit)
INT4 / NF4~0.5Aggressive 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.). Use tokenizer.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 a pad_token — set tokenizer.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:

PrefillDecode
Tokens processed per passwhole prompt (parallel)1
Bottleneckcompute (FLOPs)memory bandwidth
Grows withprompt lengthoutput length
DeterminesTTFTTPOT / inter-token latency
GPU utilizationhighlow (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 generate holds 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:

ParamEffectNote
max_new_tokenshard cap on output lengthThe #1 latency and cost lever — decode time is ~linear in it. Always set it.
do_samplegreedy (False) vs sampling (True)With do_sample=False, temperature/top_p are ignored and you get deterministic output.
temperatureflattens (>1) or sharpens (<1) the distribution0 is not literally valid for sampling; use greedy for determinism.
top_p (nucleus)sample only from the smallest set of tokens summing to prob pCommon: 0.90.95.
top_ksample only from the k highest-prob tokensAlternative/complement to top_p.
repetition_penaltydiscourage repeating tokensHelps loops; tune carefully.
stop / eos_token_idstop conditionsWrong 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.

MetricDefinitionFormula
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 latencyRequest in -> full response out.(\text{E2E} = \text{TTFT} + \text{generation time})
ThroughputTokens 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 strategyHow it worksWeakness
Static batchingCollect (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 batchingServer 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, cap max_new_tokens and 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 you await it 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; set pad_token = eos_token if absent.
  • FP32 by accident. Forgetting torch_dtype loads in FP32 and doubles weight memory — an instant OOM on models that would fit fine in BF16.
  • Unbounded generation. No max_new_tokens and 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 (/healthz gating 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 isBatchingBest forCovered in
Raw Transformers + FastAPIHand-rolled server (this chapter)None (DIY)Learning, prototypes, custom logicThis chapter
vLLMHigh-throughput OSS inference engineContinuous + PagedAttentionThroughput-critical OSS serving; OpenAI-compatible APIChapter 5
TGI (Text Generation Inference)Hugging Face’s production server (Rust + Python)Continuous, tensor-parallel shardingHF ecosystem, Inference Endpointsreferenced Ch. 5
Triton Inference ServerNVIDIA multi-framework serverDynamic batching; pairs with TensorRT-LLM / vLLM backendsMulti-model, mixed workloads, tight NVIDIA stackChapter 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

  1. “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.
  2. “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.
  3. “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.
  4. “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.
  5. “What’s your OOM story?” Expected: memory math up front; cap max_new_tokens and context; limit concurrency; quantize; paged KV; monitor KV-cache utilization, not just GPU memory.
  6. “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.
  7. “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.
  8. “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


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:

  1. Builder stage: Install dependencies, download models
  2. 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

  1. Use slim base images: python:3.9-slim instead of python:3.9
  2. Multi-stage builds: Don’t include build tools in final image
  3. Remove cache: pip install --no-cache-dir
  4. Combine RUN commands: Fewer layers = smaller image

Speed Up Builds

  1. Layer caching: Order Dockerfile commands by change frequency
  2. Build cache: Use --cache-from for CI/CD
  3. 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

  1. Use .dockerignore: Exclude unnecessary files
  2. Tag images: Use semantic versioning
  3. Health checks: Add HEALTHCHECK instruction
  4. Non-root user: Run as non-root for security
  5. Resource limits: Set memory/CPU limits
  6. Logging: Configure proper logging

Exercises

  1. Build basic image: Create Dockerfile for basic serving
  2. Optimize image: Reduce image size by 50%
  3. Multi-stage build: Create optimized multi-stage Dockerfile
  4. GPU support: Add CUDA support to Dockerfile
  5. 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 available in container
  • Solution: Install nvidia-docker, use --gpus all flag

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 insufficient at 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:

FlavorContainsSize (approx)Use it for
baseCUDA runtime libs minimum~200 MBRare; you usually need more
runtimeCUDA runtime + math libs (cuBLAS), optionally cuDNN~2–3 GBFinal runtime stage of an inference server
develEverything in runtime + nvcc, headers, static libs~5–7 GBBuild 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-smi prints Driver Version and CUDA 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, not 12).

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:

  1. Base image
  2. System packages (apt-get)
  3. Dependency manifests (requirements.txt / pyproject.toml) and pip install
  4. 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

DimensionBake into imageMount volumeDownload at startup
Image sizeHuge (15–150 GB)SmallSmallest
Cold startFast (already present)Fast (local mount)Slow (multi-GB download)
Self-containedYes (air-gap ok)No (needs volume)No (needs network + token)
Swap modelsRebuild imageChange mount / envChange env var
Registry cost / pushHighLowLow
ReproducibilityHighest (weights pinned)Depends on volume contentsDepends on HF tag/revision
Best forAir-gapped, small, regulatedFixed nodes, shared FSDev, 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 mismatchCUDA 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 invisibletorch.cuda.is_available() is False, no nvidia-smi in container. Toolkit not installed, --gpus omitted, or runtime not registered. Test with docker run --gpus all nvidia/cuda:...-base nvidia-smi.
  • Giant images — shipped the devel base, apt lists left behind, weights baked in, or pip cache 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_HOME points at an ephemeral dir. Mount a named volume at the cache path.
  • Secrets in layersENV HF_TOKEN=... or COPY .env persists 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/shm too small — default 64 MB causes NCCL/tensor-parallel hangs and Bus error. Use --ipc=host or --shm-size.
  • :latest everywhere — non-reproducible builds and surprise upgrades. Pin base by digest, deps by version, your image by semver.
  • Signals ignored — shell-form CMD runs under /bin/sh which doesn’t forward SIGTERM; use exec-form ENTRYPOINT/CMD so shutdown is graceful and requests drain.

Tools and options comparison

OptionWhat it isWhen to reach for it
nvidia/cuda:*-runtimeSlim CUDA userspace baseFinal stage of a custom server
nvidia/cuda:*-develCUDA + nvcc + headersBuild stage compiling kernels
vllm/vllm-openaiOfficial vLLM OpenAI server imageFast path to production vLLM
ghcr.io/.../text-generation-inferenceOfficial HF TGI imageHF-ecosystem serving, gated models
NVIDIA Container ToolkitHost runtime that injects GPUsRequired for any GPU container
BuildKit / docker buildxModern builder: cache mounts, secretsEvery build (faster, safer)
dive / docker historyInspect layers & sizeHunting image bloat
docker scout / trivyImage vulnerability scanningCI gate before push

Production checklist — what an interviewer probes

  1. “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 with docker run --gpus all ... nvidia-smi.
  2. “runtime vs devel base image — which do you ship?” — Build in devel, ship on runtime via multi-stage. Shipping devel is a multi-GB mistake.
  3. “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.
  4. “How do you keep the image small?” — Multi-stage, --no-install-recommends, clean apt lists, .dockerignore, don’t bake weights, BuildKit cache mounts.
  5. “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.
  6. “How do secrets and config get in?” — Runtime env / orchestrator secrets and BuildKit --secret, never ENV/COPY .env (persists in layer history).
  7. “Non-root, healthcheck, signals?” — Drop to an unprivileged UID, healthcheck with a long start-period for model load, exec-form entrypoint for graceful SIGTERM draining.
  8. “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/cuda image 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

  1. Always set resource limits: Prevent resource exhaustion
  2. Use health checks: Enable automatic recovery
  3. Use ConfigMaps: Don’t hardcode configuration
  4. Tag images: Use semantic versioning
  5. Test locally: Use minikube/kind before production
  6. Monitor: Set up logging and metrics

Exercises

  1. Basic deployment: Deploy app to local K8s
  2. Health checks: Add liveness/readiness probes
  3. Resource limits: Set appropriate CPU/memory
  4. ConfigMap: Move config to ConfigMap
  5. Scaling: Scale to 3 replicas
  6. 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:

  1. 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.”

  2. 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.

  3. 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.

  4. 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 (ClusterIP for in-cluster, LoadBalancer for 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/gpu and burst to 1. Practically you only ever specify it under limits (Kubernetes copies it to requests for you).
  • GPUs are never overcommitted. Two pods cannot each hold nvidia.com/gpu: 1 on a node that advertises one GPU — the second stays Pending. 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:

MechanismIsolationHow it splitsAdvertised asUse when
MIG (Multi-Instance GPU)Hardware — separate memory + compute slicesPhysically partitions an A100/H100 into up to 7 instancesnvidia.com/mig-1g.10gb etc. (or relabeled nvidia.com/gpu)Strong isolation, predictable QoS, multi-tenant
Time-slicingNone — processes share memory, take turns on the SMsPlugin advertises N “replicas” of each GPU; the driver context-switchesnvidia.com/gpu (inflated count)Bursty/low-QPS dev workloads that tolerate contention
MPS (Multi-Process Service)Soft — shared memory, concurrent kernels with optional compute capsA daemon runs many clients’ kernels concurrentlynvidia.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 like cloud.google.com/gke-accelerator=nvidia-l4, and the GPU Operator / NFD add labels such as nvidia.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:

ProbeQuestion it answersFailure 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 periodSeconds and a failureThreshold of 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/gpu request == 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 clean Pending. 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:

ApproachHowProsCons
Baked into imageCOPY weights into the Docker imageSimplest; immutable; no runtime fetchEnormous images (30–100 GB+), slow pulls, registry bloat, rebuild to change weights
initContainer downloadAn initContainer pulls weights from object storage (S3/GCS) into a shared emptyDirSmall runtime image; weights versioned in bucketRe-downloads on every cold start unless cached; needs credentials
PVC (shared/RWX)Weights on a persistent volume (e.g. a network filesystem), mounted read-onlyDownload once, many pods share; fast pod startStorage class must support RWX or you pre-populate; network FS bandwidth can bottleneck concurrent loads
Node-local cache / model cacheCache 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 VRAMMore 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 RollingUpdate strategy with maxUnavailable / maxSurge controls how many pods are replaced at once. For GPU pods, maxSurge costs real extra GPUs — surging by 1 means the autoscaler must find another GPU node. Often you set maxSurge: 0, maxUnavailable: 1 to 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 (or maxUnavailable) 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 a preStop hook 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) via ServingRuntime. 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 RayService CRD. 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 Ingress resource 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-timeout and proxy-send-timeout to 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: ClientIP on 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 Unhealthy probe events, CrashLoopBackOff, and FailedScheduling (usually taint/selector or capacity). kubectl describe pod and kubectl get events are 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 topologySpreadConstraints so 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:

  • startupProbe budget = 10s * 60 = 600s. If your model loads in ~90s, this is generous headroom; shrink failureThreshold if you want faster crash detection, but never below your real worst-case load time.
  • The initContainer idempotency (.done sentinel) means a restarted pod on a node whose emptyDir survived (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 of emptyDir.
  • maxSurge: 0 trades a little capacity during rollout for not needing an extra GPU.
  • The preStop sleep + 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 → your nodeSelector/label is wrong (check exact label with kubectl 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 failed events at ~the same age → probe is killing the model mid-load; add/extend the startup probe.
  • OOMKilled in the container’s lastState → raise the memory limit (host RAM to stage weights).
  • initContainer errors (S3 auth, disk full on emptyDir sizeLimit) → 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

OptionWhat it isAutoscaling / scale-to-zeroBest forCost
Raw Deployment + ServiceYou write the manifestsHPA only (you wire it); no scale-to-zero out of the boxFull control, simple single-model services, learningHigh YAML/ops effort, most flexible
KServe (InferenceService)CRD over Knative/k8s, standard model protocolYes, incl. scale-to-zero + canaryPlatform teams wanting a model abstraction, many models, standardized rolloutHeavier install (Knative/Istio or raw-deploy mode), more concepts
Ray Serve (KubeRay)Distributed serving on Ray via RayServiceYes, Ray-native autoscalingMulti-model composition, distributed/multi-node tensor-parallel, complex pipelinesRay cluster to operate; overkill for one small model
Triton / NVIDIA NIM (+ NIM Operator)NVIDIA-optimized model servers/containersVia KServe/HPA integrationNVIDIA-standardized stacks, optimized/quantized engines, vendor supportVendor lock-in to NVIDIA images; excellent perf
KubeAILightweight k8s-native LLM operatorYes, incl. scale-from-zeroOpenAI-compatible serving without Istio/KnativeYounger 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 infinite CrashLoopBackOff. 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 pod shows 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 > 0 on a full GPU pool blocks the rollout waiting for GPUs that don’t exist. Use maxSurge: 0 or ensure headroom.
  • Ephemeral emptyDir cache re-downloads every reschedule. An emptyDir dies 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-local hostPath/CSI volume that survives pod churn.
  • Graceful shutdown too short. Default 30s terminationGracePeriodSeconds SIGKILLs pods mid-generation. Raise it above your longest generation and flip readiness first.

Production checklist — what an interviewer probes

  1. “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 * failureThreshold exceeds worst-case load, and suppress liveness until then.
  2. “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.
  3. “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.
  4. “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.
  5. “How do you upgrade without an outage?” — RollingUpdate with maxSurge: 0/maxUnavailable: 1, a PDB with minAvailable, graceful drain via terminationGracePeriodSeconds + preStop + readiness flip.
  6. “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.
  7. “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.
  8. “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

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

  1. Baseline: Single request, measure latency
  2. Ramp-up: Gradually increase load
  3. Sustained: Constant load for extended period
  4. Spike: Sudden increase in load
  5. 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

  1. CPU-bound: Model too large for CPU

    • Solution: Use GPU, smaller model, or quantization
  2. Memory-bound: Out of memory

    • Solution: Reduce batch size, use smaller model
  3. I/O-bound: Slow tokenization or network

    • Solution: Optimize tokenization, use faster network
  4. GPU underutilized: Not batching efficiently

    • Solution: Use continuous batching (vLLM)

Exercises

  1. Baseline Test: Measure single-request latency
  2. Ramp-up Test: Gradually increase from 1 to 50 users
  3. Compare Models: Test gpt2 vs distilgpt2 performance
  4. Find Bottleneck: Identify what limits throughput
  5. 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-prefill off.

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:

  1. Low load: GPU underutilized. Latency is flat and near-minimal. Throughput rises roughly linearly with concurrency.
  2. The knee: GPU compute (or KV-cache memory) saturates. Throughput flattens — you’ve hit the roofline. Latency starts climbing because requests now queue.
  3. 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_true measures 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 the e2e_true field, end - (t0 + sched_t), for reporting; the plain e2e from 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/sOutput tok/sTTFT p50 (ms)TTFT p95 (ms)TPOT p50 (ms)E2E p95 (ms)Avg in-flight (L=\lambda W)
55.01000427015320016
1010.02000559517360036
2020.040008821021440088
3029.85900180620286800200
4033.1660054024004114200470
5032.966001900900063410001350

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

ToolLoad modelMetrics reportedEndpointsBest forWatch out
vLLM benchmark_serving.py / vllm bench serveOpen-loop via --request-rate (inf = burst all --num-prompts at once); Poisson/--burstinessRequest throughput (req/s), Output token throughput (tok/s), Total token throughput, Mean/Median/P99 TTFT, TPOT, ITL, E2EOpenAI-compatible + native vLLM/TGI backendsPurpose-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-requestsTTFT, inter-token latency, E2E, output throughput per-request and aggregateMany providers (OpenAI, Anthropic, Together, Bedrock, Vertex, SageMaker, vLLM)Cross-provider apples-to-apples; the LLMPerf leaderboardConcurrency 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/p99OpenAI-compatible, Triton (TRT-LLM, vLLM), gRPC/HTTPDeep NVIDIA/Triton stacks; synthetic + custom datasets; rich exportsHeavier setup; Triton-centric defaults
LocustClosed-loop by default; constant_throughput ≈ openWhatever you instrument; built-in RPS + percentile table + web UIAny HTTP (write Python tasks)Custom flows, quick dashboards, mixed trafficPython workers can be the bottleneck; streaming/TTFT needs custom code
k6Open-loop (constant/ramping-arrival-rate) or closed (*-vus)RPS, latency percentiles, custom TrendsAny HTTP/gRPC (JS scripts)Honest open-loop SLA tests, CI gatingGo 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

  1. “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.
  2. “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.
  3. “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.
  4. “Apply Little’s Law here.” — ( L = \lambda W ). Given a rate and E2E latency, compute in-flight concurrency and compare to max-num-seqs to detect oversubscription; convert closed-loop concurrency to sustainable req/s.
  5. “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.
  6. “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.
  7. “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.
  8. “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

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

  1. Compare Performance: Run same model with HuggingFace vs vLLM, measure throughput
  2. Tune Parameters: Experiment with gpu_memory_utilization, max_model_len
  3. Test Continuous Batching: Send requests at different rates, observe GPU utilization
  4. 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:

  1. 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.

  2. 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.

  1. Weights: (8\text{B} \times 2\ \text{bytes} = 16\ \text{GB}).
  2. Budget: --gpu-memory-utilization 0.9 → vLLM may use (0.9 \times 80 = 72\ \text{GB}).
  3. Non-KV overhead: CUDA context, activations, CUDA graphs — call it ~2 GB.
  4. KV cache pool: (72 - 16 - 2 = 54\ \text{GB}).
  5. Per token: 128 KB (from above). Per block (16 tokens): (16 \times 128\ \text{KB} = 2\ \text{MB}).
  6. 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:

  1. Any sequence that emitted its EOS/stop this step is evicted immediately, and its KV blocks are freed.
  2. Waiting requests are admitted mid-flight to fill the vacated slots.
  3. 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.

FlagDefaultWhat it doesHow to tune
--gpu-memory-utilization0.9Fraction 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-seqs256 (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-tokensauto (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-lenfrom model configMax 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-size16Tokens per KV block.Rarely changed. Larger blocks = less overhead but more internal fragmentation.
--enable-prefix-caching / --no-enable-prefix-cachingon (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-prefillon (V1)Split prefills into chunks, co-schedule with decodes.Keep on; tune via max-num-batched-tokens.
--tensor-parallel-size (-tp)1Shard 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)1Split layers into stages across GPUs/nodes.Use to span multiple nodes or when TP alone can’t fit the model.
--quantizationnoneWeight/activation quant scheme (awq, gptq, fp8, bitsandbytes, …).Use to shrink weights → more KV room / smaller GPU. Costs some quality.
--kv-cache-dtypeautoStore KV cache in fp8 etc.fp8 ~halves KV memory → bigger batch/context; small accuracy cost.
--swap-space4 (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 togetherThe two levers that shape the batch.Co-tune: token budget caps work/step; seq budget caps concurrency.
--dtypeautoCompute dtype (bfloat16, float16).bfloat16 on Ampere+; matters for numerical stability.
--speculative-confignoneSpeculative 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.

SchemeBitsWhat’s quantizedWhen to useTradeoff
AWQ4-bitWeights only (activation-aware, protects salient weights)Serving throughput on Ampere/Ada; strong quality at 4-bitNeeds a pre-quantized AWQ checkpoint; INT4 weights dequantized for compute
GPTQ3/4/8-bitWeights only (2nd-order error minimization)Broad hardware/checkpoint availabilityQuality can degrade at 3-bit; per-model calibration sensitivity
FP8 (E4M3)8-bitWeights + activations (and KV via --kv-cache-dtype fp8)Hopper/H100, Ada with hardware FP8; near-lossless, high throughputRequires FP8-capable GPUs for full speedup
INT8 (SmoothQuant/W8A8)8-bitWeights + activationsGood quality/speed balance where FP8 HW absentMore setup; less dramatic memory savings than 4-bit
bitsandbytes4/8-bitWeights, on-the-flyQuick experiments, no pre-quant stepSlower 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 fp8 to 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 fp8 and --max-model-len are 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:
    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
    
    Bigger token budget = fatter prefill chunks and higher throughput; more seqs = more concurrency, backed by the enlarged KV pool.
  • 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-space so 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 fp8 on 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

DimensionvLLMTGI (HF Text Generation Inference)TensorRT-LLM + Triton
Core strengthPagedAttention + continuous batching; best throughput/$ out of the boxSolid production server, tight HF ecosystem fitPeak NVIDIA-GPU performance via compiled engines
BatchingContinuous, iteration-levelContinuous (in-flight)In-flight batching (Triton backend)
KV memory mgmtPagedAttention (near-zero waste)Paged KV (adopted vLLM-style ideas)Paged KV
Prefix cachingAutomatic, on by defaultSupportedSupported
QuantizationAWQ, GPTQ, FP8, INT8, bnbAWQ, GPTQ, EETQ, FP8, bnbINT4/8, FP8 (compiled, very fast)
Speculative decodeDraft model, n-gram, EAGLE/MedusaMedusa / n-gramEAGLE, Medusa, draft
Setup costLow — pip install, one commandLow — Docker imageHigh — per-model engine build/compile step
HardwareNVIDIA + AMD ROCm + othersNVIDIA + AMDNVIDIA only
APIOpenAI-compatible serverOpenAI-compatible + nativeTriton (OpenAI frontend available)
Best whenDefault choice; open models, fast iteration, high throughputHF-centric stacks wanting a batteries-included serverSqueezing 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-utilization too 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. Frequent preempted warnings mean you over-committed max-num-seqs/max-model-len; throughput drops as work is redone. Fix by lowering concurrency, shortening max-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-tokens too 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-seqs is the batch limit. Usually KV memory binds first. Raising max-num-seqs without KV headroom just causes preemption. Watch the # GPU blocks log, 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

  1. “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.
  2. “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%).
  3. “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.
  4. “Walk me through sizing KV cache and picking gpu-memory-utilization / max-num-seqs / max-model-len for a given GPU.” — Do the bytes/token math, subtract weights, derive block count and concurrency; explain preemption when over-committed.
  5. “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).
  6. “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.
  7. “When does speculative decoding help vs hurt?” — Helps latency at low batch with high acceptance; hurts under saturation or low acceptance.
  8. “How do you debug an OOM or a latency spike in production?” — Check # GPU blocks and preemption logs; lower gpu-memory-utilization/max-model-len; tune max-num-batched-tokens for ITL; add swap-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:

  1. Reads the sequence’s block table (logical block → physical block number).
  2. For each logical block, computes attention scores ( q \cdot k ) against the K vectors in that physical block, iterating block by block.
  3. 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:

  1. Frees blocks of any sequence that finished last step.
  2. Tries to admit waiting requests into running, subject to the KV block budget and max-num-seqs / max-num-batched-tokens.
  3. 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

GoalStarting 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 preamblekeep --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

  1. HPA checks metrics every 15s (default)
  2. Compares current vs target
  3. Calculates desired replicas
  4. Updates deployment
  5. 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:

  1. Install Prometheus
  2. Install Prometheus Adapter
  3. Configure custom metrics
  4. 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

  1. Set min replicas: Always have some pods running
  2. Set max replicas: Prevent runaway scaling
  3. Use stabilization windows: Avoid flapping
  4. Monitor scaling events: Track scale-up/down
  5. Test under load: Verify scaling works
  6. 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

  1. Basic HPA: Set up CPU-based autoscaling
  2. Custom metrics: Scale based on request rate
  3. Scaling policies: Configure scale-up/down behavior
  4. Load test: Generate load and observe scaling
  5. 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.

SignalSourceProsCons
Queue depth (vllm:num_requests_waiting)vLLM/TGI Prometheus metricDirectly reflects unmet demand; leads latency, so it’s an early signal; cheap to computeZero 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 KPAMaps cleanly to a per-replica capacity target; stableSaturates at the batch limit — can’t tell “full” from “overwhelmed” alone
GPU utilization (DCGM_FI_DEV_GPU_UTIL)NVIDIA dcgm-exporterHardware truth; catches non-vLLM workloads tooLagging and coarse — 100% util can mean “efficiently batched” or “drowning”; poor sole signal
KV-cache usage (vllm:gpu_cache_usage_perc)vLLM metricPredicts imminent preemption/OOM before latency degradesvLLM-specific; needs a sensible target (~90%)
TTFT / p95 latency (vllm:e2e_request_latency_seconds)Engine histogram → histogram_quantileIs the SLO — what users actually feelLagging: by the time it breaches, users are already hurting. Use as guardrail, not primary
Requests per secondIngress / KPASimple, intuitiveIgnores request size; 10 long generations ≠ 10 short ones
Batch/token throughputEngine metricsReflects real GPU workHard 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 averagesaverageValue: "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 scaling threshold (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 clean 0, which is exactly the “no demand” reading you want.
  • metricType: AverageValue divides the query result by the current replica count so the target is per-pod; use Value only when your query already returns a per-pod figure.
  • activationThreshold: "1" vs threshold: "5" — one waiting request is enough to justify the first replica; you only add more replicas once the per-pod queue passes 5.
  • cooldownPeriod governs the final ramp toward minReplicaCount (including the drop to zero); the HPA behavior block 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:

  1. Scheduling — Kubernetes finds a node with a free GPU (seconds → minutes if the cluster autoscaler must add a node).
  2. Image pull — the container image is often 5–15 GB (CUDA, PyTorch, vLLM). Seconds to minutes if not cached on the node.
  3. 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.
  4. 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

MitigationHow it worksCold-start impactCostBest for
Provisioned min replicas (minReplicas/minReplicaCount ≥ 1)Never fully scale down; keep N warmEliminates it for the first N concurrent requestsHighest — you pay for idle GPUsInteractive, SLA-bound traffic
Warm pool / over-provision headroomKeep spare ready replicas ahead of demand (e.g. +1 buffer)New traffic hits an already-warm podMedium — pay for the buffer onlyPredictable 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 deserializationCuts the dominant load phase (reported up to ~6x)Low — engineering onlyEvery setup; stacks with others
Node/image pre-pull & DaemonSet cachePre-pull the container image and warm node cachesRemoves image-pull phaseLowLarge images, node churn
Snapshot / checkpoint-restore (NVIDIA Dynamo snapshot, CUDA checkpoint/CRIU)Snapshot a warmed process (CUDA context + weights in VRAM) and restore itCan approach near-zero — skips load and warm-upMedium; newer/less matureAggressive scale-to-zero without the latency tax
Smaller/quantized model or smaller shardsFewer bytes to move and initializeProportionally shorter loadFree-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: 8 vs containerConcurrency: 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-capacity decides 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-delay is Knative’s answer to flapping — the KPA equivalent of HPA’s scale-down stabilization window.
  • Set min-scale: 0 only 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?

SituationReach forWhy
Simple custom-metric scaling, floor ≥ 1, existing PrometheusHPA + Prometheus AdapterFewest moving parts; native; no scale-to-zero needed
Queue/event-driven, scale-to-zero, many metric sourcesKEDAactivationThreshold, 0→1, and 70+ scalers wrap HPA cleanly
Concurrency-driven serverless with request bufferingKnative (KPA)Built-in scale-to-zero + activator; concurrency target fits LLMs
Bursty, latency-tolerant, want managed cold-start bufferingKnativeActivator holds requests during spin-up so nothing drops
Interactive, strict TTFT SLOAny + warm floorThe 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 generous cooldownPeriod. 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 scaleUp step size (Pods: value / periodSeconds), stagger with fast-load streaming, pre-warm images, and cache weights on nodes.
  • GPU quota / capacity ceilings. maxReplicas is a wish; cloud GPU quota and actual availability are the reality. HPA will happily request 12 pods that sit Pending forever because there are no H100s to schedule them on. Fix: set maxReplicas to your real quota, alert on Pending GPU pods, and combine with cluster-autoscaler node pools sized to quota.
  • Metric pipeline is a hidden dependency. If Prometheus, the adapter, or dcgm-exporter hiccups, 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 Value where you meant AverageValue (or a raw sum() 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 terminationGracePeriodSeconds sized accordingly.

Production Checklist — What an Interviewer Probes

  1. “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.
  2. “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.
  3. “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.
  4. “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.
  5. “How do you stop it flapping?” — Asymmetric behavior: aggressive scale-up (0s window), conservative scale-down (300s+ window, one pod at a time), cooldown.
  6. “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.
  7. “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.
  8. “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


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

  1. Start small: 5-10% traffic initially
  2. Monitor closely: Watch metrics during rollout
  3. Have rollback plan: Know how to revert quickly
  4. Test thoroughly: Test canary before production
  5. Document changes: Track what changed
  6. 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

  1. Basic canary: Deploy canary with 10% traffic
  2. Gradual rollout: Increase from 10% to 100% over phases
  3. Monitoring: Set up dashboards to compare versions
  4. Rollback: Practice rolling back canary
  5. 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:

Signalv1 (stable)v2 (canary)Infra verdict
HTTP 5xx rate0.02%0.02%✅ pass
p95 latency480 ms410 ms✅ pass (faster!)
Pod restarts / OOMs00✅ pass
Throughput220 rps235 rps✅ pass
Groundedness / factuality0.910.78regression
Refusal rate on valid prompts1.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

StrategyUser blast radius if badRollback speedExtra costCatches quality regressions?Best fit for models
RollingGrows as pods replace; hard to boundSlow (roll back = another rollout)~noneNo — new pods serve users immediatelyLow-risk config bumps, sidecar updates
Blue-green100% at the instant of flipInstant (flip traffic back)High (2× fleet during overlap)Only if you gate the flip on an eval suiteBig/atomic version jumps where partial-mix is unacceptable
CanaryBounded to the canary weight (e.g. 5%)Fast (set weight → 0)Moderate (small extra fleet)Only if analysis includes a quality gateThe default for model rollouts
ShadowZero (responses discarded)N/A (no user traffic to roll back)High (full 2nd inference path, doubled GPU)Yes, offline — no user exposurePre-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:

  1. You declare steps (weights + pauses) and metrics with thresholds.
  2. The controller shifts traffic to the first step.
  3. At each step it queries metrics (usually Prometheus) over an interval, a number of times.
  4. If a metric violates its condition too many times → abort and roll back.
  5. 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 runs count × interval before it can succeed.
  • successCondition / failureCondition — a boolean expression over result (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: 0 means 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:

  1. Argo creates canary pods, points model-canary at them, and sets the VirtualService to 5% canary / 95% stable.
  2. Waits 10m, then runs infra-metrics five times over five minutes.
  3. Any check fails past failureLimitweight snapped back to 0, rollout marked Degraded, canary pods torn down. Automatic rollback.
  4. 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/threshold is crossed. Humans add an optional pause: {} (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 -shadow to the Host/Authority header of mirrored requests, so the canary (and any downstream) can tell it’s shadow traffic.
  • mirrorPercentage.value controls 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 -shadow header 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

  1. “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.
  2. 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?
  3. 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)?
  4. Shadow vs canary tradeoff. When do you shadow first? What can shadow not tell you (user outcomes), and what’s the side-effect hazard?
  5. Statistical soundness of the gate. How do you avoid flaky rollbacks from thin quality samples? Dwell time, sample size, repeated-breach thresholds.
  6. Cold-start / cache warmup handling. How do you keep KV/prefix-cache and kernel-compile cold starts from skewing the first analysis window?
  7. 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)?
  8. Blue-green vs canary decision. When is an atomic flip (tokenizer/prompt-format change) actually the right call over a mixed canary?

Further reading

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

  1. Prometheus: Metrics collection and storage
  2. Grafana: Visualization and dashboards
  3. Exporters: Collect metrics from applications
  4. 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

  1. Start Prometheus: ./prometheus --config.file=prometheus.yml
  2. Start Grafana: ./grafana-server
  3. Configure data source in Grafana

Grafana Dashboards

Pre-built Dashboards

  1. LLM Serving Overview: Request rate, latency, errors
  2. GPU Monitoring: GPU utilization, memory, temperature
  3. System Metrics: CPU, memory, disk, network
  4. Model Performance: Throughput, tokens/second

Creating Custom Dashboards

  1. Open Grafana (http://localhost:3000)
  2. Login (default: admin/admin)
  3. Add Prometheus data source
  4. Create new dashboard
  5. 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

  1. Set up monitoring: Deploy Prometheus and Grafana
  2. Create dashboard: Build a dashboard for your metrics
  3. Set up alerts: Configure alerts for high latency
  4. Monitor GPU: Add GPU metrics to dashboard
  5. 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.

  1. 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.

  2. 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.

  3. 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

MetricWhat it meansWhy it mattersHealthy range / notes
TTFT p50/p95/p99Time until first tokenPerceived responsiveness; captures prefill + queue waitInteractive chat: p95 < 1–2 s. Rising p95 with flat p50 = queue building
TPOT / inter-token latencySteady-state gap between output tokensStream “typing speed”; regressions multiply over long outputs10–50 ms/token typical; > 100 ms feels slow
E2E latency p50/p95/p99Full request wall-clockThe SLO users actually sign; skewed by output lengthAlways report percentiles, never the mean
Throughput — requests/sCompleted requests per secondCapacity planning, autoscaling signalCompare against offered load; gap = queue growth
Throughput — tokens/sGenerated tokens per second (decode)The real work rate of the GPU; the currency of costTrack output tok/s separately from prompt tok/s
Queue depth / waiting seqsRequests admitted-but-waitingLeading indicator of a latency cliffShould hover near 0; sustained > 0 = under-provisioned
Running sequencesSequences decoding right nowEffective batch size; drives GPU efficiencyLow + full queue = memory-bound, not compute-bound
GPU utilization% time GPU had work scheduledCoarse “is the GPU busy” signalHigh util ≠ efficient; can be high while thrashing
GPU memory used / freeHBM in use (framebuffer)OOM risk; headroom for larger batches/KVLeave headroom; OOM crashes the whole replica
KV-cache utilizationFraction of paged KV blocks in useThe saturation signal for LLM serving> 90% sustained → preemption, TTFT spikes
Batch sizeSequences processed per stepThroughput vs latency tradeoff knobLarger = more throughput, higher TPOT
PreemptionsSequences evicted & recomputedDirect evidence of cache pressureAny sustained rate is a red flag
Error rateFailed / total requestsAvailability SLI; 5xx, OOM, timeouts, truncationsAlert on rate, not raw count
Cost per 1k tokens$ per 1000 tokens servedTurns efficiency into money; the exec-facing numberDerived: 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 — TTFT
  • vllm:time_per_output_token_seconds — TPOT / inter-token latency
  • vllm:e2e_request_latency_seconds — full request latency
  • vllm:request_prompt_tokens — prompt length distribution
  • vllm:request_generation_tokens — output length distribution

Gauges (instantaneous system state):

  • vllm:num_requests_running — sequences currently decoding
  • vllm:num_requests_waiting — sequences queued (the saturation signal)
  • vllm:num_requests_swapped — swapped to CPU under pressure
  • vllm: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 processed
  • vllm:generation_tokens_total — output tokens generated (throughput source)
  • vllm:request_success_total — successful requests (has a finished_reason label so you can separate stop vs length vs abort)
  • 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_):

MetricMeaning
DCGM_FI_DEV_GPU_UTILGPU utilization (% of time a kernel was resident)
DCGM_FI_DEV_FB_USEDFramebuffer (HBM) memory used, MiB
DCGM_FI_DEV_FB_FREEFramebuffer memory free, MiB
DCGM_FI_DEV_POWER_USAGEBoard power draw, watts
DCGM_FI_DEV_GPU_TEMPGPU die temperature, °C
DCGM_FI_DEV_SM_CLOCKSM clock, MHz (watch for throttling)
DCGM_FI_PROF_GR_ENGINE_ACTIVEGraphics/compute engine active ratio
DCGM_FI_PROF_PIPE_TENSOR_ACTIVETensor-core pipe active ratio (real compute intensity)
DCGM_FI_PROF_DRAM_ACTIVEMemory-bandwidth active ratio

Two subtleties worth internalizing:

  • DCGM_FI_DEV_GPU_UTIL is 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. Use DCGM_FI_PROF_PIPE_TENSOR_ACTIVE and DCGM_FI_PROF_DRAM_ACTIVE to see whether you’re compute-bound or bandwidth-bound.
  • Every DCGM series carries a gpu (index) and usually UUID/modelName label, 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:

SLIExample 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:

  • Raterate(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):

  • UtilizationDCGM_FI_DEV_GPU_UTIL, and more honestly DCGM_FI_PROF_PIPE_TENSOR_ACTIVE / DCGM_FI_PROF_DRAM_ACTIVE; HBM used %.
  • Saturationvllm:num_requests_waiting (queue) and vllm: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 _bucket series, and compute them with histogram_quantile over a rate(), never over a raw counter.

  • No queue or KV-cache metrics. The single most common LLM-monitoring gap. Without num_requests_waiting and gpu_cache_usage_perc you 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 with PIPE_TENSOR_ACTIVE / DRAM_ACTIVE before 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 _perc suffix. Forgetting the * 100 silently makes a “90% full” alert fire at 9000% or never fire at all.

  • Percentiles over the wrong window. histogram_quantile on 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 _bucket series 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

  1. “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.
  2. “How do you know before users do?” — Point to queue depth (num_requests_waiting) and KV-cache utilization as leading indicators, not GPU util.
  3. “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 _bucket by le first.
  4. “GPU util is 100% — are you compute-bound?” — Not necessarily; decode is often memory-bandwidth-bound. Cross-check PIPE_TENSOR_ACTIVE / DRAM_ACTIVE.
  5. “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.
  6. “How do you avoid a Prometheus cardinality blowup?” — Low-cardinality labels only; per-request detail goes to traces/logs.
  7. “How do you debug one slow request?” — OpenTelemetry tracing with propagated traceparent, sampled, to split queue vs prefill vs decode time.
  8. “What’s your cost metric?” — $/1k tokens derived from GPU $/hr and rate(generation_tokens_total), tracked per model/replica.

Further reading

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

  1. Identify current version
  2. Identify target version
  3. Update deployment
  4. Verify health
  5. 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

  1. Version everything: Models, code, configs
  2. Tag releases: Use semantic versioning
  3. Store metadata: Track training info
  4. Test before deploy: Validate new versions
  5. Document changes: Changelog for each version
  6. Automate: Use CI/CD for versioning

Exercises

  1. Create registry: Set up model version storage
  2. Version model: Tag and store model version
  3. Deploy version: Deploy specific version
  4. Rollback: Practice rolling back
  5. 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:

  1. 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.
  2. Mutable pointers (aliases/stages) — human-friendly names like @champion, @production, staging that 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.

ComponentWhy it changes outputsFailure if unpinned
Weights (safetensors/GGUF/etc.)The model itselfDifferent answers; the obvious one
Model config (config.json, arch, rope/context, dtype)Defines how weights are interpretedWrong context length, silent truncation
Tokenizer (vocab, merges, special tokens, chat template)Maps text ↔ tokensPrompt/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 modelPrompt template drift is a top silent regression
Inference engine + version (vLLM, TGI, TensorRT-LLM, llama.cpp)Kernels, sampling RNG, quant handling, batchingNumeric drift, different quant results
Quantization recipe (AWQ/GPTQ/FP8 params, calibration set)Alters the effective weightsQuality cliff that “weights hash” alone won’t catch
Runtime deps (torch, CUDA, transformers, flash-attn)Kernel/numeric behaviorReproducibility gaps across hosts
Hardware/precision assumptions (GPU arch, bf16 vs fp16)Numeric results differCross-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:

  1. Integrity — re-download and re-hash; if it matches, the bytes are intact.
  2. 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 @champion and @shadow simultaneously. 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 @champion unless 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)
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

FeatureMLflow Model RegistrySageMaker Model RegistryVertex AI Model RegistryHugging Face Hub
Version unitRegistered model + integer versionModel Package Group + Model PackageModel resource + version idGit repo + commit (revision)
Pointer mechanismAliases + tags (stages deprecated)Approval status (Pending/Approved/Rejected)Version aliases (incl. default)Branches / tags / commit SHA
Immutable idVersion number + logged artifact hashModel Package ARNVersion id (immutable)Commit hash (content-addressed via Git/LFS)
Gated promotionAlias move + tag gates in CIApproval status flip (EventBridge-triggerable)Alias reassignmentPR/branch merge; manual convention
Artifact storePluggable (S3/GCS/Azure/local)S3 (+ ECR image)Google Cloud StorageGit LFS on the Hub
Lineage/metadataRuns, params, metrics, tagsMetrics, data lineage, source pipelineMetadata, eval, dataset linksModel card (README.md + YAML)
Approval/auditTags + external gateNative approval workflow + auditIAM + Cloud Audit LogsRepo history / commits
Best whenOpen-source, self-hosted MLOpsDeep AWS + Pipelines/EventBridgeDeep GCP + Vertex EndpointsPublic/OSS models, git-native pinning

Key nuances:

  • MLflow deprecated the None/Staging/Production/Archived stages in favor of named aliases + tags — repoint an alias to promote or roll back. Load with models:/<name>@<alias> or models:/<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. The ModelPackageArn is the immutable handle.
  • Vertex AI keeps versions under one model resource; aliases (e.g. the built-in default) point at versions. Reference model@default or 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= on from_pretrained pins a commit hash, branch, or tag. The commit hash is a true content address; a bare main is 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:

  1. Resolve the version id → immutable artifact reference (hash/ARN/commit).
  2. Fetch bytes from object storage; re-hash and verify against the recorded digest.
  3. Load with the pinned engine + pinned runtime (image digest or lockfile).
  4. Apply the captured generation/serving config (not the engine defaults).
  5. 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 previous ModelPackageArn (SageMaker), reassign default alias (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 @champion and @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/main in prod. Serving chatbot-llm:latest or HF main means behavior can change under you with no deploy and no diff. Pin a digest, alias→version, or commit hash. latest is 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 @champion on 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 v3 after 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

  1. “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.
  2. “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.
  3. “Walk me through promoting dev → staging → prod.” — Immutable register, evals linked to the version, enforced gates, approval recorded, pointer move — never a rebuild.
  4. “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.
  5. “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.
  6. “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.
  7. “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.
  8. “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

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

  1. Establish baseline: Use training data as reference
  2. Monitor continuously: Check drift regularly
  3. Set thresholds: Define what’s “drift”
  4. Investigate causes: Understand why drift occurs
  5. Document actions: Track responses to drift
  6. 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

  1. Set up Evidently: Install and configure
  2. Detect data drift: Compare training vs production
  3. Create dashboard: Visualize drift metrics
  4. Set up alerts: Alert on drift detection
  5. 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:

  1. Are inputs shifting? (Are we being asked things we were not built for?)
  2. Are outputs shifting? (Is quality, length, refusal rate, or latency decaying?)
  3. 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:

TypeWhat shiftsLLM exampleTypically detected via
Input / prompt driftThe distribution of raw inputs ( P(x) )Prompts get longer; a new language appears; topic mix changesPSI / KS on scalar features (length, token count, language ID); topic classifiers
Embedding / semantic driftThe distribution of inputs (or outputs) in vector space ( P(\phi(x)) )Users start asking about a product feature that did not exist at launchMMD, domain classifier, centroid / cosine distance on embeddings
Output / quality driftThe distribution of outputs ( P(y) ) or a quality proxyAnswers get shorter, refusals rise, latency creeps up, tone changesPSI/KS on output length, refusal rate, latency; LLM-judge scores
Concept driftThe conditional ( P(y \mid x) ) — the correct mapping“Best model” now points to a newer model; a policy changed so the right answer flippedRequires 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
10.250.10(-0.15)(-0.916)0.1375
20.250.20(-0.05)(-0.223)0.0112
30.250.30(+0.05)(+0.182)0.0091
40.250.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
10.250.000.25
20.500.250.25
30.750.500.25
41.000.750.25
51.001.000.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

MethodData typeOutputSensitivityProsCons
PSIScalar / categoricalUnbounded score, standard thresholds (0.1 / 0.2)Sensitive where relative mass changes; binning-dependentOne interpretable number; no p-value plumbing; localizes to bins; industry-standard cutoffsNeeds binning choice; unstable with sparse bins; not for raw vectors
KS testContinuous scalar( D\in[0,1] ) + p-valueStrong at distribution center, weak in tailsNon-parametric, no binning, principled p-valueHypersensitive on huge windows; univariate only; tail-blind; not for categoricals
MMDVectors / embeddings( \ge 0 ), null via permutationDetects general distributional shape shiftsTruly multivariate; kernel-flexible; theoretically grounded( O(n^2) ) cost; threshold not intuitive; bandwidth ( \gamma ) tuning matters
Domain classifierVectors / embeddingsROC-AUC ( \in [0.5,1] )Sees any separable shift, incl. multimodalInterpretable (AUC + feature importance), robust across embedding types, a strong defaultNeeds training per window; can overfit small windows
Centroid / cosine distVectors / embeddingsDistance ( \ge 0 )Only mean shiftCheap, streaming, interpretableBlind to variance & multimodal splits; threshold hand-tuned
Wasserstein (per-dim)Scalar / per-embedding-dim( \ge 0 ), in feature unitsSensitive to any shift incl. tailsMetric with real units; tail-awareUnivariate per dim; needs aggregation across dims
Chi-squareCategorical( \chi^2 ) + p-valueAny count-table changeRight tool for labels; simpleNeeds 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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

  1. “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.
  2. “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.
  3. “How do you pick the reference window?” — Fixed, curated, versioned; not rolling; refreshed deliberately. Red flag if they roll it automatically.
  4. “How do you avoid false alarms?” — Effect-size + significance gates, persistence across windows, multi-signal agreement, seasonality handling, per-segment analysis, multiple-comparison correction.
  5. “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.
  6. “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.
  7. “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.
  8. “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:

  1. 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.
  2. 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.
  3. 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.
  4. 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).
  5. 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_2samp reference: 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 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

  1. Requests arrive at Triton
  2. Triton queues requests
  3. When batch ready (size or timeout), process together
  4. 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

  1. Organize models: Clear repository structure
  2. Optimize configs: Tune batching parameters
  3. Monitor performance: Track metrics
  4. Version models: Use versioning in repository
  5. 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

  1. Deploy model: Set up Triton with one model
  2. Multiple models: Serve multiple models
  3. Dynamic batching: Configure and test batching
  4. Model ensemble: Chain models together
  5. 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 (or platform). 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 ininstance 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:

  1. Frontends — HTTP/REST on port 8000, gRPC on port 8001, and a Prometheus metrics endpoint on port 8002.
  2. 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.
  3. 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 or 0 directories are ignored. By default Triton serves the highest numbered version; a version_policy in the config changes that (latest N, all, or specific).
  • The model file name is fixed per backendmodel.onnx for ONNX Runtime, model.pt for PyTorch/LibTorch, model.plan for TensorRT, model.py for 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:

FieldMeaning
nameMust match the directory name (optional if it does).
backend / platformWhich backend executes the model (onnxruntime, python, pytorch/platform: "pytorch_libtorch", tensorrt/platform: "tensorrt_plan", vllm, tensorrtllm).
max_batch_sizeLargest batch Triton will assemble. 0 means the model does not support Triton’s batching (first dim is not a batch dim).
input / outputTensor 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_groupHow many copies, on what devices (see below).
dynamic_batchingEnables 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:

Backendbackend/platformBest forBatching modelNotes
TensorRT-LLMtensorrtllmProduction LLM inference on NVIDIA GPUsIn-flight (continuous)Fastest LLM path; requires compiling the model into a TensorRT-LLM engine. Paged KV cache, tensor/pipeline parallel.
vLLMvllmLLMs you want to run with minimal conversionContinuous (vLLM’s own)Wraps vLLM’s AsyncLLMEngine; PagedAttention; no engine build step. Easiest LLM onramp inside Triton.
PythonpythonPre/post-processing, tokenization, glue, custom logic, BLSDynamic (if you enable it)You write model.py with TritonPythonModel. The universal escape hatch; also hosts Business Logic Scripting.
ONNX RuntimeonnxruntimeClassifiers, embedders, small/medium models exported to ONNXDynamicPortable, CPU or GPU, good default for non-LLM models.
PyTorch (LibTorch)pytorch / pytorch_libtorchTorchScript / traced modelsDynamicServe model.pt directly without re-exporting.
TensorRTtensorrt / tensorrt_planVision/CNN/transformer engines compiled to a .planDynamicExtremely 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, and priority_levels for 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.
  • kindKIND_GPU, KIND_CPU, or KIND_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_stream for 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:

MetricMeaning
nv_inference_request_success / _failureRequest counts.
nv_inference_countInferences performed (includes batching effects).
nv_inference_queue_duration_usTime requests spend queued — your batching-pressure signal.
nv_inference_compute_infer_duration_usActual model compute time.
nv_inference_compute_input_duration_us / _output_Tensor marshalling time.
nv_gpu_utilization, nv_gpu_memory_used_bytesPer-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:

  1. 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).
  2. Assemble the ensemble repository: preprocessing/ (tokenizer, Python backend), tensorrt_llm/ (the engine + config.pbtxt with batching_strategy: inflight_fused_batching, engine_dir, kv_cache_free_gpu_mem_fraction, decoupled_mode), postprocessing/ (detokenizer), and ensemble/. NVIDIA’s fill_template.py populates these configs from the engine.
  3. Launch across GPUs:
python3 /app/scripts/launch_triton_server.py \
  --world_size=2 --model_repo=/models/trtllm_repo
  1. Call POST /v2/models/ensemble/generate (or stream via /generate_stream with decoupled_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

DimensionTriton + TensorRT-LLMvLLM (standalone)TGI (Text Generation Inference)
Primary goalMulti-model serving fleet + fastest NVIDIA LLM pathFast, simple LLM servingHuggingFace-native LLM serving
Continuous batchingYes (in-flight, fused)Yes (native)Yes (native)
KV cachePagedPagedAttention (originator)Paged
Setup effortHigh — engine build + ensemble wiringLowpip install, point at HF modelLow–medium — Docker + model id
Peak throughput on NVIDIAHighest (tuned TensorRT kernels)Very highHigh
HardwareNVIDIA onlyNVIDIA-first (some others)NVIDIA-first (some others)
Non-LLM modelsYes — same server hosts ONNX/PyTorch/TensorRTNoNo
Ops surfaceOne server, unified metrics, ensembles/BLSSimple, LLM-scopedSimple, LLM-scoped
StreamingDecoupled + /generate_streamOpenAI-compatible SSESSE, OpenAI-compatible
Best whenYou run many models and/or want max NVIDIA LLM perf with unified opsYou want the least-effort fast LLM serverYou 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_batching produces 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_type at v1, silently disables continuous batching. You paid the conversion cost and got none of the benefit.
  • Misconfigured dynamic batching. max_queue_delay_microseconds too high → latency spikes; too low → tiny batches and idle GPU. preferred_batch_size mismatched to what the engine was tuned for → padding waste. Tune against GenAI-Perf, not by guessing.
  • max_batch_size vs shape confusion. With max_batch_size > 0 the batch dim is implicit — listing it explicitly in dims double-counts it and breaks shape checks. Set max_batch_size: 0 only for models whose first dim is not a batch dimension.
  • KV-cache OOM. kv_cache_free_gpu_mem_fraction too 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. name in config.pbtxt disagreeing 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 needs decoupled: true or you get one blob at the end instead of tokens.

Production checklist — what an interviewer probes

  1. “You have five models in four frameworks sharing two GPUs. Design it.” — Expect: one Triton server, one model repository, per-model backend and instance_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.
  2. “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.
  3. “Walk me through the TensorRT-LLM deployment.” — Convert + trtllm-build with paged KV cache → ensemble (pre/trtllm/post) → fill_template.pylaunch_triton_server.py --world_size/generate. Honesty about the conversion complexity scores points.
  4. “How do you tune the latency/throughput tradeoff?”max_queue_delay_microseconds and preferred_batch_size for dynamic; instance count for concurrency; kv_cache_free_gpu_mem_fraction and max sequence length for LLMs — validated with GenAI-Perf (TTFT, ITL, tokens/s).
  5. “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.
  6. “Ensemble vs BLS?” — Ensemble for a static DAG (tokenize→infer→detokenize); BLS when the pipeline branches or loops on runtime data.
  7. “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.
  8. “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