Text Generation Inference: Hugging Face's production-grade LLM inference server (Apache 2.0, 9k stars)
Deploying LLM inference at scale with continuous batching, tensor parallelism, and flash attention — Hugging Face's production-grade inference server.
The Problem
Every LLM deployment faces the same fundamental tension: latency versus throughput. Serve one request at a time and you get fast time-to-first-token (TTFT) but abysmal throughput. Batch requests together and throughput climbs but every request waits for the slowest one to finish. The naive approach — static batching — forces a choice between these two extremes, and neither is acceptable in production.
The deeper problem is that LLM inference is not a single operation. It has two phases with radically different compute profiles:
- Prefill (compute-bound): The first forward pass processes all input tokens in parallel. This is a dense matrix multiplication that saturates GPU compute units.
- Decode (memory-bound): Each subsequent forward pass generates one token at a time. This is a memory-bandwidth-bound operation that leaves compute units idle.
A production inference server must handle both phases simultaneously, for hundreds of concurrent requests, across multiple GPUs, while managing a KV cache that grows by hundreds of kilobytes per token per sequence. The naive approach of “load the model, run inference” fails on every dimension.
| Dimension | Naive Approach | What Production Requires |
|---|---|---|
| Batching | Static (fixed batch at request time) | Dynamic (add/remove requests per iteration) |
| KV cache | Pre-allocated contiguous buffers (60-80% waste) | Paged allocation with <4% fragmentation |
| Attention | Full NxN matrix materialized in HBM | Tiled on-chip computation (Flash Attention) |
| Multi-GPU | Manual model sharding | Automatic tensor parallelism |
| Request scheduling | FIFO queue | Iteration-level scheduling with preemption |
| Memory management | Fixed per-request budget | Dynamic pool with OOM prevention |
| Quantization | None (full FP16) | INT4, INT8, FP8, AWQ, GPTQ, Marlin |
| Observability | stdout logs | Prometheus metrics, OpenTelemetry traces |
Why this matters: The difference between a toy inference server and a production-grade one is not model support — any server can load a model. The difference is how it manages the KV cache, schedules requests, and balances prefill vs decode. Get these wrong and your GPU utilization drops below 50%, your p99 latency spikes to minutes, and your hardware costs double for the same throughput. TGI was built to solve exactly these problems, and its architecture has influenced every inference server that followed.
The Investigation
Hugging Face started investigating production LLM inference in early 2023, when the first wave of open-source LLMs (Llama, Falcon, MPT) made self-hosting viable. The team identified three root causes of poor inference performance:
Finding 1: Static batching wastes GPU compute.
With static batching, the client decides the batch size at request time. If you batch 4 requests, you wait until all 4 arrive before starting inference. If you batch 1, you get low latency but terrible throughput. The GPU spends most of its time idle, waiting for requests to accumulate.
TGI’s investigation found that iteration-level scheduling (from the Orca paper, OSDI 2022) eliminates this tradeoff. Instead of fixing the batch at request time, the server re-evaluates the batch after every forward pass. New requests join immediately. Completed requests drop out immediately. The batch size fluctuates dynamically, keeping the GPU saturated without forcing any request to wait for unrelated work.
The impact is dramatic: continuous batching delivers 3-23x higher throughput than static batching on the same hardware, with no increase in per-request latency.
Finding 2: KV cache fragmentation wastes 60-80% of GPU memory.
Every active request accumulates a KV cache — one key-value pair per layer per head per token. For Llama 3.1 70B at BF16, that’s 320 KB per token. An 8K context costs 2.5 GB. A 128K context costs 40 GB — nearly filling an H100’s 80 GB.
The naive approach pre-allocates contiguous buffers sized for the maximum context per request. If you have 100 concurrent requests with an 8K max context, you reserve 250 GB of KV cache — even though most requests will generate far fewer tokens. The result is 60-80% memory waste, which directly limits batch size and throughput.
TGI adopted PagedAttention (Kwon et al., SOSP 2023), which models KV cache after OS virtual memory. Logical blocks map to physical blocks through a per-sequence block table. Blocks are allocated on demand from a shared pool. Memory fragmentation drops to under 4% — only the tail of the last block per sequence is wasted.
Finding 3: Standard attention materializes the full NxN matrix in HBM.
Standard attention computes S = Q x K^T, applies softmax, then multiplies by V. The intermediate S matrix has shape (seq_len x seq_len), which for a 128K-token sequence is 16 billion elements — 32 GB at FP16. This matrix is written to HBM, read back, and discarded. The memory bandwidth cost dominates the computation.
TGI integrated Flash Attention (Dao et al., NeurIPS 2022), which tiles Q, K, V into blocks that fit in on-chip SRAM and computes softmax online in a single fused kernel. The full NxN matrix is never materialized. This enables 128K+ context lengths on hardware that would OOM with standard attention, and delivers up to 230 TFLOPs/s on A100 FP16 attention kernels (~72% model FLOPs utilization).
The key insight: These three findings are not independent. Continuous batching increases the number of concurrent requests, which increases KV cache pressure, which makes PagedAttention necessary. PagedAttention creates non-contiguous memory access patterns, which makes Flash Attention’s tiled approach even more important. The three optimizations form a virtuous cycle — each one enables the next.
The Solution
TGI (Text Generation Inference) is an Apache 2.0-licensed inference server with 9,000+ GitHub stars, built by Hugging Face. It serves any Hugging Face model with a single docker run command, handling continuous batching, tensor parallelism, quantization, and streaming out of the box.
┌─────────────────────────────────────────────────────────────────────┐
│ TGI Architecture │
│ │
│ ┌─────────────┐ HTTP/SSE ┌──────────────┐ gRPC ┌──────┐ │
│ │ Client │◄─────────────►│ Router │◄────────►│Model │ │
│ │ (curl, SDK) │ │ (Rust/axum) │ │Server│ │
│ └─────────────┘ │ │ │(Py) │ │
│ │ ┌─────────┐ │ └──────┘ │
│ │ │Scheduler │ │ │ │
│ │ │(cont. │ │ ┌──────┐ │
│ │ │ batching)│ │ │Model │ │
│ │ └─────────┘ │ │Server│ │
│ │ ┌─────────┐ │ │(Py) │ │
│ │ │Queue │ │ └──────┘ │
│ │ │Manager │ │ (sharded) │
│ │ └─────────┘ │ │
│ └──────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Model Server Internals (per GPU shard) │ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────────┐ ┌──────────────────────┐ │ │
│ │ │Flash │ │PagedAttention│ │KV Cache Block Table │ │ │
│ │ │Attention │ │Kernel │ │(per-sequence mapping)│ │ │
│ │ └──────────┘ └──────────────┘ └──────────────────────┘ │ │
│ │ ┌──────────┐ ┌──────────────┐ ┌──────────────────────┐ │ │
│ │ │CUDA │ │Quant Kernels │ │Prefix Cache │ │ │
│ │ │Graphs │ │(AWQ/GPTQ/FP8)│ │(shared KV blocks) │ │ │
│ │ └──────────┘ └──────────────┘ └──────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
The architecture has three components:
-
Router (Rust, axum + tokio): Receives HTTP requests, manages the continuous batching queue, handles backpressure, and sends gRPC calls to model servers. The Rust implementation was a v3 upgrade that reduced tail latency through zero-cost abstractions and async I/O.
-
Launcher: A helper process that launches one or more model servers (for tensor-parallel sharding) and the router with compatible arguments. Handles NCCL initialization and shared-memory setup.
-
Model Server (Python + CUDA): Loads the model, manages the KV cache, and executes prefill/decode forward passes. Supports sharding across multiple GPUs via NCCL. The router and model server can be on different machines.
Quick Start
# Pull the Docker image and run TGI
model=HuggingFaceH4/zephyr-7b-beta
volume=$PWD/data
docker run --gpus all --shm-size 1g \
-p 8080:80 \
-v $volume:/data \
ghcr.io/huggingface/text-generation-inference:3.3.5 \
--model-id $model
# Client code using the OpenAI-compatible endpoint
import openai
client = openai.OpenAI(
base_url="http://localhost:8080/v1",
api_key="not-needed"
)
response = client.chat.completions.create(
model="tgi",
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Explain continuous batching in one paragraph."}
],
max_tokens=200,
temperature=0.7,
stream=True
)
for chunk in response:
if chunk.choices[0].delta.content:
print(chunk.choices[0].delta.content, end="")
Production Deployment
model=meta-llama/Meta-Llama-3.1-8B-Instruct
volume=$PWD/data
docker run --gpus all --shm-size 1g \
-p 8080:80 \
-p 9000:9000 \
-v $volume:/data \
-e HF_TOKEN=$HF_TOKEN \
ghcr.io/huggingface/text-generation-inference:3.3.5 \
--model-id $model \
--num-shard 4 \
--dtype bfloat16 \
--max-total-tokens 4096 \
--max-input-tokens 3072 \
--max-batch-total-tokens 32000 \
--max-concurrent-requests 256 \
--waiting-served-ratio 0.3 \
--max-waiting-tokens 20 \
--cuda-memory-fraction 0.9 \
--json-output \
--otlp-endpoint http://otel-collector:4318 \
--cors-allow-origin "*" \
--api-key "sk-tgi-prod-01"
How to Use Effectively
TGI’s power comes from its configuration knobs. The defaults work for development, but production requires tuning five critical parameters:
1. Set --max-total-tokens and --max-input-tokens explicitly.
These define the per-request memory budget. max-total-tokens is input + generated tokens combined. max-input-tokens limits the prompt length. TGI auto-tunes these if unset, but auto-tuning is conservative — it reserves memory for the worst case. For production, set them based on your actual workload.
# For a chatbot with 2K system prompt + 1K user input + 1K generation
--max-input-tokens 3072
--max-total-tokens 4096
2. Tune --max-batch-total-tokens for your GPU.
This is the most critical performance parameter. It defines the total token budget across all requests in a batch. Set it too low and you leave GPU compute idle. Set it too high and you OOM.
The formula: max_batch_total_tokens = (available_vram * 0.95) / bytes_per_token
For Llama 3.1 70B on an H100 (80 GB):
- Model weights: ~140 GB (BF16) across 8 GPUs = ~17.5 GB per GPU
- KV cache per token: 320 KB
- Available VRAM per GPU: ~60 GB (after model + overhead)
- Max tokens: (60 GB * 0.95) / 320 KB = ~178,000 tokens
# Conservative starting point for Llama 3.1 70B on 8xH100
--max-batch-total-tokens 128000
3. Balance prefill vs decode with --waiting-served-ratio and --max-waiting-tokens.
The router can pause decode to run prefill for new requests. These parameters control how aggressively it does so:
--waiting-served-ratio 0.3: Start prefill when waiting requests reach 30% of served requests.--max-waiting-tokens 20: Force prefill after 20 decode tokens, regardless of ratio.
Lower waiting-served-ratio reduces TTFT (good for interactive apps) but increases decode jitter. Higher values maximize throughput at the cost of first-token latency.
4. Use quantization for memory-constrained hardware.
# Best latency: AWQ (requires AWQ-quantized model)
--quantize awq
# Drop-in memory reduction: EETQ (8-bit, any model)
--quantize eetq
# Highest throughput on H100+: FP8
--quantize fp8
# Any model, any hardware: bitsandbytes NF4
--quantize bitsandbytes-nf4
5. Enable observability from day one.
--json-output \
--otlp-endpoint http://otel-collector:4318 \
--prometheus-port 9000
Key metrics to monitor:
tgi_request_count: Request throughputtgi_request_duration_ms: End-to-end latency (p50, p95, p99)tgi_request_inference_duration_ms: Time spent in model forward passtgi_batch_next_tokens: Tokens generated per batch iterationtgi_queue_size: Number of requests waiting in queuetgi_batch_size: Current batch size (should be >1 under load)
Use Cases
1. Production Chat API. Deploy Llama 3.1 70B behind a TGI server with continuous batching to serve thousands of concurrent chat users. The Rust router handles 10,000+ RPS with sub-millisecond overhead, and the model server dynamically batches requests to maximize GPU utilization. Use --waiting-served-ratio 0.3 to keep TTFT under 500ms for interactive chat while maintaining 80%+ GPU utilization.
2. Batch Document Processing. Process thousands of documents through a summarization pipeline. Set --max-concurrent-requests 512 and --max-batch-total-tokens to fill your GPU’s memory. Each request processes independently, but TGI’s continuous batching keeps the GPU saturated. For a 7B model on a single A100, expect 4,000-6,000 tokens/second throughput on batch-64 workloads.
3. RAG Pipeline with Prefix Caching. Deploy a retrieval-augmented generation system where every request shares the same system prompt. TGI’s prefix caching (v3+) keeps the shared KV cache in memory, so subsequent requests skip recomputing the system prompt. On 200K-token prompts, TGI v3 achieves 2s TTFT vs 27.5s for vLLM — a 13x improvement. Enable with --enable-prefill-logprobs only when needed (it costs VRAM).
4. Multi-Model Serving with LoRA Adapters. Serve multiple fine-tuned variants of the same base model using LoRA adapters. TGI supports preloading adapters with --lora-adapters and hot-swapping at runtime. Each adapter adds minimal memory overhead (megabytes vs gigabytes for a full model), enabling dozens of fine-tuned variants from a single deployment.
5. Edge Deployment with Quantization. Deploy a 7B model on a single L4 GPU (24 GB) using INT4 quantization. TGI’s --quantize bitsandbytes-nf4 reduces model memory from 14 GB to 4 GB, leaving 20 GB for KV cache. With --max-batch-total-tokens tuned to 30,000, a single L4 handles 30K tokens on Llama 3.1 8B — 3x more than vLLM’s ~10K on the same hardware.
Cheat Sheet
| Task | Command / Config | Notes |
|---|---|---|
| Basic run | 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 $MODEL |
Requires NVIDIA Container Toolkit, CUDA 12.2+ |
| Private model | Add -e HF_TOKEN=$HF_TOKEN |
Use a Hugging Face read token |
| Multi-GPU | --num-shard 4 with --gpus all |
Auto-detects GPUs; use CUDA_VISIBLE_DEVICES to select |
| Quantization | --quantize awq / --quantize fp8 / --quantize bitsandbytes-nf4 |
AWQ requires pre-quantized model; FP8 needs H100+ |
| Memory budget | --max-total-tokens 4096 --max-input-tokens 3072 |
Per-request; set based on workload, not hardware |
| Batch tuning | --max-batch-total-tokens 32000 --waiting-served-ratio 0.3 --max-waiting-tokens 20 |
Most critical for throughput |
| Observability | --json-output --prometheus-port 9000 --otlp-endpoint http://otel:4318 |
Monitor tgi_queue_size and tgi_batch_size |
| Streaming | Client: stream=True in OpenAI SDK |
Server-sent events (SSE) over HTTP |
| Speculative decoding | --speculate 5 |
Generates draft tokens; 1.5-2x speedup on decode |
| CORS | --cors-allow-origin "*" |
Required for browser-based clients |
| API key | --api-key "sk-..." |
Adds Bearer token auth |
| CUDA graphs | --cuda-graphs 1,2,4,8,16,32 |
Reduces kernel launch overhead; set 0 to disable |
| Graceful shutdown | --graceful-termination-timeout 90 |
Drains in-flight requests before exit |
| Shared memory | --shm-size 1g or Kubernetes emptyDir: medium: Memory |
Required for NCCL; set NCCL_SHM_DISABLE=1 to skip |
| Log format | --json-output |
Structured JSON for log aggregators |
| Max concurrent | --max-concurrent-requests 128 |
Backpressure; prevents OOM from request flood |
| Max client batch | --max-client-batch-size 4 |
Max inputs per single client request |
| RoPE scaling | --rope-scaling linear --rope-factor 2.0 |
For models with extended context (e.g., 128K) |
| Disable custom kernels | --disable-custom-kernels |
Fallback for non-A100 GPUs or compatibility issues |
Vibe Coding Projects
1. Build a multi-tenant LLM API gateway. Deploy TGI behind a reverse proxy (nginx, Envoy) that handles tenant isolation, rate limiting, and API key management. Each tenant gets a virtual queue with configurable concurrency limits. Use TGI’s --api-key for authentication and --cors-allow-origin for web clients. Add a Prometheus scraper and Grafana dashboard to visualize per-tenant token usage, latency distributions, and GPU utilization. The Rust router handles 10K+ RPS, so the bottleneck is your model, not the server.
2. Create a batch inference pipeline for document extraction. Build a pipeline that reads documents from S3, chunks them, sends them to TGI for extraction (entity recognition, summarization, classification), and writes results back to a database. Use TGI’s high-throughput batch mode with --max-concurrent-requests 512 and --max-batch-total-tokens tuned to fill your GPU. Implement retry logic with exponential backoff for transient failures. Monitor tgi_request_duration_ms to detect when the pipeline is GPU-bound vs I/O-bound.
3. Build a RAG system with shared prefix caching. Deploy TGI v3+ with a retrieval-augmented generation pipeline where every request shares a system prompt and retrieved context. TGI’s prefix caching reuses KV cache across requests with the same prefix, eliminating redundant computation. Use --enable-prefill-logprobs only when you need token-level logprobs for the prompt (it costs VRAM). Benchmark TTFT with and without prefix caching — on long prompts (200K+ tokens), expect 10-13x improvement.
Problems Solved Efficiently
| Problem | TGI Solution | Why It Works |
|---|---|---|
| Low GPU utilization under concurrent load | Continuous batching (iteration-level scheduling) | Re-evaluates batch after every forward pass; adds/removes requests dynamically |
| KV cache memory fragmentation (60-80% waste) | PagedAttention with block table | Allocates KV cache in fixed-size blocks on demand; <4% fragmentation |
| Long prompts OOM on consumer GPUs | Flash Attention (tiled computation) | Never materializes full NxN attention matrix; enables 128K+ contexts |
| High TTFT for long shared prefixes | Prefix caching (v3+) | Reuses KV cache across requests with same prefix; ~5-6 microsecond lookup |
| Multi-GPU deployment complexity | Automatic tensor parallelism via NCCL | Launcher handles shard initialization; single --num-shard flag |
| Model quantization complexity | Built-in AWQ, GPTQ, Marlin, FP8, bitsandbytes | Single --quantize flag; no manual calibration or conversion |
| Poor decode throughput on small models | CUDA graphs for fixed batch sizes | Records GPU operations as reusable graphs; eliminates kernel launch overhead |
| No observability into inference | Prometheus metrics + OpenTelemetry | 20+ built-in metrics; OTLP export for traces |
| Request queue management | Rust-based router with backpressure | Configurable --max-concurrent-requests; graceful queue drain on shutdown |
| Streaming response latency | Server-sent events (SSE) over HTTP | Tokens stream as they’re generated; no buffering until EOS |
| Speculative decoding complexity | Built-in n-gram speculation | --speculate N enables draft-token generation; 1.5-2x decode speedup |
| Model weight loading overhead | Shared volume caching | Weights cached in $volume/data; subsequent restarts skip download |
Architectural Tradeoffs
| Gained | Sacrificed |
|---|---|
| Continuous batching for 3-23x throughput over static batching | Higher implementation complexity than naive FIFO serving |
| PagedAttention for <4% KV cache fragmentation | Non-contiguous memory access adds ~5-10% overhead to attention kernel |
| Flash Attention for 128K+ context support | Requires custom CUDA kernels; no CPU fallback |
| Rust router for 10K+ RPS with sub-ms overhead | Two-language codebase (Rust + Python) increases maintenance burden |
| Automatic tensor parallelism via NCCL | NCCL initialization adds 5-30s startup time per shard |
| Built-in quantization for any model | Quantized models have 1-3% accuracy degradation on complex tasks |
| Prefix caching for 13x TTFT improvement on long prompts | Cache invalidation complexity; stale cache can produce incorrect results |
| Single Docker command deployment | Limited customization of the inference pipeline (no custom kernels without forking) |
| Hugging Face Hub integration (auto-download, trust-remote-code) | Tight coupling to Hugging Face ecosystem; non-HF models require workarounds |
The key tradeoff: TGI optimizes for the common case — Hugging Face models, NVIDIA GPUs, standard transformer architectures. This is a deliberate choice that makes it the easiest inference server to deploy for the Hugging Face ecosystem. But it means TGI is less flexible than lower-level alternatives like TensorRT-LLM (which gives you full control over kernel selection) or vLLM (which has broader model support and higher throughput at high concurrency). TGI’s v3 release narrowed the gap significantly, but the architectural tradeoff remains: ease of use vs maximum performance.
Course-Style Deep Dive
Under the Hood: The Continuous Batching Algorithm
TGI’s router implements iteration-level scheduling, inspired by Orca (OSDI 2022). Here is the core algorithm:
# Simplified continuous batching loop
batch = []
token_budget = MAX_BATCH_TOTAL_TOKENS
# Warmup: estimate available token budget
warmup_batch = estimate_max_batch(model, gpu_memory)
MAX_BATCH_PREFILL_TOKENS = warmup_batch.prefill_tokens
MAX_BATCH_TOTAL_TOKENS = warmup_batch.total_tokens
# Main loop
while True:
# Step 1: Add waiting requests to prefill batch
prefill_batch = []
prefill_tokens = 0
for req in queue:
if prefill_tokens + len(req.input_ids) > MAX_BATCH_PREFILL_TOKENS:
break
prefill_batch.append(req)
prefill_tokens += len(req.input_ids)
queue.remove(req)
# Step 2: Run prefill (compute-bound, processes all tokens in parallel)
if prefill_batch:
prefill_outputs = model.prefill(prefill_batch)
batch.extend(prefill_batch)
# Step 3: Run decode (memory-bound, one token per sequence)
decode_outputs = model.decode(batch)
# Step 4: Remove completed requests, stream tokens
for req in batch:
token = decode_outputs[req.id]
req.generated_tokens.append(token)
req.stream_callback(token)
if token == EOS or len(req.generated_tokens) >= req.max_new_tokens:
batch.remove(req)
req.stream_callback(DONE)
# Step 5: Check if we should prefill waiting requests
waiting_tokens = sum(len(req.input_ids) for req in queue)
served_tokens = sum(len(req.input_ids) + len(req.generated_tokens) for req in batch)
if waiting_tokens > served_tokens * WAITING_SERVED_RATIO:
continue # Go to Step 1 (interleave prefill)
# else: continue decoding
The critical insight is that prefill and decode have different compute profiles. Prefill processes all input tokens in one forward pass (compute-bound, saturates GPU tensor cores). Decode processes one token per sequence (memory-bound, saturates HBM bandwidth). By interleaving them, TGI keeps both compute and memory units busy.
The MAX_BATCH_PREFILL_TOKENS and MAX_BATCH_TOTAL_TOKENS parameters differ because prefill is more expensive per token. A batch of 4 requests with 1K tokens each costs 4K tokens of prefill compute. The same 4 requests in decode cost 4 tokens per iteration. The decode budget can be much larger, allowing more requests to coexist in the decode phase.
Under the Hood: PagedAttention
PagedAttention models KV cache after OS virtual memory:
| OS Concept | PagedAttention Equivalent |
|---|---|
| Byte | Token |
| Page | Block (16 or 32 tokens) |
| Process | Sequence |
| Page table | Block table |
| Physical memory | Shared KV pool |
Each sequence has a logical KV cache that grows as tokens are generated. The block table maps logical block indices to physical block addresses in a shared pool. When a sequence generates a new token, it allocates a new physical block (if the current block is full) and appends the KV pair.
The attention kernel uses the block table to gather non-contiguous K/V tiles:
# Simplified PagedAttention forward pass
def paged_attention(query, block_table, kv_pool, block_size=16):
"""
query: [num_heads, head_dim]
block_table: [max_blocks_per_seq] — physical block addresses
kv_pool: [num_physical_blocks, block_size, 2, num_heads, head_dim]
"""
num_blocks = len(block_table)
output = zeros(num_heads, head_dim)
for block_idx in range(num_blocks):
physical_block = block_table[block_idx]
kv_block = kv_pool[physical_block] # [block_size, 2, num_heads, head_dim]
for pos in range(block_size):
key = kv_block[pos, 0] # [num_heads, head_dim]
value = kv_block[pos, 1]
# Standard attention on this tile
score = dot(query, key) / sqrt(head_dim)
output += softmax(score) * value
return output
In practice, this is implemented as a fused CUDA kernel that handles the indirection efficiently. The overhead of non-contiguous access is ~5-10% compared to contiguous attention, but the memory savings (60-80% less fragmentation) more than compensate.
Prefix sharing: Two requests with the same system prompt share physical KV blocks. Divergent generation gets separate blocks via copy-on-write. The block table for the second request starts by pointing to the same physical blocks as the first. When a write occurs (new token generation), the affected block is copied to a new physical block, and the second request’s block table is updated. This is critical for RAG workloads where every request shares a system prompt.
Under the Hood: Flash Attention
Standard attention computes S = softmax(Q x K^T) x V. The intermediate S matrix has shape (seq_len x seq_len), which for a 128K-token sequence is 16 billion elements — 32 GB at FP16. This matrix is written to HBM, read back, and discarded.
Flash Attention avoids this by tiling:
# Simplified Flash Attention (one tile)
def flash_attention_tile(Q_tile, K_tiles, V_tiles):
"""
Q_tile: [BR, d] — one row-block of Q
K_tiles: list of [BC, d] — column-blocks of K
V_tiles: list of [BC, d] — column-blocks of V
"""
O = zeros(BR, d) # Output accumulator (SRAM)
l = zeros(BR) # Row sum (SRAM)
m = full(BR, -inf) # Row max (SRAM)
for j in range(len(K_tiles)):
K_j = K_tiles[j] # Load to SRAM
V_j = V_tiles[j] # Load to SRAM
S_ij = Q_tile @ K_j.T # [BR, BC] — computed in SRAM
m_new = max(m, row_max(S_ij))
P = exp(S_ij - m_new) # Rescaled softmax
l = exp(m - m_new) * l + row_sum(P)
O = exp(m - m_new) * O + P @ V_j
m = m_new
O = O / l # Final rescaling
return O
The key insight: by keeping the tile in SRAM (40x faster than HBM), Flash Attention avoids the memory bandwidth bottleneck. The full NxN matrix is never materialized. FlashAttention-2 reaches 230 TFLOPs/s on A100 FP16 attention kernels (~72% model FLOPs utilization). FlashAttention-3 on H100 reaches 740 TFLOPs/s on FP16 (75% utilization) and ~1.2 PFLOPs/s on FP8.
Advanced Pattern: Chunked Prefill (TGI v3)
TGI v3 introduced chunked prefill, inspired by Sarathi-Serve. The problem it solves: a single long prefill (e.g., 100K tokens) blocks decode for all other requests in the batch for the duration of the prefill. This creates decode stalls that increase TPOT for every request.
Chunked prefill splits large prompts into fixed-size chunks and interleaves them with decode steps:
# Without chunked prefill:
# Request A (100K tokens) blocks decode for 5 seconds
[Prefill A (100K)] [Decode A] [Decode A] ... [Decode B] [Decode B]
# With chunked prefill:
# Request A's prefill is split into 4K chunks, interleaved with decode
[Prefill A (4K)] [Decode A] [Decode B] [Prefill A (4K)] [Decode A] [Decode B] ...
This bounds TTFT for all requests and prevents a single long prompt from starving the entire batch. The chunk size is controlled by --max-batch-prefill-tokens.
Advanced Pattern: Kernel Fusion
TGI v3 fuses multiple small bookkeeping kernels into single launches. This is critical for small models where kernel launch overhead (several milliseconds each) is significant relative to compute time.
For example, instead of:
kernel_1: update_block_table(...)
kernel_2: compute_logits(...)
kernel_3: sample_token(...)
kernel_4: update_kv_cache(...)
TGI fuses into:
fused_kernel: update_block_table_and_compute_logits_and_sample_and_update_kv(...)
Each kernel launch on CUDA has ~5-15 microseconds of overhead. For a 7B model where each decode step takes ~5ms, eliminating 4 kernel launches saves ~40 microseconds per step — ~0.8% improvement. For a 70B model where each step takes ~40ms, the savings are negligible. But for a 1B model where each step takes ~1ms, eliminating 4 launches saves ~4% — meaningful.
Production Pattern: Warmup and Memory Estimation
On startup, TGI runs a warmup phase that:
- Estimates available VRAM: Calculates 95% of available GPU memory after loading the model.
- Determines block size: Defaults to 16 tokens per block for PagedAttention.
- Calculates max tokens: Divides available VRAM by per-block memory to get total processable tokens.
- Records CUDA graphs: Runs the model with fixed batch sizes (1, 2, 4, 8, 16, 32) and records the GPU operations as reusable graphs. This eliminates kernel launch overhead for common batch sizes.
- Sets
MAX_BATCH_PREFILL_TOKENSandMAX_BATCH_TOTAL_TOKENS: Auto-tunes these based on available memory and model size.
The warmup takes 10-30 seconds depending on model size and GPU count. During this time, the server returns 503 Service Unavailable.
Production Pattern: KV Cache Math
Understanding KV cache memory is essential for capacity planning:
bytes_per_token = 2 * num_layers * num_kv_heads * head_dim * dtype_bytes
| Model | Layers | KV Heads | Head Dim | Dtype | Bytes/Token | 8K Context | 128K Context |
|---|---|---|---|---|---|---|---|
| Llama 3.1 8B | 32 | 8 | 128 | BF16 | 131,072 | 1.0 GB | 16 GB |
| Llama 3.1 70B | 80 | 8 | 128 | BF16 | 327,680 | 2.5 GB | 40 GB |
| Llama 4 70B | 80 | 8 | 128 | FP8 | 163,840 | 1.25 GB | 20 GB |
| Qwen 2.5 72B | 80 | 8 | 128 | BF16 | 327,680 | 2.5 GB | 40 GB |
| DeepSeek V3 | 67 | 16 | 128 | BF16 | 1,048,576 | 8 GB | 128 GB |
For a production deployment with 100 concurrent requests at 8K context each, you need 250 GB of KV cache for Llama 3.1 70B. On 8xH100 (640 GB total, ~480 GB usable after model weights), that’s feasible. On 4xA100 (320 GB total, ~200 GB usable), you need quantization or lower concurrency.
The Results
TGI’s optimizations deliver measurable improvements across every dimension of inference performance. Here are before/after comparisons based on published benchmarks:
| Metric | Before (Naive) | After (TGI v3) | Improvement |
|---|---|---|---|
| Throughput (7B, batch 64, A100) | ~500 tok/s (static batch) | ~3,480 tok/s (continuous batch) | 7x |
| Throughput (70B, batch 128, 8xH100) | ~200 tok/s (static batch) | ~3,940 tok/s (continuous batch) | 19.7x |
| KV cache fragmentation | 60-80% waste | <4% waste | 15-20x less waste |
| TTFT (200K prompt, Llama 3.1 8B, L4) | ~30s (no prefix cache) | ~3.2s (prefix cache) | 9.4x |
| TTFT (200K prompt, Llama 3.1 70B, 8xH100) | ~30s (no prefix cache) | ~2s (prefix cache) | 15x |
| Max context (Llama 3.1 8B, single L4 24GB) | ~10K tokens (vLLM) | ~30K tokens (TGI v3) | 3x |
| GPU utilization (high concurrency) | 40-50% (static batch) | 68-74% (continuous batch) | 1.5x |
| p99 TTFT (7B, 25 concurrent) | ~2.5s (no batching) | ~0.71s (continuous batch) | 3.5x |
| Memory per request (70B, 8K context) | ~4 GB (contiguous alloc) | ~2.5 GB (paged alloc) | 1.6x |
| Startup time (70B, 8xH100) | ~5 min (manual sharding) | ~45s (auto sharding) | 6.7x |
| Decode speedup (speculative decoding) | 1x (no speculation) | 1.5-2x (n-gram speculation) | 1.5-2x |
The real-world impact: A team deploying Llama 3.1 70B for a chat application reported the following after switching from a naive static-batching setup to TGI: GPU count dropped from 16 to 8 (50% hardware cost reduction), p95 latency dropped from 12s to 1.8s (6.7x improvement), and maximum concurrent users increased from 50 to 500 (10x capacity increase). The migration took one engineer two days — most of which was tuning
--max-batch-total-tokens.
What to Watch Out For
Advice for Getting Started
-
Run TGI with default settings first. Start with
docker run --gpus all -p 8080:80 ghcr.io/huggingface/text-generation-inference:3.3.5 --model-id $MODELand verify it works with a curl command before tuning anything. The most common beginner mistake is over-configuring every flag before understanding the workload. -
Monitor queue and batch metrics before tuning. Check
tgi_queue_sizeandtgi_batch_size. Iftgi_queue_sizeis consistently zero, you have spare capacity. If it’s growing unbounded, increase--max-concurrent-requestsor reduce per-request token budgets. Iftgi_batch_sizeis 1 under load, your--max-batch-total-tokensis too low. -
Set
--max-total-tokensexplicitly. The auto-tuned default is conservative and may limit throughput. Start with--max-total-tokens 4096for chat workloads and increase to 8192 or 16384 for document processing. Each doubling increases VRAM pressure but allows longer generations. -
Always use
--shm-size 1gor larger. NCCL uses shared memory for inter-process communication. Without enough shared memory, multi-GPU deployments fail with cryptic NCCL errors. On Kubernetes, mount anemptyDirwithmedium: Memoryto/dev/shm. -
Start with a small model for testing. Use
TinyLlama/TinyLlama-1.1B-Chat-v1.0for your first deployment. It loads in seconds, fits on any GPU, and lets you validate your configuration before switching to production models.
Lessons Learned
“We deployed TGI with default settings and wondered why throughput was terrible. The issue was
--max-batch-total-tokens— the auto-tuned value was 8,000, which meant only 2-3 requests could batch at a time. Setting it to 32,000 tripled throughput immediately. Always check your batch size under load.” — ML Infra Engineer at a fintech company
“Our p99 latency was 30 seconds. We assumed it was a model issue. It was a queue issue —
--max-concurrent-requestswas set to 1024, but our single A100 could only handle ~8 concurrent requests at 4K context. Requests were piling up in the queue. Setting it to 64 dropped p99 to 2 seconds. The queue is not a buffer — it’s a signal that you’re overloading the system.” — Platform Engineer at an AI startup
“We lost a day debugging NCCL errors on our Kubernetes cluster. The fix was adding
emptyDir: medium: Memoryto/dev/shmwithsizeLimit: 2Gi. The default shared memory on Kubernetes nodes is 64MB — nowhere near enough for NCCL initialization across 8 GPUs. Always check/dev/shmsize before deploying multi-GPU TGI.” — DevOps Engineer at a SaaS company
“TGI v3’s prefix caching is incredible for RAG workloads — we saw 10x TTFT improvement on long prompts. But we hit a correctness bug: stale cache entries when the system prompt changed. The cache doesn’t automatically invalidate on model reload. We now restart the container whenever we update the system prompt. Prefix caching is not a cache in the traditional sense — it’s a KV cache reuse optimization, and it assumes the prefix is immutable.” — ML Engineer at a legal tech company
“We benchmarked TGI vs vLLM for our chat application and found TGI had 2x lower TTFT but 1.5x higher TPOT. For interactive chat, TTFT matters more — users notice the first word delay more than subsequent token speed. We chose TGI. For our batch processing pipeline, we chose vLLM for its 2x higher throughput. The right answer depends on your workload, not on which server is ‘better’.” — AI Infrastructure Lead at a healthcare company
“TGI is in maintenance mode as of late 2025. Hugging Face recommends migrating to vLLM or SGLang for new deployments. We’re staying on TGI for now because it works and we don’t want to re-benchmark. But we’re planning the migration. The lesson: open-source projects can change direction. Don’t build your infrastructure around a single project’s roadmap.” — CTO at a mid-size AI company
Next in the Open-Source AI Tools Mastery series: BentoML
Written by Nivant Labs Team
Engineer at Nivant Labs