🎯 The Setup
I've got a single RTX 4080 (16GB VRAM), 64GB system RAM, and a stubborn desire to run 13B-ish open models without renting a cloud GPU. The two usual answers are:
- Quantize the model so the whole thing fits in VRAM at lower precision.
- Offload layers to CPU/RAM and keep weights in full or near-full precision, paying a PCIe tax for it.
Every "how to run LLMs locally" thread eventually turns into a religious war between these two camps, usually without numbers. So I ran both strategies against the same models on the same hardware and actually measured throughput and memory behavior instead of vibing.
🔧 The Problem
16GB sits in an annoying spot. It's enough to comfortably run a 7B model in fp16 (~14GB) with a small KV cache, but a 13B model in fp16 needs ~26GB — nowhere close. You have three real options:
- 4-bit or 8-bit quantize the 13B model so it fits entirely on the GPU
- Keep it in fp16/bf16 and offload the layers that don't fit to system RAM
- Just use a smaller model (boring, but sometimes correct — more on that later)
I tested with Llama-3-8B and Qwen2.5-14B as representative "fits" and "doesn't fit" cases, using llama.cpp for quantized GGUF runs and transformers + accelerate for offloaded fp16 runs.
🧪 Test Methodology
Same prompt set (512 token input, 256 token generation, batch size 1), same GPU, measured with nvidia-smi --query-gpu=memory.used --loop-ms=500 running in the background and time around generation.
Quantized runs:
bash
Convert + quantize with llama.cpp
python convert_hf_to_gguf.py ./Qwen2.5-14B-Instruct --outfile qwen14b-f16.gguf
./llama-quantize qwen14b-f16.gguf qwen14b-q4_k_m.gguf Q4_K_M
./llama-quantize qwen14b-f16.gguf qwen14b-q8_0.gguf Q8_0
./llama-bench -m qwen14b-q4_k_m.gguf -p 512 -n 256 -ngl 99
./llama-bench -m qwen14b-q8_0.gguf -p 512 -n 256 -ngl 99
Offloaded runs:
python
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch, time
tok = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-14B-Instruct")
model = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen2.5-14B-Instruct",
torch_dtype=torch.float16,
device_map="auto", # accelerate decides GPU vs CPU per layer
max_memory={0: "14GiB", "cpu": "48GiB"},
)
prompt = tok(open("prompt.txt").read(), return_tensors="pt").to("cuda")
start = time.time()
out = model.generate(**prompt, max_new_tokens=256)
elapsed = time.time() - start
print(f"{256 / elapsed:.2f} tok/s")
📊 Quantization Results
| Model | Format | VRAM used | tok/s (gen) | Notes |
|---|---|---|---|---|
| Llama-3-8B | fp16 (no quant needed) | 15.1 GB | 38.2 | baseline, fits natively |
| Qwen2.5-14B | Q8_0 | 15.8 GB | 21.4 | borderline fit, tight KV cache room |
| Qwen2.5-14B | Q4_K_M | 9.3 GB | 34.7 | plenty of headroom for longer context |
The Q4_K_M run is the standout here: it's not just that it fits, it's that the entire model and KV cache live on the GPU with room to spare, so there's zero PCIe round-tripping per token. Quality loss versus fp16 on MMLU-style spot checks was small (roughly 1-2 points), and basically imperceptible on conversational tasks.
Q8_0 technically fits but only barely — push context past ~3K tokens and you start getting CUDA OOM or llama.cpp silently falling back to partial CPU layers, which defeats the purpose.
📊 Offloading Results
| Model | Split | VRAM used | tok/s (gen) | Notes |
|---|---|---|---|---|
| Qwen2.5-14B fp16 | ~60% GPU / 40% CPU | 14.9 GB | 4.1 | heavy PCIe traffic every token |
| Qwen2.5-14B fp16 | ~80% GPU / 20% CPU | 14.9 GB | 7.6 | fewer offloaded layers, still bottlenecked |
The nvidia-smi memory trace for the offloaded run is telling: GPU memory usage is flat and near the max_memory cap the whole time, while GPU utilization oscillates between ~90% and ~15% in a sawtooth pattern — that's the model waiting on CPU-computed layers to come back over PCIe before it can continue. Full precision is preserved, but you're paying roughly a 5-8x throughput penalty to get it.
For comparison, that's the difference between a usable chat loop and something you start and go make coffee for.
⚖️ Trade-offs
This isn't close, but it's worth being honest about why:
- Quantization cost is paid once, offline. You quantize the weights, you're done — inference has no extra runtime overhead beyond slightly cheaper matmuls.
- Offloading cost is paid every single forward pass. PCIe 4.0 x16 gives you ~32 GB/s in theory, far less in practice with the overhead of partial-layer compute on CPU, which is why throughput craters.
- Quantization does touch quality. Q4_K_M is a genuinely lossy transform. For tasks sensitive to exact numeric reasoning or long-tail factual recall, run your own eval before trusting benchmarks from a different model family.
- Offloading wins only when quantization isn't an option at all — e.g., you need the full fp16 model for a specific reproducibility requirement, or you're running something where even 4-bit doesn't fit and you have no smaller checkpoint available.
- KV cache headroom matters more than people expect. A model that "fits" at 512 tokens of context can OOM at 4K. Always benchmark at the context length you'll actually use, not the default in whatever script you copied.
🏆 Recommendation
For a 16GB consumer card, default to 4-bit quantization (Q4_K_M or equivalent AWQ/GPTQ) for anything in the 7B-14B range. It's faster, it leaves context headroom, and the quality hit is small enough to not matter for most real workloads. Reach for CPU offloading only as a fallback when you need a model that won't fit even at 4-bit and no smaller variant exists — and go in expecting single-digit tokens/sec.
The one scenario where I'd flip this: if you're doing quantitative work where output precision genuinely matters (numeric reasoning benchmarks, exact reproduction of a published fp16 result), the throughput loss from offloading is the lesser evil.
What's your actual tok/s been with similar hardware — and has anyone found an 8-bit sweet spot that didn't just turn into a worse version of 4-bit? Curious whether these numbers hold up on 3090s/4090s with more VRAM headroom to play with.

