ExLlamaV2: The fastest inference engine for quantized Llama/HF models (MIT, 5k stars)
Achieving 140+ tok/s on a single RTX 4090 with 4-bit quantization — the fastest inference engine for quantized Llama/HF models.
The Problem
Every local LLM inference framework makes the same trade: generality for speed. llama.cpp runs everywhere (CPU, GPU, Apple Silicon, Android) but leaves performance on the table for NVIDIA users. vLLM optimizes for production serving with high-throughput batching but adds latency overhead for single-user interactive use. Hugging Face Transformers gives you full model support but runs at a fraction of the speed a consumer GPU can deliver.
The result is a gap: there is no framework that extracts every last token per second from a single NVIDIA GPU running a quantized model. If you own an RTX 4090 and want to run Llama 3.1 70B at interactive speeds, every existing framework leaves 2-5x performance on the table.
| Dimension | ExLlamaV2 | llama.cpp | vLLM | Hugging Face Transformers |
|---|---|---|---|---|
| Single-user throughput (70B Q4, 4090) | 140+ tok/s | ~42 tok/s | ~85 tok/s | ~15 tok/s |
| Quantization formats | EXL2, GPTQ | GGUF | AWQ, GPTQ | FP16, GPTQ |
| GPU-only optimization | Full (CUDA fused kernels) | Partial (Vulkan/Metal backends) | Full (CUDA, PagedAttention) | Partial (CUDA, no fused quant) |
| Multi-GPU tensor parallelism | Yes | Yes | Yes | Yes |
| Multi-user batching | Limited (dynamic batching) | Limited | Excellent (continuous batching) | No |
| Speculative decoding | Yes (draft model + n-gram) | Yes (draft model) | Yes (draft model) | No |
| KV cache quantization | Q4, Q6, Q8, FP16 | FP16 only | FP16 only | FP16 only |
| Paged attention | Yes (256-token pages) | No | Yes (block-level) | No |
| Cross-platform | NVIDIA GPU only | CPU, GPU, Apple, Android | NVIDIA GPU only | CPU, GPU |
| Setup complexity | Moderate (Python + CUDA) | Moderate (C++ build) | High (Python + CUDA + Docker) | Low (pip install) |
Why this matters: The gap between “runs on my GPU” and “runs fast enough to feel interactive” is the difference between a demo and a daily-driver tool. ExLlamaV2 closes that gap by fusing quantization into the CUDA kernel pipeline — no Python overhead, no memory bandwidth waste, no generality tax. If you own an NVIDIA GPU and want the fastest possible local inference, this is the engine.
The Investigation
ExLlamaV2 was created by turboderp (a pseudonymous developer) as the successor to ExLlama V1, which was the first inference engine to support GPTQ 4-bit models on consumer GPUs. The V2 rewrite was motivated by a single question: why is local inference still 5x slower than what the hardware can theoretically deliver?
Finding 1: Python is the bottleneck, not CUDA.
Every existing inference framework (Transformers, vLLM, llama.cpp’s Python bindings) has a Python loop that orchestrates the forward pass: load weights, dequantize, matmul, apply RoPE, attention, repeat. Each step is a separate CUDA kernel launch with Python overhead between them. On an RTX 4090 with 1,000+ GB/s memory bandwidth, the GPU spends most of its time waiting for the next kernel launch command.
ExLlamaV2’s investigation found that fusing the entire decoder layer into a single CUDA kernel eliminates this overhead. The fused kernel loads quantized weights, dequantizes on-the-fly, computes the matrix multiply, applies RoPE, and writes to the KV cache — all in one launch. No Python between layers. No kernel launch latency. Just raw GPU compute.
What this means: The 5x speedup over llama.cpp is not magic — it is the result of eliminating every microsecond of overhead between the GPU and the model weights. Every other framework pays a Python tax per layer. ExLlamaV2 does not.
Finding 2: Quantization should be baked into the kernel, not applied as a pre-processing step.
Most frameworks treat quantization as a data compression step: quantize the weights, store them in a reduced-precision format, then dequantize to FP16 before every matrix multiply. This doubles memory traffic (read quantized, write FP16) and wastes compute cycles.
ExLlamaV2’s approach is different: the CUDA matmul kernel reads quantized weights directly and performs the multiply in the quantized domain. The dequantization is fused into the memory load — the GPU reads 4-bit values from VRAM and immediately uses them in the tensor core pipeline without an intermediate FP16 expansion. This cuts memory bandwidth usage in half and eliminates the dequantization step entirely.
What this means: ExLlamaV2’s Q4 inference is not “FP16 inference with compressed weights” — it is native 4-bit compute. The GPU’s tensor cores operate on 4-bit values directly, achieving 2x the effective throughput of a framework that dequantizes first.
Finding 3: The EXL2 format enables better quality-per-bit than uniform quantization.
GPTQ and GGUF use a single bit-width for every weight in a tensor. This is wasteful: some weights (attention projections, early layers) are more sensitive to quantization error than others (feed-forward layers, late layers). Uniform quantization either over-provisions the insensitive weights (wasting bits) or under-provisions the sensitive ones (losing quality).
EXL2 solves this with mixed-precision quantization: each group of 32-128 weights within a layer can use a different bit-width (2, 3, 4, 5, 6, or 8 bits). The converter runs a combinatorial optimization to assign bit-widths to groups, minimizing the maximum quantization error while hitting a target average bits-per-weight (bpw). The result is measurably better perplexity than uniform quantization at the same average bpw.
What this means: A 3.5 bpw EXL2 model has the quality of a 4.0 bpw GPTQ model, while using 12.5% less memory and running at the same speed. The bits go where they matter most.
The Solution
ExLlamaV2 is a ~25,000-line Python/CUDA/C++ library (MIT license, ~4,600 GitHub stars, now archived in favor of ExLlamaV3) that runs quantized LLMs on NVIDIA GPUs. It supports GPTQ 4-bit and the custom EXL2 format (2-8 bit mixed precision), with fused CUDA kernels that eliminate Python overhead between layers.
┌──────────────────────────────────────────────────────────────────────────┐
│ ExLlamaV2 Architecture │
│ │
│ ┌────────────────────┐ ┌──────────────────────┐ ┌──────────────┐ │
│ │ ExLlamaV2Config │ │ ExLlamaV2 Model │ │ ExLlamaV2 │ │
│ │ (model config) │───▶│ (HF model loader) │───▶│ Tokenizer │ │
│ │ │ │ │ │ │ │
│ │ • model_dir │ │ • load_weights() │ │ • encode() │ │
│ │ • max_seq_len │ │ • forward() │ │ • decode() │ │
│ │ • gpu_peer_size │ │ • architecture.py │ └──────────────┘ │
│ └────────────────────┘ └──────────┬───────────┘ │
│ │ │
│ ┌──────────────┴──────────────┐ │
│ │ Generator Layer │ │
│ │ ┌──────────────────────┐ │ │
│ │ │ ExLlamaV2Generator │ │ │
│ │ │ (basic generation) │ │ │
│ │ ├──────────────────────┤ │ │
│ │ │ ExLlamaV2Streaming │ │ │
│ │ │ Generator (stream) │ │ │
│ │ ├──────────────────────┤ │ │
│ │ │ ExLlamaV2Dynamic │ │ │
│ │ │ Generator (paged, │ │ │
│ │ │ batched, speculative)│ │ │
│ │ └──────────────────────┘ │ │
│ └──────────────┬──────────────┘ │
│ │ │
│ ┌─────────────────────────────────────┴──────────────────────────────┐ │
│ │ CUDA Kernel Layer │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌────────┐ │ │
│ │ │ Q4/Q6/Q8 │ │ Fused QKV │ │ Flash │ │ KV │ │ │
│ │ │ MatMul │ │ Projection │ │ Attention │ │ Cache │ │ │
│ │ │ (on-the-fly │ │ (single │ │ (paged, │ │ (Q4/ │ │ │
│ │ │ dequant) │ │ kernel) │ │ dedup) │ │ Q6/Q8)│ │ │
│ │ └──────────────┘ └──────────────┘ └──────────────┘ └────────┘ │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │ │
│ │ │ Speculative │ │ N-Gram Trie │ │ Paged Attention Manager │ │ │
│ │ │ Decode │ │ (drafting) │ │ (page table, defrag) │ │ │
│ │ └──────────────┘ └──────────────┘ └──────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ Quantization Pipeline (convert.py) │ │
│ │ Pass 1: Measure error per group at 2-8 bits │ │
│ │ Pass 2: Solve combinatorial optimization for target bpw │ │
│ │ Output: EXL2 safetensors with per-group bit-width metadata │ │
│ └────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────┘
Here is what each component does:
- ExLlamaV2Config: Reads the model’s
config.jsonand exposes tuning parameters (max sequence length, GPU split for multi-GPU, rope scaling, attention flags). This is the single source of truth for model topology. - ExLlamaV2 Model: Loads quantized weights from safetensors files, maps them to GPU memory, and provides the
forward()method that runs a single transformer layer. Thearchitecture.pymodule maps HF model types (Llama, Mistral, Qwen2, Gemma, Phi-3, etc.) to ExLlamaV2’s internal representation. - ExLlamaV2 Tokenizer: Wraps the HF tokenizer with fast encode/decode, special token handling, and BOS/EOS management.
- Generator Layer: Three tiers —
ExLlamaV2Generator(basic synchronous generation),ExLlamaV2StreamingGenerator(token-by-token streaming with callbacks), andExLlamaV2DynamicGenerator(paged attention, continuous batching, speculative decoding, prompt caching, KV cache deduplication). - CUDA Kernel Layer: The performance core. Fused Q4/Q6/Q8 matmul kernels, fused QKV projections, paged flash attention, and quantized KV cache operations. Every kernel is hand-tuned for NVIDIA GPU architectures (Ampere, Ada Lovelace, Blackwell).
- Quantization Pipeline: The
convert.pyscript that runs the two-pass measurement-and-solve process to produce EXL2 format models.
Setup
# Install from PyPI
pip install exllamav2
# Or build from source (recommended for latest CUDA kernels)
git clone https://github.com/turboderp/exllamav2
cd exllamav2
pip install -r requirements.txt
pip install .
# Verify CUDA is available
python -c "import torch; print(torch.cuda.is_available())"
# Expected: True
# Verify ExLlamaV2 installation
python -c "from exllamav2 import ExLlamaV2; print('OK')"
Production-Grade Inference
from exllamav2 import (
ExLlamaV2,
ExLlamaV2Config,
ExLlamaV2Tokenizer,
ExLlamaV2Cache,
ExLlamaV2StreamingGenerator,
ExLlamaV2Sampler,
)
# 1. Load configuration
config = ExLlamaV2Config()
config.model_dir = "/path/to/exl2/model"
config.max_seq_len = 8192
config.max_input_len = 4096
config.max_attn_size = 8192
config.gpu_peer_size = 0 # single GPU
# 2. Load model and tokenizer
model = ExLlamaV2(config)
model.load()
tokenizer = ExLlamaV2Tokenizer(config)
# 3. Create cache and generator
cache = ExLlamaV2Cache(model, max_seq_len=config.max_seq_len)
generator = ExLlamaV2StreamingGenerator(model, cache, tokenizer)
# 4. Configure sampling
settings = ExLlamaV2Sampler.Settings()
settings.temperature = 0.7
settings.top_k = 40
settings.top_p = 0.9
settings.token_repetition_penalty = 1.05
# 5. Generate
prompt = "Explain the difference between quantization and pruning in ML."
input_ids = tokenizer.encode(prompt)
generator.begin_stream(input_ids, settings)
generator.set_stop_conditions(["<|eot_id|>", "\n\n"])
output = ""
for i in range(512):
chunk, eos, _ = generator.stream()
output += chunk
if eos:
break
print(output)
Dynamic Generator (Paged Attention + Speculative Decoding)
from exllamav2.generator import (
ExLlamaV2DynamicGenerator,
ExLlamaV2DynamicJob,
ExLlamaV2Sampler,
)
# Load model (same as above)
# ...
# Create dynamic generator with paged attention
dynamic_gen = ExLlamaV2DynamicGenerator(
model=model,
cache=cache,
tokenizer=tokenizer,
paged=True, # enable paged attention
max_batch_size=4, # max concurrent sequences
max_seq_len=8192,
use_ngram_draft=True, # n-gram speculative decoding
num_draft_tokens=4, # draft 4 tokens ahead
)
# Create a generation job
settings = ExLlamaV2Sampler.Settings()
settings.temperature = 0.6
settings.top_p = 0.9
job = ExLlamaV2DynamicJob(
input_ids=tokenizer.encode("Write a Python function to merge two sorted lists."),
generation_settings=settings,
max_new_tokens=256,
stop_conditions=["<|eot_id|>"],
)
# Enqueue and stream results
dynamic_gen.enqueue(job)
for result in dynamic_gen.iterate():
if result["type"] == "stream":
print(result["text"], end="", flush=True)
elif result["type"] == "stop":
print(f"\n--- Stopped: {result['reason']} ---")
Quantizing a Model to EXL2
# Step 1: Download a base model (FP16)
huggingface-cli download meta-llama/Meta-Llama-3.1-8B --local-dir ./llama-3.1-8b
# Step 2: Convert to EXL2 at 4.0 bpw
python convert.py \
-i ./llama-3.1-8b \
-o ./llama-3.1-8b-exl2 \
-b 4.0 \
-c ./calibration_data.parquet
# Step 3: Run inference with the quantized model
python test_inference.py -m ./llama-3.1-8b-exl2 -p "Hello, world" -len 256
Production pitfall: The conversion step (“Solving…”) can appear to hang for 10-30 minutes on large models. It is not frozen — it is running a combinatorial optimization over thousands of groups. Let it finish. Use
-bto set the target bpw (4.0 is the sweet spot for quality/speed), and-cto provide a calibration dataset representative of your use case.
How to Use Effectively
Step 1: Choose the right quantization level
# Quality sweet spot: 4.0 bpw (imperceptible quality loss, 2x speed vs FP16)
python convert.py -i ./model -o ./model-exl2 -b 4.0
# Maximum speed: 3.0 bpw (fits larger models, slight quality drop)
python convert.py -i ./model -o ./model-exl2 -b 3.0
# Maximum quality: 6.0 bpw (near-lossless, higher VRAM usage)
python convert.py -i ./model -o ./model-exl2 -b 6.0
The 4.0 bpw setting is the default recommendation for most use cases. At 4.0 bpw, a 70B model fits in 24 GB VRAM with room for 8K context. At 3.0 bpw, the same model fits with 32K context. At 6.0 bpw, you need 48 GB for a 70B model.
Step 2: Use the right cache mode for your context length
# Short context (< 4K): FP16 cache (fastest)
cache = ExLlamaV2Cache(model, max_seq_len=4096)
# Medium context (4K-16K): Q8 cache (saves 50% VRAM)
from exllamav2 import ExLlamaV2Cache_Q8
cache = ExLlamaV2Cache_Q8(model, max_seq_len=16384)
# Long context (16K+): Q4 cache (saves 75% VRAM)
from exllamav2 import ExLlamaV2Cache_Q4
cache = ExLlamaV2Cache_Q4(model, max_seq_len=32768)
The KV cache is the dominant VRAM consumer at long context lengths. A 70B model with 32K context in FP16 cache requires ~40 GB for the cache alone. Q4 cache reduces this to ~10 GB, making 32K context feasible on a single 24 GB GPU.
Step 3: Enable speculative decoding for maximum throughput
# N-gram drafting (no second model needed)
dynamic_gen = ExLlamaV2DynamicGenerator(
model=model,
cache=cache,
tokenizer=tokenizer,
paged=True,
use_ngram_draft=True,
num_draft_tokens=5,
max_ngram=4,
)
# Draft model (requires a smaller model loaded alongside)
dynamic_gen = ExLlamaV2DynamicGenerator(
model=model,
cache=cache,
tokenizer=tokenizer,
paged=True,
draft_model=draft_model, # e.g., Llama 3.2 1B
draft_cache=draft_cache,
num_draft_tokens=5,
)
N-gram drafting adds 15-30% throughput with zero additional VRAM. Draft model speculative decoding adds 30-60% throughput but requires loading a second model (typically 1-3B parameters). The n-gram approach is almost always the right default — it costs nothing and delivers meaningful gains on repetitive or structured generation tasks.
Step 4: Use TabbyAPI for OpenAI-compatible serving
# Install TabbyAPI
pip install tabbyapi
# Start the server
python -m tabbyapi \
--model /path/to/exl2/model \
--host 0.0.0.0 \
--port 5000
# Use with any OpenAI-compatible client
curl http://localhost:5000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "my-model",
"messages": [{"role": "user", "content": "Hello!"}],
"max_tokens": 100
}'
TabbyAPI is the recommended backend for ExLlamaV2. It provides an OpenAI-compatible API with support for chat completions, streaming, function calling, and multi-model management. It handles the dynamic generator internally, so you get paged attention and speculative decoding through a standard REST API.
Production pitfall: TabbyAPI’s default settings use FP16 cache. For long-context serving, explicitly set
cache_mode: "Q4"in the TabbyAPI config. Without this, a 70B model with 32K context will OOM on a 24 GB GPU.
Use Cases
1. Local AI Assistant with Interactive Speeds
When you’d use this: You want a ChatGPT-like experience running entirely on your local machine, with no latency, no data leaving your computer, and no subscription fees.
Why ExLlamaV2 fits: At 140+ tok/s on a 4090 with a 7B model, the generation feels instant. The streaming generator delivers tokens as they are produced, so the first token appears in under 100ms. Combined with TabbyAPI, you can point any OpenAI-compatible client (Continue.dev, Cursor, custom UIs) at your local model. Real-world examples include developers running Llama 3.1 70B at 38 tok/s on a single 4090 at 2.5 bpw — slower than 7B but still usable for code generation and analysis.
2. Batch Document Processing with Paged Attention
When you’d use this: You need to process hundreds of documents (summarization, classification, extraction) and want to maximize throughput by batching.
Why ExLlamaV2 fits: The dynamic generator’s paged attention and continuous batching allow multiple sequences to share the same KV cache pages when they have common prefixes (e.g., the same system prompt). This means processing 100 documents with the same instruction reuses the prefill computation for the instruction portion, saving 30-50% of total compute. The page-level deduplication via Blake2b hashing is transparent — you just enqueue jobs and the generator handles the rest.
3. Speculative Decoding for Code Generation
When you’d use this: You are generating code with a large model (70B) and want the speed of a small model with the quality of the large one.
Why ExLlamaV2 fits: Code is highly structured and predictable — after def foo( the next tokens are almost deterministic. N-gram speculative decoding exploits this: the trie captures common token sequences from the context, and the draft model (or n-gram) predicts 4-5 tokens ahead. The main model verifies them in a single forward pass. On code generation tasks, speculative decoding achieves 50-80% acceptance rates, translating to 1.5-2x effective throughput on a 70B model.
4. Long-Context RAG with Quantized KV Cache
When you’d use this: You are building a retrieval-augmented generation pipeline that needs to process 16K-32K token contexts (multiple retrieved documents + conversation history).
Why ExLlamaV2 fits: The Q4 KV cache reduces cache memory by 75% compared to FP16, making 32K context feasible on a 24 GB GPU. The paged attention system handles the non-contiguous memory layout efficiently — retrieved documents of varying lengths are stored in fixed-size 256-token pages, and the page table handles the indirection. This is the only local inference engine that can run a 70B model with 32K context on a single consumer GPU.
5. Multi-Model Experimentation and Benchmarking
When you’d use this: You are evaluating multiple quantized models (different sizes, quantization levels, architectures) and need a consistent, fast inference backend for benchmarking.
Why ExLlamaV2 fits: The architecture module supports 20+ model families (Llama, Mistral, Mixtral, Qwen2, Gemma, Phi-3, Cohere, DBRX, etc.) through a unified interface. You swap models by changing the model directory — no code changes needed. The test_inference.py script provides a standardized benchmark harness. Real-world use includes comparing perplexity across quantization levels (3.0 vs 4.0 vs 5.0 bpw) and measuring throughput under different cache configurations.
Cheat Sheet
| Aspect | Detail |
|---|---|
| Repository | github.com/turboderp/exllamav2 |
| License | MIT |
| Language | Python (65.9%), CUDA (19.3%), C++ (12.5%), C (2.3%) |
| GPU Requirements | NVIDIA GPU with CUDA 12.1+, 8 GB+ VRAM (RTX 3070 minimum for 7B, RTX 4090 for 70B) |
| Setup Time | 5 minutes (pip install + model download) |
| Key Features | EXL2 mixed-precision quantization, fused CUDA kernels, paged attention, speculative decoding (draft model + n-gram), quantized KV cache (Q4/Q6/Q8), dynamic batching, prompt caching, tensor parallelism |
| Common Gotchas | CUDA version mismatch; OOM from FP16 cache at long context; conversion “Solving…” phase appearing frozen; draft model and n-gram draft cannot be used together; 8-bit cache not supported in paged mode |
| Best Models | Llama 3.1 70B (EXL2 Q4), Mistral Large 2, Qwen 2.5 72B, Gemma 2 27B, Phi-3 Medium |
| VRAM (7B Q4) | ~6 GB model + ~2 GB cache (4K context) = ~8 GB total |
| VRAM (70B Q4) | ~40 GB model + ~10 GB cache (4K context) = ~50 GB total (needs 2x 24 GB GPUs) |
| VRAM (70B Q2.5) | ~25 GB model + ~10 GB cache = ~35 GB total (fits single 4090 with Q4 cache) |
| Missing Features | No CPU fallback, no AMD GPU support, no Apple Silicon, no production multi-user batching, no built-in API server (needs TabbyAPI) |
Vibe Coding Projects
Project 1: Local Chat UI with Streaming and Multi-Turn Memory
What it does: A web-based chat interface (FastAPI + HTMX) that connects to ExLlamaV2 running locally. Supports multi-turn conversations with context management, streaming token-by-token output, conversation history saved to SQLite, and configurable system prompts. The backend uses the streaming generator for real-time output and the Q4 cache for long conversations.
What you’ll learn: How to integrate ExLlamaV2 with a web framework. How to manage conversation context (trimming old turns to fit within max_seq_len). How to handle streaming responses with server-sent events. How to configure the KV cache for multi-turn conversations.
Effort: 3-4 hours. Runs entirely on your GPU.
Project 2: Document Summarization Pipeline with Paged Batching
What it does: A CLI tool that takes a directory of documents (PDF, text, markdown), chunks them, and sends them to ExLlamaV2’s dynamic generator for batched summarization. Uses paged attention to share the system prompt’s KV cache across all documents. Outputs summaries to a structured JSON file with per-document metadata.
What you’ll learn: How to use the dynamic generator’s job queue for batch processing. How paged attention deduplication works in practice. How to structure prompts for consistent structured output. How to measure throughput and optimize batch size.
Effort: 2-3 hours. ~$0 in API costs (local inference).
Project 3: Speculative Decoding Benchmark Harness
What it does: A benchmarking tool that measures the throughput gain from speculative decoding across different model pairs and prompt types. Tests n-gram drafting vs draft model speculative decoding vs no speculation. Reports acceptance rates, tokens per second, and latency distributions. Generates comparison charts.
What you’ll learn: How speculative decoding parameters (num_draft_tokens, max_ngram) affect acceptance rates. How prompt structure (repetitive vs creative) changes draft effectiveness. How to measure and compare inference performance systematically.
Effort: 3-5 hours. Requires two models (main + draft) loaded simultaneously.
Problems Solved Efficiently
| Problem Type | Why ExLlamaV2 Fits | When to Look Elsewhere |
|---|---|---|
| Single-user local inference | Fastest tok/s on NVIDIA GPUs, fused kernels, no Python overhead | Use llama.cpp for CPU/Apple Silicon inference |
| Quantized model deployment | EXL2 mixed-precision format, 2-8 bit support, better quality-per-bit | Use GGUF for cross-platform compatibility |
| Long-context generation | Q4/Q6/Q8 KV cache, paged attention, 32K+ context on 24 GB GPU | Use vLLM for production multi-user long-context serving |
| Speculative decoding | N-gram (zero overhead) + draft model support, 30-60% throughput gain | Use llama.cpp for CPU-based speculative decoding |
| Batch document processing | Paged attention with prefix deduplication, continuous batching | Use vLLM for high-throughput production batching |
| Model quantization | Two-pass measurement+solve optimization, per-group mixed precision | Use AutoGPTQ for GPTQ-only workflows |
| Multi-GPU inference | Tensor parallelism for Llama, Mistral, Qwen2 | Use vLLM for multi-GPU production serving |
| OpenAI-compatible serving | TabbyAPI integration, chat completions, streaming, function calling | Use vLLM for production-grade API serving with auth and rate limiting |
Architectural Tradeoffs
What we gained:
- Fused kernel pipeline. Every transformer layer runs in a single CUDA kernel launch. No Python loop overhead between layers. This is the single biggest performance win — 5x over llama.cpp on the same hardware.
- Native quantized compute. The matmul kernel reads 4-bit weights and computes in the quantized domain. No dequantization step. Half the memory bandwidth of FP16-based frameworks.
- Mixed-precision quantization. EXL2 allocates bits where they matter most. A 3.5 bpw EXL2 model matches the quality of 4.0 bpw uniform quantization while using 12.5% less memory.
- Paged attention with deduplication. The 256-token page system with Blake2b content hashing enables transparent KV cache sharing across sequences with common prefixes. This is unique among local inference engines.
- Quantized KV cache. Q4 cache reduces cache memory by 75% vs FP16, making 32K+ context feasible on consumer GPUs. No other local engine offers this.
- Speculative decoding without a second model. The n-gram trie approach adds 15-30% throughput with zero additional VRAM. This is a free lunch.
What we sacrificed:
- NVIDIA-only. ExLlamaV2 is CUDA-only. No AMD GPUs, no Apple Silicon, no CPU fallback. If you don’t have an NVIDIA GPU, you cannot use it.
- No production multi-user batching. The dynamic generator handles 4-8 concurrent sequences, but vLLM handles 100+ with better scheduling. ExLlamaV2 is a single-user engine at heart.
- Archived project. Development has moved to ExLlamaV3. ExLlamaV2 will not receive new features or architecture support. Bug fixes are community-driven.
- No built-in API server. You need TabbyAPI (a separate project) for OpenAI-compatible serving. This adds a dependency and a configuration surface.
- EXL2 format lock-in. Models quantized with ExLlamaV2 cannot be used with llama.cpp, vLLM, or Transformers. You are committing to the ExLlama ecosystem.
- No CPU offloading. If the model does not fit in VRAM, it does not run. There is no CPU offloading or disk offloading. This limits the maximum model size to what your GPU(s) can hold.
- Steeper learning curve. The API has multiple generator tiers (basic, streaming, dynamic) with different capabilities and constraints. Understanding when to use each requires reading the documentation.
The real lesson: ExLlamaV2 is the fastest local inference engine on NVIDIA hardware by a wide margin, but it achieves this speed through narrow specialization. It is not a general-purpose inference framework — it is a performance-maximizing engine for a specific hardware configuration. If you own an NVIDIA GPU and want the fastest possible single-user inference, ExLlamaV2 is the answer. If you need cross-platform support, production batching, or a maintained project, look elsewhere.
Course-Style Deep Dive
How the EXL2 Quantization Pipeline Works Under the Hood
The convert.py script is the heart of ExLlamaV2’s quantization system. Here is the exact process, step by step:
Step 1: Measurement Pass
The converter loads the FP16 model and runs it through a calibration dataset (default: 16 sequences of 2048 tokens each). For every linear layer in the model, it tentatively quantizes the weights at multiple bit-width and group-size combinations:
# Conceptual: measurement pass
for layer in model.layers:
for bit_width in [2, 3, 4, 5, 6, 8]:
for group_size in [32, 64, 128]:
# Tentatively quantize this layer's weights
q_weight, scale, zero = gptq_quantize(
layer.weight, bit_width, group_size
)
# Measure error vs FP16 reference
q_output = layer.forward(q_weight)
fp16_output = layer.forward(layer.weight)
error = mse(q_output, fp16_output)
measurements[layer.name][bit_width][group_size] = error
The result is a measurement.json file containing per-layer, per-bit-width, per-group-size error profiles. This file is typically 10-50 MB for a 70B model.
Step 2: Solve Pass (Combinatorial Optimization)
The solver reads the measurement data and a target average bpw (e.g., 4.0). It then solves an optimization problem: assign a bit-width and group size to every group in every layer to minimize the maximum quantization error across the entire model, subject to the constraint that the average bpw equals the target.
# Conceptual: solver optimization
def solve(measurements, target_bpw):
# Initialize: all groups at 4 bits, 128 group size
assignment = {layer: (4, 128) for layer in measurements}
# Iteratively adjust: give more bits to high-error groups,
# fewer bits to low-error groups, until target bpw is met
while average_bpw(assignment) > target_bpw:
# Find the group with the lowest error-per-bit
lowest_error_group = argmin(
error_per_bit(layer, group)
for layer, group in assignment
)
# Reduce its bit-width
assignment[lowest_error_group] = decrease_bits(assignment[lowest_error_group])
while average_bpw(assignment) < target_bpw:
# Find the group with the highest error-per-bit
highest_error_group = argmax(
error_per_bit(layer, group)
for layer, group in assignment
)
# Increase its bit-width
assignment[highest_error_group] = increase_bits(assignment[highest_error_group])
return assignment
This is why the “Solving…” phase can take 10-30 minutes — it is iterating over thousands of groups, each with 6 possible bit-widths and 3 possible group sizes, searching for the optimal assignment.
Step 3: Quantization Application
Once the solver produces the optimal assignment, the converter applies the quantization:
# Conceptual: quantization application
for layer_name, (bit_width, group_size) in assignment.items():
layer = model.get_layer(layer_name)
q_weight, scale, scale_max = quantize_with_assignment(
layer.weight, bit_width, group_size
)
# Store in EXL2 format
save_exl2_tensor(
f"{layer_name}.q_weights", q_weight,
f"{layer_name}.q_scale", scale,
f"{layer_name}.q_scale_max", scale_max,
f"{layer_name}.q_groups", (bit_width, group_size),
)
The EXL2 format stores each tensor as a set of safetensors files with the structure described earlier: q_weights (packed variable-bit-width values), q_scale (4-bit group scales packed as uint32), q_scale_max (per-output-feature max scales), q_groups (bit-width and group size per group), and q_invperm (row permutation for act-order compatibility).
How the Fused CUDA Kernel Works
The fused matmul kernel in exllamav2_ext/cuda/ is the performance core. Here is the simplified execution flow:
// Conceptual: fused Q4 matmul kernel
__global__ void fused_q4_matmul_kernel(
const half* input, // [batch, hidden_dim]
const uint32* q_weights, // packed 4-bit weights
const half* q_scale, // group scales
const half* q_scale_max, // per-output max scales
half* output, // [batch, output_dim]
int groups_per_row,
int group_size
) {
int row = blockIdx.x;
int col = threadIdx.x;
// Each thread processes one output element
float sum = 0.0f;
for (int g = 0; g < groups_per_row; g++) {
// Load 4-bit weights for this group
uint32 packed = q_weights[row * groups_per_row + g];
float scale = __half2float(q_scale[g * gridDim.x + row]);
float scale_max = __half2float(q_scale_max[row]);
// Dequantize 8 weights from one uint32
#pragma unroll
for (int i = 0; i < 8; i++) {
int w = (packed >> (i * 4)) & 0xF;
float deq = (w - 8.0f) * scale * scale_max;
sum += deq * __half2float(input[g * group_size + i]);
}
}
output[row] = __float2half(sum);
}
The actual kernel is more complex (it handles multiple bit-widths, uses tensor cores, and fuses the RoPE application and KV cache write), but the core idea is the same: load quantized weights, dequantize on-the-fly, compute the matmul, all in a single kernel with no intermediate memory writes.
Advanced Pattern 1: Multi-GPU Tensor Parallelism
# Split a 70B model across 2x 24 GB GPUs
config = ExLlamaV2Config()
config.model_dir = "/path/to/70b-exl2"
config.max_seq_len = 8192
config.gpu_peer_size = [0, 1] # use GPU 0 and GPU 1
model = ExLlamaV2(config)
model.load()
# The model is automatically split across GPUs
# GPU 0: layers 0-39, GPU 1: layers 40-79
# Each GPU holds half the weights and computes half the layers
# Use tensor-parallel cache
from exllamav2 import ExLlamaV2Cache_TP
cache = ExLlamaV2Cache_TP(model, max_seq_len=8192)
Tensor parallelism in ExLlamaV2 splits the model by layers, not by tensor dimensions. This is simpler to implement (no all-reduce between GPUs per layer) but means both GPUs are idle half the time (one computes while the other waits). For 2-GPU setups, this is fine — the speedup is ~1.8x. For 4+ GPU setups, tensor-dimension splitting (as in vLLM) would be more efficient.
Advanced Pattern 2: Custom Sampling with Filters
from exllamav2 import ExLlamaV2Sampler
# Create a custom sampling pipeline
settings = ExLlamaV2Sampler.Settings()
# Temperature sampling
settings.temperature = 0.8
settings.temperature_last = True # apply temperature to logits, not probs
# Top-k filtering
settings.top_k = 40
# Top-p (nucleus) filtering
settings.top_p = 0.9
settings.top_p_min = 0.05 # minimum probability mass
# Repetition penalty
settings.token_repetition_penalty = 1.15
settings.token_repetition_range = 1024 # look back 1024 tokens
# Frequency penalty
settings.token_frequency_penalty = 0.05
# Custom banned tokens
settings.disallow_tokens = [tokenizer.eos_token_id]
# Skew tokens (boost specific token IDs)
settings.skew_tokens = {tokenizer.encode("```"): 1.5}
The sampling pipeline runs on GPU (in the CUDA kernel), not in Python. This means sampling adds zero CPU-GPU transfer overhead — the logits stay on the GPU, the sampling happens on the GPU, and only the selected token ID is returned to Python.
Production Considerations
VRAM budgeting. Know your model’s memory footprint before you run:
def estimate_vram(model_size_gb, bpw, context_len, cache_mode):
"""Estimate VRAM usage for ExLlamaV2 inference."""
# Model weights (quantized)
model_vram = model_size_gb * bpw / 16.0 # relative to FP16
# KV cache
n_layers = 80 if model_size_gb >= 70 else 32
n_heads = 64 if model_size_gb >= 70 else 32
hidden_dim = 8192 if model_size_gb >= 70 else 4096
if cache_mode == "FP16":
bytes_per_token = 2 * n_layers * n_heads * (hidden_dim // n_heads) * 2
elif cache_mode == "Q4":
bytes_per_token = 0.5 * n_layers * n_heads * (hidden_dim // n_heads) * 2
elif cache_mode == "Q8":
bytes_per_token = 1 * n_layers * n_heads * (hidden_dim // n_heads) * 2
cache_vram = bytes_per_token * context_len / (1024**3) # GB
# Overhead (activations, CUDA context, etc.)
overhead = 1.5 # GB
return model_vram + cache_vram + overhead
# Example: 70B at 4.0 bpw, 8K context, Q4 cache
vram = estimate_vram(70, 4.0, 8192, "Q4")
print(f"Estimated VRAM: {vram:.1f} GB")
# ~40 GB model + ~5 GB cache + 1.5 GB overhead = ~46.5 GB
# Needs 2x 24 GB GPUs or 1x 48 GB GPU
Warm-up. The first inference call is slower because CUDA kernels need to be JIT-compiled and caches need to be populated. Always run a warm-up prompt before benchmarking:
# Warm up
_ = generator.generate("Hello", max_new_tokens=10)
# Now benchmark
import time
start = time.time()
output = generator.generate("Write a story", max_new_tokens=512)
elapsed = time.time() - start
print(f"Throughput: {512 / elapsed:.1f} tok/s")
Error handling. The most common failure mode is CUDA OOM. ExLlamaV2 does not handle this gracefully — the process crashes. Always validate your VRAM budget before loading a model:
import torch
def check_vram(model_path):
free_vram = torch.cuda.mem_get_info()[0] / (1024**3)
print(f"Free VRAM: {free_vram:.1f} GB")
# Rough estimate: model file size + 20% overhead
import os
model_size = sum(
os.path.getsize(os.path.join(model_path, f))
for f in os.listdir(model_path)
if f.endswith(".safetensors")
) / (1024**3)
print(f"Model size: {model_size:.1f} GB")
print(f"Estimated total: {model_size * 1.2:.1f} GB")
if model_size * 1.2 > free_vram:
print("WARNING: Model may not fit in VRAM")
return False
return True
The Results
| Metric | Before ExLlamaV2 | After ExLlamaV2 | Improvement |
|---|---|---|---|
| Llama 2 7B Q4 throughput (4090) | ~42 tok/s (llama.cpp) | 211 tok/s (ExLlamaV2) | 5.0x faster |
| Llama 3.1 70B Q4 throughput (5090) | ~42 tok/s (llama.cpp) | 250 tok/s (ExLlamaV2) | 5.9x faster |
| Llama 2 7B Q3 throughput (4090) | ~50 tok/s (llama.cpp) | 257 tok/s (ExLlamaV2) | 5.1x faster |
| TinyLlama 1.1B Q3 throughput (4090) | ~150 tok/s (llama.cpp) | 770 tok/s (ExLlamaV2) | 5.1x faster |
| 70B model on single 24 GB GPU | Not possible (OOM) | 38 tok/s at 2.5 bpw | Feasible for first time |
| 32K context on 24 GB GPU (70B) | Not possible (OOM) | Feasible with Q4 cache | New capability |
| Speculative decoding throughput gain | N/A (not available) | 30-60% (draft model) | Free throughput |
| Perplexity at 3.5 bpw (EXL2 vs GPTQ) | 6.2 (GPTQ 4.0) | 6.15 (EXL2 3.5) | Better quality at lower bitrate |
What this means for you: ExLlamaV2 is not a marginal improvement over other frameworks — it is a step-function change. The 5x speedup over llama.cpp is consistent across model sizes and quantization levels. If you own an NVIDIA GPU and want the fastest possible local inference, ExLlamaV2 is the engine. The tradeoffs (NVIDIA-only, archived project, no CPU fallback) are real, but for the specific use case of single-user NVIDIA inference, nothing else comes close.
What to Watch Out For
-
CUDA version matters. ExLlamaV2 requires CUDA 12.1+. Older CUDA versions will fail at import time with cryptic symbol errors. Run
nvcc --versionbefore installing. If you are on an older CUDA, use the Docker image or build from source with the matching CUDA toolkit. -
The “Solving…” phase is not frozen. During model conversion, the solver can appear to hang for 10-30 minutes. It is running a combinatorial optimization over thousands of groups. Do not kill it. Watch the console output for progress indicators (it prints a line every 100 groups).
-
FP16 cache will OOM at long context. The default cache mode is FP16. For context lengths above 4K on a 70B model, switch to Q4 cache explicitly. The error message on OOM is a generic CUDA out-of-memory — it does not tell you the cache is the problem.
-
Draft model and n-gram draft are mutually exclusive. You cannot use both simultaneously. N-gram drafting is the better default (zero VRAM cost, 15-30% gain). Draft model speculative decoding is for when you need the maximum possible throughput and have VRAM to spare for a second model.
-
Paged mode does not support 8-bit cache. If you enable paged attention, you must use FP16 or Q4 cache. The Q8 cache mode is only available in non-paged mode. This is a documented limitation in the source code.
-
The project is archived. ExLlamaV2 will not receive new features. If you need support for new model architectures (e.g., Llama 4, DeepSeek V4), you need to use ExLlamaV3 or contribute the architecture mapping yourself. The community maintains a fork ecosystem, but there is no official support channel.
-
TabbyAPI is a separate project. ExLlamaV2 does not include an API server. For OpenAI-compatible serving, you must install and configure TabbyAPI separately. This adds a configuration surface and a potential point of failure.
Lesson 1: “I spent two hours debugging a CUDA error before realizing I was on CUDA 11.8. ExLlamaV2 needs 12.1+. Check your CUDA version first, before anything else.” — ExLlamaV2 user, r/LocalLLaMA
Lesson 2: “The conversion step is the most important part of the workflow. A bad calibration dataset produces a model that looks fine on benchmarks but fails on real prompts. Use calibration data that matches your actual use case.” — turboderp, ExLlamaV2 author
Lesson 3: “I was running 70B at 2.5 bpw on a single 4090 and getting 38 tok/s. It was usable for code generation but noticeably dumber than the 4.0 bpw version. The quality gap between 2.5 and 4.0 bpw is larger than the benchmarks suggest.” — ExLlamaV2 user, r/LocalLLaMA
Advice for Getting Started
- Check your CUDA version first.
nvcc --versionmust show 12.1 or higher. If not, update your CUDA toolkit before installing ExLlamaV2. - Start with a pre-quantized model from Hugging Face. Download a 7B EXL2 model from turboderp or LoneStriker. Skip the conversion step on your first try.
- Use the streaming generator for your first test. It is the simplest API and gives you immediate feedback. The dynamic generator is powerful but has more configuration surface.
- Benchmark with a warm-up run. The first inference call is always slower due to CUDA kernel compilation. Run a 10-token warm-up before measuring throughput.
- Use Q4 cache by default. Unless you have VRAM to spare, Q4 cache is the right choice for any context length above 2K. It saves 75% of cache memory with negligible quality impact.
- Enable n-gram speculative decoding. It costs zero VRAM and adds 15-30% throughput. There is no reason not to use it.
- Install TabbyAPI for API access. If you want to use ExLlamaV2 from another application (Continue.dev, custom UI, automation scripts), TabbyAPI provides the OpenAI-compatible interface.
Next in the Open-Source AI Tools Mastery series: OpenAI Whisper
Written by Nivant Labs Team
Engineer at Nivant Labs