LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Self-Hosted AI Inference: vLLM, llama.cpp, and Running Your Own OpenAI-Compatible API

aillminferencevllmllama.cppmlopsgpuself-hosting

Running your own LLM inference stack used to require a research budget and a team of ML engineers. Today, tools like vLLM and llama.cpp let you run production-grade inference on hardware ranging from a single consumer GPU to a multi-node cluster — with an OpenAI-compatible API so existing code works unchanged.

This guide covers the full stack: engine architecture, deployment patterns, quantization trade-offs, batching mechanics, and how to benchmark throughput so you know what you’re actually getting.


Why Self-Host Inference?

Before diving into tooling, it’s worth being clear about when self-hosting makes sense:

Reason Details
Privacy Data never leaves your infrastructure — critical for healthcare, legal, and financial workloads
Cost At high volume, GPU compute is cheaper than per-token API pricing
Latency No network round-trips to an external API; colocation with your application
Control Specific model versions, fine-tuned models, custom sampling parameters
Availability No dependency on third-party rate limits or outages

Self-hosting is not automatically cheaper for low-volume workloads. Break-even typically happens somewhere around 1–5M tokens/day depending on the model and hardware.


The Inference Engine Landscape

┌─────────────────────────────────────────────────────┐
│              LLM Inference Engines                   │
├───────────────┬──────────────────┬───────────────────┤
│   vLLM        │   llama.cpp      │   Ollama          │
├───────────────┼──────────────────┼───────────────────┤
│ GPU-first     │ CPU+GPU, mobile  │ Dev/hobbyist UX   │
│ PagedAttention│ GGUF quant       │ llama.cpp backend │
│ Continuous    │ Speculative dec. │ Model management  │
│ batching      │ Very portable    │ Auto pull/run     │
│ High thruput  │ Low VRAM option  │ Simple API        │
│ Production    │ Edge/offline     │ Not for prod      │
└───────────────┴──────────────────┴───────────────────┘

Other notable engines:

  • TensorRT-LLM — NVIDIA’s optimized engine, best raw performance on A100/H100
  • MLC LLM — Compiles models to optimized kernels via TVM, mobile support
  • DeepSpeed-MII — Microsoft’s inference stack, strong for large model parallelism
  • SGLang — Structured generation focus, excellent for complex prompting pipelines
  • Triton Inference Server — Framework-agnostic, supports TensorRT-LLM backends

For most self-hosting scenarios you’ll choose between vLLM (GPU, production throughput) and llama.cpp (CPU/consumer GPU, flexibility).


vLLM: High-Throughput GPU Inference

Why vLLM Is Different

Traditional LLM serving allocates GPU memory for each request’s KV cache upfront, wasting memory on unused capacity and limiting concurrency. vLLM’s PagedAttention algorithm manages KV cache like OS virtual memory — allocating in fixed-size pages, sharing pages between requests for prefix caching, and enabling near-zero memory waste.

Traditional serving:          vLLM PagedAttention:
┌──────────────────┐          ┌──────────────────┐
│ Req A  [████░░░░]│          │ Page pool        │
│ Req B  [██░░░░░░]│          │ ┌──┬──┬──┬──┐  │
│ Req C  [████████]│          │ │A1│A2│B1│C1│  │
│ (wasted: ░░░░░░░)│          │ ├──┼──┼──┼──┤  │
└──────────────────┘          │ │C2│C3│  │  │  │
                              │ └──┴──┴──┴──┘  │
                              │ (no waste)      │
                              └──────────────────┘

Combined with continuous batching (dynamically adding new requests to in-flight batches rather than waiting for fixed-size batches to fill), vLLM achieves 2–24× higher throughput than naive serving.

Installation

1
2
3
4
5
# Requires CUDA 12.1+ and a compatible NVIDIA GPU
pip install vllm

# Or with Docker (recommended for production)
docker pull vllm/vllm-openai:latest

Running Your First vLLM Server

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# Serve Mistral 7B with an OpenAI-compatible API
docker run --gpus all \
  -v ~/.cache/huggingface:/root/.cache/huggingface \
  -p 8000:8000 \
  vllm/vllm-openai:latest \
  --model mistralai/Mistral-7B-Instruct-v0.3 \
  --dtype bfloat16 \
  --max-model-len 8192 \
  --gpu-memory-utilization 0.90

# Test it — identical to OpenAI API
curl http://localhost:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "mistralai/Mistral-7B-Instruct-v0.3",
    "messages": [{"role": "user", "content": "Explain PagedAttention in one paragraph."}],
    "temperature": 0.7,
    "max_tokens": 256
  }'

Key vLLM Configuration Parameters

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
vllm serve mistralai/Mistral-7B-Instruct-v0.3 \
  # Model loading
  --dtype bfloat16 \              # bfloat16 for Ampere+, float16 otherwise
  --quantization awq \            # AWQ, GPTQ, SqueezeLLM, None
  --max-model-len 32768 \         # Context window (reduces if OOM)
  \
  # Memory and concurrency
  --gpu-memory-utilization 0.90 \ # Reserve 10% for overhead
  --max-num-seqs 256 \            # Max concurrent sequences
  --max-num-batched-tokens 8192 \ # Max tokens per batch step
  \
  # Performance
  --enable-prefix-caching \       # Cache common prefixes (system prompts)
  --enable-chunked-prefill \      # Better latency for long prompts
  --speculative-model eagle \     # Speculative decoding (faster generation)
  \
  # Serving
  --host 0.0.0.0 \
  --port 8000 \
  --api-key your-secret-key \
  --served-model-name gpt-4       # Alias (useful for drop-in replacement)

Multi-GPU Tensor Parallelism

For models that don’t fit on a single GPU, vLLM uses tensor parallelism:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Llama 3 70B across 4x A100 80GB
docker run --gpus all \
  -e CUDA_VISIBLE_DEVICES=0,1,2,3 \
  vllm/vllm-openai:latest \
  --model meta-llama/Llama-3-70b-instruct \
  --tensor-parallel-size 4 \
  --dtype bfloat16 \
  --max-model-len 8192

# For very large models (405B), combine tensor + pipeline parallelism
vllm serve meta-llama/Llama-3.1-405B-Instruct \
  --tensor-parallel-size 8 \
  --pipeline-parallel-size 2 \   # 16 GPUs total
  --dtype bfloat16

Prefix Caching for System Prompts

If many requests share a long system prompt, prefix caching provides huge speedups:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import openai

client = openai.OpenAI(base_url="http://localhost:8000/v1", api_key="key")

SYSTEM_PROMPT = """You are a helpful assistant specialized in DevOps and infrastructure.
You always provide production-ready examples with proper error handling.
[... 2000 tokens of system context ...]"""

# First request: compute and cache the system prompt KV
response = client.chat.completions.create(
    model="mistral-7b",
    messages=[
        {"role": "system", "content": SYSTEM_PROMPT},
        {"role": "user", "content": "How do I set up Prometheus?"}
    ]
)

# Subsequent requests with same system prompt: cache hit, much faster TTFT
response = client.chat.completions.create(
    model="mistral-7b",
    messages=[
        {"role": "system", "content": SYSTEM_PROMPT},  # Cache hit!
        {"role": "user", "content": "Explain Grafana dashboards"}
    ]
)

vLLM with Python API (Offline Batching)

For bulk processing without an HTTP server:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from vllm import LLM, SamplingParams

llm = LLM(
    model="mistralai/Mistral-7B-Instruct-v0.3",
    dtype="bfloat16",
    gpu_memory_utilization=0.85,
    enable_prefix_caching=True,
)

sampling_params = SamplingParams(
    temperature=0.7,
    top_p=0.95,
    max_tokens=512,
    stop=["</s>", "[INST]"],
)

prompts = [
    "<s>[INST] Summarize this document: ... [/INST]",
    "<s>[INST] Extract the key points: ... [/INST]",
    # ... thousands of prompts
]

# vLLM automatically batches these optimally
outputs = llm.generate(prompts, sampling_params)

for output in outputs:
    print(f"Prompt: {output.prompt[:50]}...")
    print(f"Generated: {output.outputs[0].text}")
    print(f"Tokens: {len(output.outputs[0].token_ids)}")

llama.cpp: CPU and Consumer GPU Inference

llama.cpp implements LLM inference in pure C/C++ with no hard dependency on CUDA, making it remarkably portable. It runs on CPUs (including Apple Silicon with Metal), consumer NVIDIA GPUs, AMD GPUs, and even Raspberry Pi.

GGUF Format and Quantization

llama.cpp uses the GGUF format (successor to GGML), which bundles model weights, tokenizer, and metadata in a single file. Quantization reduces the number of bits per weight:

Quantization levels for Llama 3 8B (base: 16GB BF16):

Q2_K   ~3.2 GB   2-bit   Very degraded quality, extreme VRAM savings
Q3_K_M ~4.0 GB   3-bit   Noticeable quality loss
Q4_0   ~4.7 GB   4-bit   Acceptable quality for many tasks
Q4_K_M ~5.0 GB   4-bit   Better than Q4_0, recommended minimum
Q5_K_M ~5.7 GB   5-bit   Good quality, close to full precision
Q6_K   ~6.1 GB   6-bit   Near-lossless, recommended for quality-sensitive tasks
Q8_0   ~8.5 GB   8-bit   Essentially lossless, 2× size of Q4
F16    ~16 GB   16-bit  Full precision (bfloat16)

The _K suffix uses k-quants (mixed precision per layer — important weights get higher precision), _M and _L are medium/large variants with more high-precision layers.

Rule of thumb: Q4_K_M is the sweet spot for quality vs size. Use Q5_K_M or Q6_K when quality matters most.

Building and Running llama.cpp

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# Build with CUDA support
git clone https://github.com/ggerganov/llama.cpp
cd llama.cpp
cmake -B build -DGGML_CUDA=ON
cmake --build build --config Release -j$(nproc)

# Download a model from Hugging Face (GGUF format)
# Many pre-quantized models at huggingface.co/TheBloke or bartowski
huggingface-cli download \
  bartowski/Meta-Llama-3.1-8B-Instruct-GGUF \
  Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf \
  --local-dir ./models

# Run the server (OpenAI-compatible API)
./build/bin/llama-server \
  --model ./models/Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf \
  --ctx-size 8192 \
  --n-gpu-layers 35 \    # Offload 35 layers to GPU (adjust for VRAM)
  --threads 8 \          # CPU threads for remaining layers
  --parallel 4 \         # Concurrent request slots
  --batch-size 512 \     # Prompt processing batch size
  --host 0.0.0.0 \
  --port 8080

# Test the API
curl http://localhost:8080/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model": "llama3.1", "messages": [{"role": "user", "content": "Hello!"}]}'

CPU-Only Inference (No GPU Required)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# CPU-only: use all cores, enable AVX2/AVX512 optimizations
./build/bin/llama-server \
  --model ./models/Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf \
  --ctx-size 4096 \
  --n-gpu-layers 0 \     # No GPU layers
  --threads $(nproc) \
  --parallel 1 \         # CPU inference: 1 concurrent request
  --mlock                # Lock model in RAM, prevent swapping

# Expect ~5-15 tok/s on modern x86 server CPUs for 8B Q4
# Apple M3 Max: ~80-100 tok/s (unified memory = fast!)

Partial GPU Offload (VRAM-Limited Setups)

One of llama.cpp’s killer features is layer-level GPU offloading. If you have 8GB VRAM but a 13B model needs 10GB at Q4:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Llama 3 8B: 35 transformer layers total
# With 8GB VRAM at Q4_K_M:
# - Full GPU: needs ~6GB → use --n-gpu-layers 99 (all layers)
# - 6GB VRAM: --n-gpu-layers 28 (partial, rest on CPU)

# Experiment with --n-gpu-layers and check VRAM with nvidia-smi
watch -n 1 nvidia-smi --query-gpu=memory.used,memory.total --format=csv

# For Apple Silicon (Metal), use -ngl instead:
./build/bin/llama-server \
  --model ./models/llama-3.1-8b-instruct-q4_k_m.gguf \
  -ngl 99 \              # All layers on Metal GPU
  --ctx-size 8192

Quantizing Your Own Models

If you have a fine-tuned or custom model in Hugging Face format:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# Convert from HuggingFace format to GGUF
python convert_hf_to_gguf.py \
  /path/to/hf-model \
  --outfile models/my-model-f16.gguf \
  --outtype f16

# Quantize to Q4_K_M
./build/bin/llama-quantize \
  models/my-model-f16.gguf \
  models/my-model-Q4_K_M.gguf \
  Q4_K_M

# Verify quantization quality with perplexity test
./build/bin/llama-perplexity \
  --model models/my-model-Q4_K_M.gguf \
  --file wikitext-2-raw/wiki.test.raw \
  --ctx-size 512 \
  --chunks 128

Speculative Decoding

Speculative decoding dramatically improves generation speed by using a small “draft” model to predict multiple tokens, then verifying them with the large “target” model in parallel. If the draft is correct (it usually is for common text), you get multiple tokens in one forward pass.

Normal generation:
BigModel → token1
BigModel → token2
BigModel → token3
3 forward passes for 3 tokens

Speculative decoding:
SmallDraft → [token1, token2, token3, token4, token5]  (1 fast pass)
BigModel verifies all 5 in parallel                      (1 slow pass)
Accept: token1, token2, token3  (4th was wrong)
Net: 3 tokens, 2 forward passes total → ~2× speedup

vLLM speculative decoding:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Use a draft model (must be same architecture, smaller)
vllm serve meta-llama/Llama-3.1-70B-Instruct \
  --speculative-model meta-llama/Llama-3.2-1B-Instruct \
  --num-speculative-tokens 5 \
  --tensor-parallel-size 4

# Or use ngram-based speculation (no draft model needed)
vllm serve meta-llama/Llama-3.1-8B-Instruct \
  --speculative-model [ngram] \
  --ngram-prompt-lookup-min 4 \
  --num-speculative-tokens 5

llama.cpp speculative decoding:

1
2
3
4
5
6
# Run with a draft model
./build/bin/llama-speculative \
  --model models/llama-3.1-70b-Q4_K_M.gguf \
  --model-draft models/llama-3.2-1b-Q8_0.gguf \
  --n-draft 8 \
  --ctx-size 4096

Batching Strategies and Throughput

Understanding batching is key to maximizing hardware utilization.

Static vs Continuous Batching

Static batching (naive):
Time →
Req A: [PROMPT_PROCESS][GENERATE___________]
Req B: [waiting...    ][PROMPT][GENERATE___]
Req C: [waiting...          ][PROMPT][GEN__]
GPU utilization: ~30-40%

Continuous batching (vLLM):
Time →
Step 1: [A-prompt][B-prompt][C-prompt]  ← all start immediately
Step 2: [A-gen1 ][B-gen1  ][C-gen1  ]
Step 3: [A-gen2 ][B-gen2  ][C-gen2  ][D-prompt] ← D starts when slot opens
Step 4: [A-gen3 ][B-gen3  ][C-done  ][D-gen1  ]
GPU utilization: ~85-95%

Tuning for Throughput vs Latency

These goals conflict — optimize for your workload:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Throughput-optimized (batch processing, offline inference)
vllm serve model \
  --max-num-seqs 512 \              # Many concurrent sequences
  --max-num-batched-tokens 32768 \  # Large batch steps
  --scheduling-policy fcfs          # First-come-first-served

# Latency-optimized (interactive chat, low TTFT)
vllm serve model \
  --max-num-seqs 32 \               # Fewer concurrent sequences
  --max-num-batched-tokens 4096 \   # Smaller batches
  --enable-chunked-prefill \        # Don't block short requests behind long prompts
  --preemption-mode swap            # Preempt low-priority requests

# Mixed workload (embeddings + generation)
vllm serve model \
  --task embed \                    # Or: generate, classify
  --max-num-seqs 128

Request Queuing with a Load Balancer

For production deployments, run multiple vLLM instances behind a load balancer:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# nginx.conf for vLLM cluster
upstream vllm_backends {
    least_conn;  # Route to server with fewest active connections
    server vllm-1:8000;
    server vllm-2:8000;
    server vllm-3:8000;
    server vllm-4:8000;
    keepalive 32;
}

server {
    listen 80;

    location /v1/ {
        proxy_pass http://vllm_backends;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_read_timeout 300s;    # Long timeout for streaming
        proxy_buffering off;        # Required for SSE streaming
    }
}

Quantization Deep Dive

Quantization reduces weight precision to save memory and increase throughput. The main approaches:

Post-Training Quantization (PTQ)

Applied after training with no fine-tuning data required:

Method Bits Quality Speed Notes
GGUF Q4_K_M 4.x Good Fast (CPU+GPU) llama.cpp format
GPTQ 4 Good GPU only Uses calibration dataset
AWQ 4 Very good GPU only Activation-aware, better than GPTQ
bitsandbytes 4/8 Good Moderate Easiest to apply
FP8 8 Excellent Very fast on H100 Requires H100/H200/Ada
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# Loading a quantized model with bitsandbytes (for custom inference)
from transformers import AutoModelForCausalLM, BitsAndBytesConfig
import torch

bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_compute_dtype=torch.bfloat16,
    bnb_4bit_use_double_quant=True,   # Nested quantization for extra savings
    bnb_4bit_quant_type="nf4",        # NormalFloat4: better than regular int4
)

model = AutoModelForCausalLM.from_pretrained(
    "meta-llama/Llama-3.1-8B-Instruct",
    quantization_config=bnb_config,
    device_map="auto",
)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# Apply AWQ quantization (requires calibration data)
pip install autoawq

python -c "
from awq import AutoAWQForCausalLM
from transformers import AutoTokenizer

model_path = 'meta-llama/Llama-3.1-8B-Instruct'
quant_path = 'llama-3.1-8b-awq'

model = AutoAWQForCausalLM.from_pretrained(model_path)
tokenizer = AutoTokenizer.from_pretrained(model_path)

quant_config = {
    'zero_point': True,
    'q_group_size': 128,
    'w_bit': 4,
    'version': 'GEMM'
}

model.quantize(tokenizer, quant_config=quant_config)
model.save_quantized(quant_path)
tokenizer.save_pretrained(quant_path)
"

# Now serve the AWQ model with vLLM
vllm serve ./llama-3.1-8b-awq --quantization awq

FP8 on H100/H200

If you have H100 hardware, FP8 is the best balance of quality and speed:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# vLLM automatically uses FP8 on H100 with --dtype float8
vllm serve meta-llama/Llama-3.1-70B-Instruct \
  --dtype float8 \
  --tensor-parallel-size 4

# Or with explicit FP8 quantization
vllm serve meta-llama/Llama-3.1-70B-Instruct \
  --quantization fp8 \
  --kv-cache-dtype fp8 \
  --tensor-parallel-size 4

OpenAI-Compatible API Patterns

Both vLLM and llama.cpp implement the OpenAI API spec, so you can use any OpenAI client library:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import openai
from typing import Iterator

# Point to your local server
client = openai.OpenAI(
    base_url="http://localhost:8000/v1",
    api_key="not-needed-but-required",  # vLLM requires an API key field
)

# List available models
models = client.models.list()
for model in models.data:
    print(f"  {model.id}")

# Streaming chat completion
def stream_chat(prompt: str) -> Iterator[str]:
    stream = client.chat.completions.create(
        model="mistral-7b",
        messages=[{"role": "user", "content": prompt}],
        stream=True,
        temperature=0.7,
        max_tokens=1024,
    )
    for chunk in stream:
        if chunk.choices[0].delta.content:
            yield chunk.choices[0].delta.content

for token in stream_chat("Explain Kubernetes networking in detail"):
    print(token, end="", flush=True)

# Embeddings (vLLM supports embedding models too)
embedding_client = openai.OpenAI(
    base_url="http://localhost:8001/v1",  # separate embedding server
    api_key="key",
)

response = embedding_client.embeddings.create(
    model="BAAI/bge-m3",
    input=["text to embed", "another text"],
)
print(f"Embedding dim: {len(response.data[0].embedding)}")

# Structured outputs (JSON mode)
response = client.chat.completions.create(
    model="mistral-7b",
    messages=[{
        "role": "user",
        "content": "Extract name, age, and city from: John Smith, 34, lives in Seattle"
    }],
    response_format={"type": "json_object"},
    max_tokens=256,
)
import json
data = json.loads(response.choices[0].message.content)
print(data)  # {"name": "John Smith", "age": 34, "city": "Seattle"}

Guided Generation with vLLM

vLLM supports constrained decoding — force outputs to match a JSON schema, regex, or grammar:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from vllm import LLM, SamplingParams
from vllm.sampling_params import GuidedDecodingParams

llm = LLM(model="mistralai/Mistral-7B-Instruct-v0.3")

# JSON schema enforcement
schema = {
    "type": "object",
    "properties": {
        "hostname": {"type": "string"},
        "ip_address": {"type": "string", "pattern": r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$"},
        "status": {"type": "string", "enum": ["healthy", "degraded", "down"]},
        "cpu_percent": {"type": "number", "minimum": 0, "maximum": 100}
    },
    "required": ["hostname", "ip_address", "status", "cpu_percent"]
}

sampling_params = SamplingParams(
    temperature=0.1,
    max_tokens=200,
    guided_decoding=GuidedDecodingParams(json=schema)
)

outputs = llm.generate(
    ["Generate a server status report for web-01.prod.example.com"],
    sampling_params
)
print(outputs[0].outputs[0].text)
# Guaranteed valid JSON matching the schema

Benchmarking Throughput

Never guess at performance — measure it. Key metrics:

Metric Description What Matters
TTFT Time To First Token Latency for user
TPOT Time Per Output Token Generation speed
Throughput Tokens/second across all requests Capacity planning
Concurrency Requests served simultaneously Cost efficiency

vLLM Benchmark Tool

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# Install benchmarking tool
git clone https://github.com/vllm-project/vllm
cd vllm

# Basic throughput benchmark
python benchmarks/benchmark_throughput.py \
  --backend vllm \
  --model mistralai/Mistral-7B-Instruct-v0.3 \
  --input-len 512 \
  --output-len 128 \
  --num-prompts 200 \
  --dtype bfloat16

# Latency benchmark (single request, no batching)
python benchmarks/benchmark_latency.py \
  --model mistralai/Mistral-7B-Instruct-v0.3 \
  --input-len 512 \
  --output-len 128 \
  --num-iters 50

# Serving benchmark (tests the HTTP API with concurrent requests)
python benchmarks/benchmark_serving.py \
  --backend openai-chat \
  --base-url http://localhost:8000 \
  --model mistralai/Mistral-7B-Instruct-v0.3 \
  --request-rate 10 \     # Requests per second (Poisson arrival)
  --num-prompts 200 \
  --sharegpt-path ./ShareGPT_V3_unfiltered_cleaned_split.json  # Realistic prompts

Custom Benchmark Script

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
import asyncio
import time
import statistics
import aiohttp
from dataclasses import dataclass

@dataclass
class BenchmarkResult:
    ttft_ms: float
    total_ms: float
    output_tokens: int
    tpot_ms: float

async def single_request(
    session: aiohttp.ClientSession,
    url: str,
    prompt: str,
    max_tokens: int = 256,
) -> BenchmarkResult:
    payload = {
        "model": "mistral-7b",
        "messages": [{"role": "user", "content": prompt}],
        "max_tokens": max_tokens,
        "stream": True,
    }

    start = time.perf_counter()
    ttft = None
    output_tokens = 0

    async with session.post(f"{url}/v1/chat/completions", json=payload) as resp:
        async for line in resp.content:
            line = line.decode().strip()
            if not line.startswith("data: "):
                continue
            data = line[6:]
            if data == "[DONE]":
                break

            if ttft is None:
                ttft = (time.perf_counter() - start) * 1000
            output_tokens += 1

    total_ms = (time.perf_counter() - start) * 1000
    tpot_ms = (total_ms - ttft) / max(output_tokens - 1, 1) if output_tokens > 1 else 0

    return BenchmarkResult(
        ttft_ms=ttft or 0,
        total_ms=total_ms,
        output_tokens=output_tokens,
        tpot_ms=tpot_ms,
    )

async def benchmark(url: str, concurrency: int = 10, total_requests: int = 100):
    prompts = [
        "Explain Kubernetes networking in detail.",
        "Write a Python function to parse a CIDR range.",
        "What is the difference between TCP and UDP?",
        "Describe the CAP theorem with examples.",
    ] * (total_requests // 4 + 1)
    prompts = prompts[:total_requests]

    semaphore = asyncio.Semaphore(concurrency)
    results = []

    async def run_with_semaphore(prompt):
        async with semaphore:
            async with aiohttp.ClientSession() as session:
                return await single_request(session, url, prompt)

    start = time.perf_counter()
    tasks = [run_with_semaphore(p) for p in prompts]
    results = await asyncio.gather(*tasks)
    wall_time = time.perf_counter() - start

    ttfts = [r.ttft_ms for r in results]
    tpots = [r.tpot_ms for r in results if r.tpot_ms > 0]
    total_tokens = sum(r.output_tokens for r in results)

    print(f"\n=== Benchmark Results ===")
    print(f"Concurrency:     {concurrency}")
    print(f"Total requests:  {total_requests}")
    print(f"Wall time:       {wall_time:.1f}s")
    print(f"Throughput:      {total_tokens / wall_time:.1f} tok/s")
    print(f"\nTTFT (ms):")
    print(f"  P50:  {statistics.median(ttfts):.0f}")
    print(f"  P95:  {sorted(ttfts)[int(len(ttfts)*0.95)]:.0f}")
    print(f"  P99:  {sorted(ttfts)[int(len(ttfts)*0.99)]:.0f}")
    print(f"\nTPOT (ms/token):")
    print(f"  P50:  {statistics.median(tpots):.1f}")
    print(f"  P95:  {sorted(tpots)[int(len(tpots)*0.95)]:.1f}")

asyncio.run(benchmark("http://localhost:8000", concurrency=16, total_requests=200))

Expected Performance by Hardware

Hardware Model Quantization Throughput TTFT (512 in)
A100 80GB Llama 3.1 70B BF16 (TP=1) ~3,000 tok/s ~200ms
A100 80GB Llama 3.1 8B BF16 ~12,000 tok/s ~50ms
RTX 4090 Llama 3.1 8B Q4_K_M ~3,500 tok/s ~80ms
RTX 3080 Llama 3.1 8B Q4_K_M ~1,500 tok/s ~150ms
M3 Max (96GB) Llama 3.1 8B Q4_K_M ~800 tok/s ~100ms
CPU (32-core) Llama 3.1 8B Q4_K_M ~200 tok/s ~500ms

Throughput numbers are for single-request latency mode. With batching, GPU numbers increase 3-10×.


Production Deployment

Docker Compose Stack

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# docker-compose.yml
services:
  vllm:
    image: vllm/vllm-openai:latest
    runtime: nvidia
    environment:
      - NVIDIA_VISIBLE_DEVICES=all
      - HF_TOKEN=${HF_TOKEN}
    volumes:
      - huggingface-cache:/root/.cache/huggingface
    command: >
      --model mistralai/Mistral-7B-Instruct-v0.3
      --dtype bfloat16
      --max-model-len 8192
      --gpu-memory-utilization 0.90
      --enable-prefix-caching
      --max-num-seqs 128
      --api-key ${VLLM_API_KEY}
      --served-model-name mistral-7b
    ports:
      - "8000:8000"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 120s  # Model loading takes time
    restart: unless-stopped
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]

  nginx:
    image: nginx:alpine
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    ports:
      - "80:80"
    depends_on:
      vllm:
        condition: service_healthy

volumes:
  huggingface-cache:

Kubernetes Deployment

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
apiVersion: apps/v1
kind: Deployment
metadata:
  name: vllm-inference
  namespace: ai-inference
spec:
  replicas: 2
  selector:
    matchLabels:
      app: vllm-inference
  template:
    metadata:
      labels:
        app: vllm-inference
    spec:
      containers:
      - name: vllm
        image: vllm/vllm-openai:latest
        args:
        - "--model"
        - "mistralai/Mistral-7B-Instruct-v0.3"
        - "--dtype"
        - "bfloat16"
        - "--max-model-len"
        - "8192"
        - "--gpu-memory-utilization"
        - "0.90"
        - "--enable-prefix-caching"
        - "--max-num-seqs"
        - "128"
        env:
        - name: HF_TOKEN
          valueFrom:
            secretKeyRef:
              name: hf-credentials
              key: token
        - name: VLLM_API_KEY
          valueFrom:
            secretKeyRef:
              name: vllm-credentials
              key: api-key
        ports:
        - containerPort: 8000
        resources:
          requests:
            nvidia.com/gpu: "1"
            memory: "24Gi"
            cpu: "4"
          limits:
            nvidia.com/gpu: "1"
            memory: "32Gi"
            cpu: "8"
        livenessProbe:
          httpGet:
            path: /health
            port: 8000
          initialDelaySeconds: 120
          periodSeconds: 30
        readinessProbe:
          httpGet:
            path: /health
            port: 8000
          initialDelaySeconds: 120
          periodSeconds: 10
        volumeMounts:
        - name: hf-cache
          mountPath: /root/.cache/huggingface
      volumes:
      - name: hf-cache
        persistentVolumeClaim:
          claimName: hf-model-cache
      nodeSelector:
        nvidia.com/gpu.product: NVIDIA-A100-SXM4-80GB
---
apiVersion: v1
kind: Service
metadata:
  name: vllm-inference
  namespace: ai-inference
spec:
  selector:
    app: vllm-inference
  ports:
  - port: 80
    targetPort: 8000
---
# KEDA autoscaling based on queue depth
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: vllm-scaledobject
  namespace: ai-inference
spec:
  scaleTargetRef:
    name: vllm-inference
  minReplicaCount: 1
  maxReplicaCount: 8
  triggers:
  - type: prometheus
    metadata:
      serverAddress: http://prometheus:9090
      metricName: vllm_num_requests_waiting
      threshold: "10"
      query: avg(vllm:num_requests_waiting:rate5m)

Monitoring vLLM with Prometheus

vLLM exposes rich metrics at /metrics:

1
2
3
4
5
6
# prometheus scrape config
scrape_configs:
  - job_name: vllm
    static_configs:
      - targets: ['vllm:8000']
    metrics_path: /metrics

Key metrics to alert on:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# GPU KV cache utilization (alert if >90%)
vllm:gpu_cache_usage_perc > 0.90

# Request queue depth (alert if requests piling up)
vllm:num_requests_waiting > 20

# P99 TTFT (alert if latency spikes)
histogram_quantile(0.99, rate(vllm:time_to_first_token_seconds_bucket[5m])) > 2.0

# Generation throughput (alert if drops significantly)
rate(vllm:generation_tokens_total[5m]) < 100

# Request error rate
rate(vllm:request_failure_total[5m]) / rate(vllm:request_success_total[5m]) > 0.01
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Grafana dashboard panels
- title: "Token Throughput"
  expr: rate(vllm:generation_tokens_total[1m])

- title: "P50/P95/P99 TTFT"
  expr: |
    histogram_quantile(0.50, rate(vllm:time_to_first_token_seconds_bucket[5m]))
    histogram_quantile(0.95, rate(vllm:time_to_first_token_seconds_bucket[5m]))
    histogram_quantile(0.99, rate(vllm:time_to_first_token_seconds_bucket[5m]))

- title: "KV Cache Utilization"
  expr: vllm:gpu_cache_usage_perc * 100

- title: "Active vs Waiting Requests"
  expr: |
    vllm:num_requests_running
    vllm:num_requests_waiting

Choosing the Right Setup

Decision Tree

Do you have NVIDIA GPU(s) with ≥16GB VRAM?
├── Yes → Use vLLM
│   ├── Single GPU, ≤13B model → basic vLLM serve
│   ├── Multiple GPUs, 70B+ → vLLM + tensor parallelism
│   └── Production traffic → vLLM + Nginx + KEDA autoscaling
└── No → Use llama.cpp
    ├── Apple Silicon → llama.cpp with Metal (-ngl 99)
    ├── Consumer GPU <16GB → llama.cpp with partial GPU offload
    ├── CPU only → llama.cpp --n-gpu-layers 0
    └── Just want something easy → Ollama (llama.cpp wrapper)

How much do you care about throughput?
├── High (batch processing, many users) → vLLM, prioritize throughput settings
├── Low (personal use, experiments) → llama.cpp or Ollama
└── Medium (small team) → vLLM with latency-optimized settings

What model size?
├── 7-8B → Single A10G/RTX 4090 in BF16, or consumer GPU in Q4
├── 13-14B → Single A100 40GB, or dual-GPU setup
├── 34-70B → Multi-GPU (2-4× A100 80GB) or aggressive quantization
└── 405B+ → 8+ H100s, tensor + pipeline parallelism

Quantization Decision Guide

Priority: Quality first, size second
→ Use Q6_K (llama.cpp) or FP8 (vLLM on H100)

Priority: Size first, quality second
→ Use Q4_K_M (llama.cpp) or AWQ 4-bit (vLLM)

Extremely VRAM-limited
→ Q3_K_M (noticeable but tolerable quality loss for many tasks)
→ Consider a smaller model instead (8B Q4 > 70B Q2)

Production serving on A100/H100
→ BF16 (best quality, fits on A100 80GB for 13B)
→ AWQ 4-bit (for 70B on 4×A100)
→ FP8 (best trade-off on H100)

Common Issues and Fixes

Model won’t load (CUDA OOM):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Reduce context length
--max-model-len 4096  # instead of 32768

# Reduce memory reservation
--gpu-memory-utilization 0.80  # instead of 0.90

# Use quantization
--quantization awq

# Add a GPU
--tensor-parallel-size 2

Slow first request (model not warmed up):

1
2
3
4
# Pre-warm with a dummy request after startup
curl -s http://localhost:8000/v1/completions \
  -d '{"model": "m", "prompt": "warmup", "max_tokens": 1}' \
  -H "Content-Type: application/json"

Streaming not working:

1
2
3
# Ensure proxy isn't buffering
proxy_buffering off;  # nginx
# Or bypass proxy for inference endpoint

llama.cpp running slowly on CPU:

1
2
3
4
5
# Build with CPU-specific optimizations
CMAKE_ARGS="-DGGML_AVX2=ON -DGGML_FMA=ON -DGGML_F16C=ON" pip install llama-cpp-python

# Check CPU features
grep -m1 flags /proc/cpuinfo | grep -o 'avx2\|avx512f'

Context length exceeds model’s training context:

1
2
3
4
# Enable RoPE scaling for longer contexts
vllm serve model \
  --rope-scaling '{"type": "dynamic", "factor": 2.0}' \
  --max-model-len 16384  # 2× the model's native 8192

The self-hosted inference landscape moves fast. vLLM releases significant performance improvements every few weeks, and new quantization formats (like GGUF’s newer k-quants) continuously improve the quality-vs-size frontier. Pin your versions in production, but stay close to upstream for development.


Filed under: AI & ML Infrastructure

Comments