TL;DR — Small language models (1B–7B parameters) can hit 30+ tokens/sec on a modern laptop CPU and exceed 100 tokens/sec on a phone NPU, but only if you stack three layers of optimization: aggressive quantization (INT4/INT8 weight-only or full INT8), structural pruning tuned to the target hardware, and runtime tuning of the KV cache, batch size, and prefill/chunking strategy. Measure prefill latency, decode throughput, and first-token time separately — average tokens/sec lies.
Why Edge Inference for LLMs Is Suddenly Real
For most of the last five years, “local LLM” meant “an M3 MacBook pretending it isn’t a server.” That changed when three things converged:
- Open weights for genuinely capable small models. Llama 3.2 1B/3B, Phi-3.5-mini, Qwen2.5-3B/7B, Gemma 2 2B, and Mistral 7B all deliver usable instruction-following and reasoning at sub-7B scale. The quality gap between 7B and 70B has narrowed dramatically.
- Weight-only quantization matured. GPTQ, AWQ, and bitsandbytes nf4 went from research curiosities to one-liners. INT4 weight-only models now match FP16 quality on most benchmarks within 1–3%.
- Consumer hardware caught up. Apple Silicon’s unified memory, Qualcomm’s Hexagon NPU, Intel’s NPU-equipped Meteor Lake, and even Steam Deck-class AMD APUs all have enough memory bandwidth (50–400 GB/s) to serve 3–7B parameter models interactively.
The result: a 3B parameter INT4 model needs roughly 2 GB of weights, fits in the RAM of a $400 phone, and runs at conversational speeds. But getting there requires more than just “download a GGUF file.” Production edge deployment is a stack of three layers — compression, architecture choice, and runtime tuning — and the layers interact in non-obvious ways.
The Optimization Stack: Compression → Architecture → Runtime
Treat these as a pipeline. Each layer makes the next one cheaper:
| Layer | What it changes | Typical gain |
|---|---|---|
| Compression | Bits per weight, sparse weights | 2–4× memory, 1.5–3× latency |
| Architecture | Operator fusion, attention variant | 1.2–2× latency, lower memory peak |
| Runtime | Batch, KV cache, prefill chunking | 1.5–4× throughput |
If you skip compression, runtime tuning is fighting against a 16 GB memory footprint. If you skip runtime tuning, you’ve spent quantization time just to hit 8 tokens/sec.
Quantization: Where the Real Wins Are
Quantization is the single largest lever. The math is brutal and simple: a 7B model in FP16 is 14 GB. In INT4 weight-only it’s ~3.5 GB. On a 100 GB/s memory system, that drops weight-load time from 140 ms to 35 ms — meaningful when prefill is 200 ms.
Weight-Only vs. Full Quantization
Weight-only quantization stores weights in INT4 or INT8 but keeps activations in FP16 (or BF16). This is what most edge tools default to because it’s simple, lossless-ish, and works without retraining.
Full quantization (sometimes called “static” or “W8A8”) quantizes both weights and activations. It gives better latency on integer-only hardware (Hexagon NPU, some ARM Cortex-M variants, Intel VNNI) but requires calibration data and can degrade quality more visibly on small models.
For a 3B model on a laptop CPU, AWQ INT4 weight-only is the default I’d recommend. The AWQ paper showed that protecting the top ~1% of “salient” weight channels during quantization preserves perplexity within 0.1 of FP16 — far better than naive round-to-nearest.
The Quality Cliff at INT4
INT4 works for most 7B+ models. For sub-3B models, INT4 weight-only can produce visibly worse outputs on structured tasks (JSON generation, arithmetic, function calls). The reason: small models have less redundancy, so losing 4 bits per weight is a bigger relative hit.
A practical rule from the llama.cpp quantization docs:
- >7B params: Q4_K_M is the sweet spot.
- 3B–7B: Q5_K_M or Q6_K if you have the RAM.
- <3B: Q8_0 is the floor for quality-sensitive workloads.
Activation-Aware vs. Post-Training
GPTQ and AWQ are both post-training — you quantize a finished model. For edge deployment where quality is critical, QLoRA-style fine-tuning on a quantized base is worth the extra step. The QLoRA paper showed you can fine-tune a 4-bit base and recover nearly all the lost quality, at a fraction of the memory cost of full fine-tuning. The trick is keeping LoRA adapters in FP16/FP32 while the base stays in NF4.
# Typical QLoRA setup with bitsandbytes
from transformers import AutoModelForCausalLM, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype="bfloat16",
bnb_4bit_use_double_quant=True,
)
base = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-3.2-3B",
quantization_config=bnb_config,
device_map="auto",
)
lora = LoraConfig(
r=16, lora_alpha=32, lora_dropout=0.05,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
)
model = get_peft_model(base, lora)
Pruning: Less Useful Than It Sounds, Until It Isn’t
Pruning gets less attention than quantization but can be the difference between “fits in 4 GB” and “doesn’t.” The two flavors behave very differently on edge hardware.
Unstructured Pruning (Sparse Weights)
Tools like SparseGPT can remove 50% of weights from a 7B model with under 1% perplexity increase. The catch: modern consumer hardware (CPUs, GPUs, NPUs) doesn’t accelerate unstructured sparse matmuls well. You trade memory for nothing in latency unless you’re targeting a specific sparse ISA (NVIDIA 2:4 sparsity, some ARM SVE2 configurations).
For a CPU/NPU edge target, unstructured pruning is almost never worth it. You pay the memory back in indexing overhead.
Structured Pruning (Fewer Heads, Narrower Layers)
Structured pruning — dropping attention heads, FFN dimensions, or entire layers — is the right tool for edge. The LLM-Pruner paper showed that 20% of layers in LLaMA-7B can be removed with recoverable fine-tuning. The result is a smaller dense model that runs faster on every backend.
A practical recipe:
- Measure head importance using the method from the ALBERT paper — mask each head, measure perplexity delta, drop the bottom 20%.
- Couple with quantization. A 20% pruned + INT4 model is roughly half the size and 1.5–2× faster than a vanilla INT4 model.
- Recover with LoRA fine-tuning for 1–2 epochs on a small instruction dataset. This matters more for pruning than for quantization because structured pruning has a bigger quality hit.
Architecture Choices That Matter at the Edge
Not all 3B models are equal on edge. The architecture determines which runtime optimizations are even possible.
Attention Variants
- Multi-Query Attention (MQA) and Grouped-Query Attention (GQA): These collapse the KV head count. Llama 3.2 1B/3B uses GQA with 4 KV heads. The win is enormous: KV cache memory drops proportionally, and decode latency (which is memory-bandwidth-bound) drops with it. If you can choose a model family, pick one with GQA.
- Sliding window attention (Mistral, Gemma 2): Bounds the KV cache to a fixed window. Means you can pre-allocate a fixed-size cache regardless of context length. For long-context edge use, this is mandatory.
- Linear attention / state-space hybrids (Mamba-2, RWKV-7, Jamba): Promising for edge because they have constant-time decode, but tooling support is still rough as of 2026.
FFN Variants
The standard SwiGLU FFN takes 2/3 of total parameters and 2/3 of decode time on most models. Some newer architectures (Phi-3.5-mini, Qwen2.5) experimented with shared experts (MoE with 1 active expert) that drop parameter count without the routing overhead. For edge, these are worth tracking.
Runtime Tuning: The Layer Most People Skip
You can have a perfectly quantized, well-pruned model and still get mediocre latency if the runtime is misconfigured. This is where I see the biggest gap between “I tried local LLM” and “I run local LLM in production.”
KV Cache: The Hidden Memory Hog
The KV cache grows with batch_size × seq_len × num_layers × head_dim × 2 (K and V) × bytes_per_element. For a 7B model with GQA, BF16 KV cache, 4K context, batch 1, this is roughly 1.2 GB. Quantize the KV cache to INT8 (a technique llama.cpp supports) and you halve it. The quality hit is usually under 1% on benchmarks but can matter for retrieval-heavy tasks.
# llama.cpp: enable INT8 KV cache
./llama-cli -m model.gguf -c 4096 --cache-type-k q8_0 --cache-type-v q8_0
Prefill vs. Decode: Different Bottlenecks
LLM inference has two distinct phases:
- Prefill: Processes the entire prompt in parallel. Compute-bound on GPU, memory-bound on CPU. Latency is roughly linear in prompt length.
- Decode: Generates one token at a time. Memory-bandwidth-bound everywhere. Latency is roughly constant per token.
This split matters because optimizations have to be tuned per phase:
| Optimization | Helps prefill? | Helps decode? |
|---|---|---|
| Larger batch | Yes (amortizes weights) | Helps up to a point |
| Smaller batch | No | Helps by reducing KV pressure |
| Flash Attention | Yes | Marginal |
| Quantized KV cache | No | Yes |
| Speculative decoding | No | Yes (2–3×) |
| Continuous batching | Helps utilization | Helps utilization |
First-token latency (TTFT) is dominated by prefill. Inter-token latency (ITL) and throughput are dominated by decode. If you’re building a chat UI, you care about both. If you’re running a batch summarization job, you care mostly about throughput.
Speculative Decoding: The 2× Win
Speculative decoding uses a small draft model to propose tokens that a larger target model verifies in parallel. For edge setups, this is a free 1.8–2.5× decode speedup:
- Pair a 7B target with a 0.5B–1B draft (e.g., Llama 3.2 1B as draft for Llama 3.2 3B).
- Acceptance rates of 60–80% are common when both models share a tokenizer.
- The draft is cheap enough that you can run it on the NPU while the target runs on the CPU/GPU.
# Hugging Face speculative decoding example
from transformers import AutoModelForCausalLM, AutoTokenizer
from transformers.generation import GenerationConfig
target = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-3.2-3B-Instruct")
draft = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-3.2-1B-Instruct")
output = target.generate(
**inputs,
assistant_model=draft,
do_sample=True,
temperature=0.7,
max_new_tokens=512,
)
Memory-Mapping and mlock
For sub-2-second TTFT, you need the model in RAM, not paged from disk. llama.cpp’s --mlock flag pins the model in physical memory, and the default mmap behavior avoids double-buffering. On Linux, also consider transparent_hugepage=always for 2 MB pages — measurable on 7B+ models.
Continuous Batching
If you’re serving more than one user, continuous batching (à bitsandbytes, vLLM, llama.cpp server) lets you add new requests to a batch mid-generation. This raises aggregate throughput 2–4× over naive request-level batching at the cost of slightly more variable per-request latency.
Patterns in Production: A Concrete Stack
Here’s a stack I’d actually ship today for a 3B-class model on a MacBook Air M2 or a Snapdragon X Elite laptop:
| Layer | Choice | Why |
|---|---|---|
| Model | Llama 3.2 3B Instruct, Q5_K_M | Best quality/memory ratio at 3B |
| Runtime | llama.cpp (Metal backend) or MLX | Native Apple Silicon, no CUDA tax |
| Quantization | Q5_K_M (GGUF) | Within 1% of FP16 on MMLU, ~2.2 GB |
| KV cache | Q8_0 | Halves cache memory, minimal quality hit |
| Speculative decoding | Draft with 1B variant | 1.8–2× decode speedup |
| Server | llama.cpp --server with continuous batching | Handles 4–8 concurrent users |
Measured on M2 Air, 16 GB: ~32 tokens/sec decode, ~250 ms TTFT for a 512-token prompt. That’s a fully usable chat experience with no GPU server.
For a phone (e.g., Pixel 9 Pro or iPhone 15 Pro), the same model in Q4_0 with the MediaTek Genio or Apple Neural Engine backend hits ~18 tokens/sec decode at ~2 W — enough for offline voice assistants and on-device translation.
What to Actually Measure
Most “local LLM is fast now” claims are meaningless because they report average tokens/sec over a 200-token generation, which hides both TTFT and tail latency. For a real deployment, track:
- TTFT p50 and p95 at 512-token, 2048-token, and 8192-token prompt sizes.
- ITL p50 and p95 during decode. The p95 is what users feel as “stuttering.”
- Peak memory including KV cache, not just weights.
- Tokens per joule if you’re on battery. Roughly
(throughput × energy_per_token). - Time-to-first-byte from cold (model load + first token). For mobile, this dominates the experience.
The vLLM benchmarking scripts and the genai-perf tool from NVIDIA are both good starting points even for non-NVIDIA setups — you can repurpose the request patterns.
Key Takeaways
- Quantization is mandatory, not optional. AWQ INT4 weight-only is the default for 7B+; Q5–Q6 for sub-3B. QLoRA recovers quality when you have a calibration set.
- Unstructured pruning rarely helps on edge hardware. Use structured pruning (heads, layers) only, and always pair with LoRA fine-tuning to recover quality.
- GQA is the single best architecture feature for edge. Pick models with it. Sliding window attention is the runner-up.
- TTFT and ITL are separate metrics. A 30 tok/s average can mean either “smooth chat” or “30-second pause then fast.” Measure them separately.
- Speculative decoding is the cheapest 2× win. Pair a 7B target with a 0.5B–1B draft from the same family.
- Quantize the KV cache. It costs almost nothing in quality and halves a major memory consumer.
- The stack is compression → architecture → runtime. Skip any layer and the next one can’t do its job.
Further Reading
- AWQ: Activation-aware Weight Quantization for LLM Compression and Acceleration — the foundational INT4 weight-only method for edge.
- QLoRA: Efficient Finetuning of Quantized LLMs — the standard recipe for fine-tuning 4-bit base models.
- The llama.cpp quantization documentation — practical quantization types (Q2–Q8) and their tradeoffs.
- Fast Inference from Transformers via Speculative Decoding — the speculative decoding paper, with a clear walkthrough of the algorithm.
- Continuous Batching for LLM Inference (Anyscale blog) — how continuous batching works and why it matters for serving.
- LLM-Pruner: On the Structural Pruning of Large Language Models — the canonical structured pruning approach for LLMs.
- vLLM benchmarks and profiling scripts — production-grade measurement methodology you can repurpose for any backend.