22. Running locally

← LLM Deployment from First PrinciplesModule 2 of 9

Running Models Locally.

Module 1 gave you the two-phase shape of inference; this module is where those abstractions hit physical hardware. The question it answers with byte-level arithmetic: for any (model, , context, hardware) tuple — does it fit, where does each byte live, and what is the bottleneck? You will map the three memory worlds (discrete , Apple , and CPU RAM), pick between llama.cpp, Ollama, vLLM and TensorRT-LLM, read vs , choose a quant off the size/quality curve, and count exactly what sits in RAM and VRAM while a model runs. The through-line from Module 1 holds: is , so — bytes are everything.

LocalQuantizationGGUF

3 worlds

memory architectures

discrete · unified · CPU

~4.8 bpw

Q4_K_M weight cost

the default sweet spot

~3.3×

fp16 vs Q4 weights

~16 GB → ~4.8 GB (8B)

BW ÷ bytes

decode tok/s ceiling

bandwidth-bound

Three memory worlds

You run Linux servers for a living, so the OS is not the interesting axis — the memory architecture and compute API are. Local inference lives in one of three worlds. The production baseline is Linux (or Windows) with an NVIDIA GPU: the stack is → driver → a discrete GPU with its own /GDDR. Two physical memories exist — host RAM (DDR) and device — joined by at ~32 GB/s, roughly 30× slower than VRAM. The rule that follows recurs in every later module: load weights to VRAM once, keep them resident, never stream per-token across PCIe.

The second world is Apple Silicon, which has no separate VRAM. CPU cores, GPU cores and the Neural Engine share one physical pool of LPDDR5 on-package over a very wide bus — . The compute API is Metal (PyTorch exposes it as MPS; llama.cpp has a native Metal backend). Bandwidth is the catch: ~400 GB/s on an M3 Max, ~800 GB/s on an M-Ultra — respectable, but well below an H100's ~3.35 TB/s. A Mac wins on capacity and loses on throughput.

The third is CPU-only fallback. Every engine has a CPU path (llama.cpp shines here with AVX2/AVX-512/NEON and GGUF), and it always works — it is just bandwidth-starved: dual-channel DDR5 is ~50–90 GB/s versus a GPU's 1,000–3,350 GB/s. Since decode is bandwidth-bound, CPU decode is roughly 10–40× slower for the same model. Fine for a small, latency-tolerant model; painful for interactive large ones. Windows+NVIDIA is architecturally identical to Linux+NVIDIA — same CUDA, same discrete split — so the memory math never changes; that is why we treat them as one world.

Win / Linux + NVIDIA

Compute API
CUDA
Memory model
Discrete VRAM + host RAM
Bandwidth
~1.0 TB/s (4090) → ~3.35 TB/s (H100)
Max model size
Capped by VRAM (~8–80 GB)

Best for: Throughput, production

Apple Silicon

Compute API
Metal / MPS
Memory model
Unified (one shared pool)
Bandwidth
~400 GB/s (M3 Max) → ~800 GB/s (M-Ultra)
Max model size
Capped by unified RAM (up to ~512 GB)

Best for: Large models at low volume

CPU-only

Compute API
AVX / NEON
Memory model
Host RAM
Bandwidth
~50–90 GB/s (dual-channel DDR5)
Max model size
Capped by RAM (huge, but slow)

Best for: Fallback / small models

The four tools that matter

Four tools cover essentially all of local and self-hosted inference, and they split cleanly along one axis: single-user local convenience versus multi-user server throughput. llama.cpp is the C/C++ reference implementation (with ggml as its tensor library). It has zero heavy dependencies, runs everywhere (CUDA, Metal, Vulkan, ROCm, pure CPU), and invented both the format and the K-quant scheme. It ships llama-server (OpenAI-compatible) and llama-cli, and it is optimized for low-batch, single-user latency. Ollama is a batteries-included wrapper built on llama.cpp — the "Docker for models": ollama run llama3 pulls a curated GGUF, picks a quant, sets GPU layers and serves an API. It inherits llama.cpp's single-user profile.

vLLM is a Python/CUDA inference server built for throughput and concurrency. Its signature contribution is PagedAttention — a virtual-memory-like allocator that eliminates fragmentation and enables large batches — plus continuous batching (new requests join the running batch every step). It consumes in the common path, is NVIDIA-first, and is the default for self-hosting real traffic (internals are Module 4). TensorRT-LLM is NVIDIA's maximum-performance path: it compiles a model into a hardware-specific TensorRT engine — kernels fused and tuned for one exact GPU ( version), with FP8/INT4 and in-flight batching — for the highest tokens/sec on NVIDIA, at the cost of a build step and much less flexibility (an engine built for H100 will not run on an A100).

The rule of thumb: Mac or laptop → Ollama/llama.cpp. One NVIDIA GPU serving users → vLLM. A fleet of identical GPUs, latency-critical → TensorRT-LLM. The comparison table below sorts these along the axes you actually decide on: what each is built on, its concurrency profile, the formats it eats, and the platforms it supports.

llama.cppggmlLocal, portableCUDA / Metal / Vulkan / ROCm / CPUGGUFLow (single-user)Laptop / Mac / mixed HW; max portability
Ollamallama.cppLocal, easySame as llama.cppGGUFLowYou want zero-config local
vLLMPyTorch / CUDAServer throughputNVIDIA (some AMD)Safetensors (GGUF partial)HighSelf-hosting with real traffic
TensorRT-LLMTensorRT / CUDAMax performanceNVIDIA onlyCompiled engine (from Safetensors)HighFixed model + GPU, squeeze every token
Four tools split along one axis — single-user local convenience (llama.cpp, Ollama) vs multi-user server throughput (vLLM, TensorRT-LLM). High-concurrency rows are highlighted. Click a header to sort.

GGUF vs Safetensors

A model on disk is just a large collection of named tensors plus metadata. The format decides how fast you can load them, whether you can mmap them, and — crucially — whether reading the file can execute code. The cautionary tale is pickle: PyTorch's original .bin/.pt checkpoints are Python pickle streams, and unpickling can execute arbitrary code (__reduce__), so loading a random model off the internet was literally remote code execution. Both modern formats exist to kill pickle.

is Hugging Face's format, now the default for full-precision (fp16/bf16) weights. The layout is dead simple: an 8-byte header length, a JSON header describing each tensor (name, dtype, shape, byte offsets), then raw tensor bytes back-to-back. Two properties matter. It is safe — pure data, parsing cannot execute code — and it is zero-copy: because tensors are contiguous bytes at known offsets, a loader can mmap the file and hand slices straight to the framework (or cudaMemcpy to GPU) with no deserialization pass, so loading is bounded by disk/PCIe bandwidth, not CPU. It is what vLLM and TensorRT-LLM ingest.

is llama.cpp's format, purpose-built for quantized, CPU/GPU-portable inference. Unlike Safetensors it is self-contained: one file holds the tensors plus everything needed to run — architecture, hyperparameters, the tokenizer (vocab + merges), and the chat template — so you need no separate config.json. It is mmap-friendly with lazy fault-in: the engine maps the file and the OS pages tensors in on demand, so the process starts before the whole file is read, two processes mapping the same GGUF share physical pages, and those file-backed pages are reclaimable under pressure. On a GPU run the mapped file is only the staging copy; the resident copy lives in VRAM. Mental model: Safetensors is the interchange format for full-precision weights; GGUF is the deployment format for quantized local inference.

Quantization in the GGUF world (Q2–Q8)

means storing weights in fewer bits than the fp16 they were trained in. Because decode is bandwidth-bound (fewer bytes per weight = fewer bytes to stream per token = faster) and it shrinks the resident footprint, it is the single highest-leverage knob for local inference — Module 1 told you decode re-reads all weights every token, and this attacks that cost directly. fp16 is 16 bits/weight; "Q4" is ~4 bits/weight, a ~4× shrink. But the real numbers are not exactly 4.0, because quantization works in blocks (~32 weights) with a shared scale (and sometimes a zero-point) per block, and those scales cost bits too, so the effective bits-per-weight sits a little above the nominal number.

Decoding a name like Q4_K_M: Q4 is the nominal ~4 bits/weight; _K is the K-quant superblock scheme (sub-blocks with hierarchical scales), higher quality than the legacy Q4_0/Q4_1 round-to-nearest quants; and _S/_M/_L is a Small/Medium/Large mix — sensitive tensors (attention output, feed-forward down-projections, often embeddings) are kept at higher precision while the bulk stays at the nominal level, and _M keeps more of them high than _S. This mixed-precision-by-importance is why K-quants punch above their bit count. What gets quantized is the big weight matrices (Q/K/V/O and MLP up/gate/down — ~99% of parameters); layer-norms and often the embedding/output matrices are kept higher. Activations and the KV cache are a separate runtime story (Module 9).

Quality is usually reported as perplexity increase versus the fp16 baseline. The sortable table below gives approximate, community-consensus figures for a ~7–8B model — order-of-magnitude, not benchmarks. Two rules hold in practice. Q4_K_M is the default — the knee of the curve, ~4× smaller than fp16 with loss most users cannot feel in chat. And a bigger model at a lower quant usually beats a smaller model at a higher quant at equal file size: a 70B at Q2_K (~26 GB) generally out-reasons a 13B at Q8 (~14 GB). Spend your bytes on parameters first, precision second — down to about Q3; below that the degradation gets steep.

fp1616.0~16.0baselinereference
Q8_08.5~8.5~losslessif you have the RAM, no reason not to
Q6_K6.6~6.6near-losslessexcellent
Q5_K_M5.7~5.7very closegreat balance
Q4_K_M4.8~4.9small but real lossthe default sweet spot
Q4_K_S4.6~4.7slightly worse than _Mwhen a hair tighter on RAM
Q3_K_M3.9~4.0noticeable degradationonly if forced
Q2_K3.4~3.0significant degradationemergency / huge models only
Approximate, community-consensus figures for a ~7–8B model (order-of-magnitude, not benchmarks). Q4_K_M — the highlighted default — sits at the knee of the size/quality curve. Sort by bits/weight or size to see the curve.

The load map — what is in RAM vs VRAM

This is the crux of the module: when you "load a model," several distinct things take up memory, and conflating them is the number-one source of "why did I OOM?" confusion. There are four residents. Weights — the parameters, static, sized by , the big one. — per-token, per-layer K and V vectors cached during decode, which grows with context length and batch size (the one people forget). Activations — transient tensors for the current forward pass, small in decode and larger in prefill. And context/compute buffers — CUDA/Metal context, kernel scratch, allocator overhead: a roughly fixed tax of hundreds of MB to ~1–2 GB.

Two formulas do all the work. Weights are . The KV cache is — note , not : with , Llama-3-8B has 32 query heads but only 8 KV heads, a 4× cut in KV size. Worked example — Llama-3-8B as GGUF Q4_K_M at 8k context, batch 1, KV at fp16: weights GB; KV GB; activations ~0.1 GB; buffers ~0.4 GB — total ~6.3 GB, comfortably inside a 24 GB GPU. Once weights are copied to VRAM, the mmap'd file in host RAM is just a reclaimable staging copy; the GPU never touches disk again during generation, and the only thing that grows while the user chats is the KV cache.

The same model at fp16 (the Safetensors/vLLM path) tells the other half of the story: weights GB, plus ~1 GB KV and ~2 GB of CUDA/PyTorch context — ~18–20 GB, leaving only ~5 GB free on a 24 GB card versus ~17 GB free for Q4_K_M. That headroom is your concurrency and context budget, which is exactly why production servers quantize even when the fp16 weights "fit." Toggle the residency diagram below between a discrete GPU and an Apple unified pool, and use the VRAM budget calculator to watch weights + KV + overhead compete for the same scarce memory.

Memory residency — Llama-3-8B

Where every byte lives. Toggle discrete VRAM vs Apple unified memory and watch the four residents compete for capacity.

Platform

Weight precision

✓ Fits — 5.99 GB of 24.0 GB

GPU VRAM

Weights 4.49 GBKV cache 1.00 GBActivations 0.10 GBBuffers 0.40 GBFree 18.0 GB

Host RAM (DDR)

GGUF file, mmap'd — 4.49 GB page cache. A reclaimable staging copy only: read once at load and copied to VRAM over PCIe, then never touched again during generation.

The only resident that grows while the user chats is the KV cache (context × batch). Weights are static; activations and buffers are a roughly fixed tax.

KV-cache size calculator

How the per-token cache — and the total — grows with model, context and batch.

Model

KV dtype

80 layers8 KV headsGQA

Per token

320 KB

Total KV cache (8k × 1)

2.50 GB

At batch 1, this is memory that must live in HBM alongside the weights — often the first thing to run out.

Formula: bytes per token. The factor 2 is one key + one value. Notice a 70B GQA model has a smaller per-token cache than a 7B model with full multi-head attention — GQA is why.
# Back-of-envelope: does a model fit, and how fast will decode run?
# Everything here is bytes and bandwidth -- the two levers of local inference.

GiB = 1024 ** 3

def plan(params_b, bits_per_weight, n_layers, n_kv_heads, head_dim,
         ctx, batch=1, kv_dtype_bytes=2,
         mem_bandwidth_gbs=1000, capacity_gb=24):
    # 1. Weights: the static, dominant resident.
    weight_bytes = params_b * 1e9 * (bits_per_weight / 8)

    # 2. KV cache: grows with context x batch. Note n_kv_heads (GQA), not n_heads.
    kv_bytes = 2 * n_layers * n_kv_heads * head_dim * ctx * batch * kv_dtype_bytes

    # 3. Activations + framework/context buffers: a roughly fixed tax.
    overhead = 0.5 * GiB

    required = weight_bytes + kv_bytes + overhead
    capacity = capacity_gb * GiB

    # 4. Decode is memory-bound: tok/s ceiling ~ bandwidth / bytes read per token.
    tok_s_ceiling = (mem_bandwidth_gbs * 1e9) / weight_bytes

    return {
        "weight_gb": round(weight_bytes / GiB, 2),
        "kv_gb": round(kv_bytes / GiB, 2),
        "required_gb": round(required / GiB, 2),
        "fits": required <= capacity,
        "decode_tok_s_ceiling": round(tok_s_ceiling),
    }

# Llama-3-8B as Q4_K_M on a 24 GB RTX 4090, 8k context:
print(plan(8.03, 4.8, 32, 8, 128, ctx=8192,
           mem_bandwidth_gbs=1000, capacity_gb=24))
# -> {'weight_gb': 4.49, 'kv_gb': 1.0, 'required_gb': 5.99,
#     'fits': True, 'decode_tok_s_ceiling': 209}

Unified memory vs discrete VRAM

Here is the architectural point that surprises people, made precise. On a discrete GPU (say an RTX 4090, 24 GB) the weights must fit in the GPU's 24 GB, or you offload layers to host RAM and pay per token. A fp16 70B is ~140 GB — it simply does not fit — and even a Q4 70B (~40 GB) does not fit in 24 GB. You are capped hard by the number printed on the box.

On a Mac (128 GB) the GPU cores can address the entire pool. A Q4 70B (~40 GB of weights plus KV) fits with room to spare, and the GPU is not reading over PCIe — the LPDDR5 is its memory, so there is no offload and no per-token host round-trip. The tradeoff is bandwidth, and it maps straight onto Module 1's bandwidth-bound decode: a 70B Q4 (~40 GB streamed per token) at ~400 GB/s tops out around ~10 tok/s on an M-series — usable for a single reader, not for serving. A 4090 would do far more tok/s on any model that fits its 24 GB, but cannot hold the 70B at all.

So: Mac = capacity king; NVIDIA = throughput king. Choose by whether your constraint is "can it fit" or "how many tokens/sec and how many users." This is also why the datacenter answer is not "buy a huge unified box" — it is to pool many high-bandwidth GPUs over , getting both capacity and bandwidth (Modules 5 and 6). The VRAM budget calculator lets you sweep model, precision and GPU to see the fit boundary move.

Will it fit? — VRAM budget

Weights + KV cache + workspace vs. the memory you actually have.

Model

Weight precision

GPU

✕ Does not fit — needs 134 GB of 80.0 GB
Weights 130 GBKV 2.50 GBOverhead 1.20 GB

100% of 1× H100

Weights = params × bytes/param (quantization shrinks this). KV cache is kept at FP16 here. Overhead (~1.2 GB) is CUDA context + activation workspace — a rough constant. Real usage varies by engine.

CPU vs GPU — it is the bandwidth, not the FLOPs

The instinct from CPU-land is "GPUs have more cores/FLOPs." True, but for decode that is not why they win. Decode is : each token requires reading every weight once, and the arithmetic per byte read is low (a matrix–vector product), so the ALUs sit idle waiting on memory. Throughput is therefore approximately . Plug in Llama-3-8B Q4_K_M (~4.8 GB read per token) and the ceiling scales almost linearly with bandwidth — that is the whole story, and why every serious deployment is on high-bandwidth HBM. The table below makes the linearity concrete across DDR5, an M3 Max, a 4090 and an H100.

Prefill is the exception: it is compute-bound because it processes many tokens in parallel, so GPUs win there on FLOPs too (Module 1's prefill/decode split). CPU is acceptable for small models (≤3–8B), latency-tolerant or batch workloads, no GPU available, or dev/testing — GGUF + llama.cpp on CPU is genuinely fine for a background 3B doing occasional summarization. For anything interactive or multi-user, use a GPU.

Partial offload (llama.cpp --n-gpu-layers) is the hybrid case: put as many layers as fit in VRAM and the rest in CPU RAM. Every token, the CPU-resident layers are computed on CPU (or their weights streamed over PCIe), so effective throughput is dragged toward the slow tier — offloading even ~20% of layers can halve tok/s. Useful to run a model slightly too big for your VRAM, but the cliff is steep: prefer a smaller quant that fully fits over offloading. The repeatable back-of-envelope — weight_bytes, kv_bytes, overhead, fit?, and a bandwidth-derived speed ceiling — is in the code below.

Dual-channel DDR5 (CPU)~70~14single-digit to low-teens
M3 Max (unified)~400~80tens of tok/s
RTX 4090 (GDDR6X)~1,000~200~100+ tok/s
H100 (HBM3)~3,350~700very high, batched
Theoretical decode ceiling for Llama-3-8B Q4_K_M (~4.8 GB read per token) = bandwidth ÷ 4.8 GB. The ceiling scales almost linearly with bandwidth — that is the whole story of why HBM wins.

Glossary

Every dotted-underlined term on this page links back here. Hover any of them in context, or browse the full definitions below.

Token

A token is a subword unit produced by the tokenizer (typically byte-level BPE). Common words are single tokens; rare words split into several. As a rule of thumb ~1 token ≈ 0.75 English words. The model only ever sees token IDs, never raw text.

Tokenizer

The tokenizer converts a UTF-8 string into a list of integer IDs and back. Byte-level BPE guarantees any input is representable (no out-of-vocabulary) by starting from raw bytes and greedily merging frequent pairs. Vocabulary sizes are typically 32k–256k.

Embedding

The embedding matrix has shape [vocab_size, d_model]. Converting a token ID to its vector is a pure row lookup — no matrix multiply. The same matrix is often tied to the output projection that produces logits.

d_model (hidden size)

d_model is the size of the hidden state carried through the network (e.g. 4096 for Llama-2-7B). Most weight matrices are O(d_model²), so it dominates parameter count and per-token compute.

Attention

Scaled dot-product attention computes softmax(QKᵀ/√d_k)·V. Each query token scores every key, normalizes with softmax, and gathers a weighted sum of value vectors. In decoders it is causal: a token may only attend to earlier positions.

KV cache

During generation, the K and V projections of every previous token are cached in HBM so they are not recomputed. Size grows linearly with sequence length and batch: 2 · n_layers · n_kv_heads · head_dim · dtype_bytes per token. It is the dominant runtime memory consumer and a key reason decode is memory-bound.

Prefill

Prefill runs one forward pass over all N prompt tokens at once. Because it is a big matrix–matrix multiply, it is compute-bound and saturates the GPU's Tensor Cores. Its latency determines time-to-first-token.

Decode

Decode produces tokens one by one; each step reads all model weights plus the KV cache from HBM to compute a single new token. It is a matrix–vector operation with arithmetic intensity ~1 FLOP/byte — deeply memory-bandwidth-bound.

Autoregressive generation

The model outputs a probability distribution over the next token, one is sampled, appended, and fed back in. Token t+1 cannot be computed until token t exists, which is why decode cannot be parallelized across time within a single sequence.

Logits

The final hidden state is projected to a vector of size vocab_size. These logits are turned into probabilities by softmax (after temperature scaling and top-k/top-p filtering) and then sampled.

Sampling

Greedy takes the argmax. Temperature scales logits before softmax (higher = more random). Top-k keeps the k highest-probability tokens; top-p (nucleus) keeps the smallest set whose cumulative probability exceeds p. These trade determinism for diversity.

Grouped-query attention (GQA)

GQA sits between multi-head attention (one KV head per query head) and multi-query attention (one KV head total). By using e.g. 8 KV heads for 64 query heads, it cuts KV-cache size and bandwidth several-fold with minimal quality loss — which is why modern models use it.

Memory-bound

A kernel is memory-bound when its arithmetic intensity (FLOPs per byte moved) is below the hardware's ridge point. Single-stream LLM decode moves ~1–2 FLOPs per byte against a GPU that wants a few hundred, so it uses only single-digit percentages of peak compute.

Arithmetic intensity

Defined as total FLOPs ÷ bytes read/written to HBM. Compared against a GPU's ridge point (peak FLOP/s ÷ peak bytes/s), it predicts whether a kernel is limited by math or memory. Batching raises effective intensity by reusing weights across many sequences.

HBM

HBM2e/HBM3/HBM3e are 3D-stacked DRAM dies giving enormous bandwidth (H100 HBM3 ≈ 3.35 TB/s) at capacities of 24–192 GB. Because decode is memory-bound, HBM bandwidth — not FLOPs — is usually the ceiling on tokens/sec.

VRAM

On discrete GPUs, VRAM is separate HBM/GDDR the CPU cannot directly address. A model runs only if weights + KV cache + activation/workspace buffers all fit. On Apple Silicon there is no separate VRAM — see unified memory.

Quantization

Reducing precision from FP16 (2 bytes) to INT8/INT4 (1 byte / 0.5 byte) shrinks the model and, because decode is memory-bound, speeds it up roughly proportionally. Methods like GPTQ and AWQ minimize the resulting accuracy loss.

GGUF

GGUF packs the tensors, tokenizer and hyperparameters into one file designed to be mmap'd, so the OS pages weights in on demand. It is the standard format for CPU/Apple-Silicon inference and for the Q2–Q8 k-quant schemes.

Safetensors

Safetensors stores a JSON header of tensor offsets followed by raw bytes, enabling zero-copy memory-mapped loading with no arbitrary code execution (unlike pickle). It is the default for Hugging Face FP16/BF16 checkpoints served by vLLM and TensorRT-LLM.

Unified memory

On Apple Silicon (and Grace-Hopper style parts) CPU and GPU address the same RAM, so there is no PCIe copy and the whole capacity can hold model weights. A 128 GB Mac can therefore run models a 24 GB discrete GPU cannot — but at the memory's lower bandwidth.

CUDA

CUDA exposes the GPU as a grid of thread blocks running the same kernel (SIMT). Threads are grouped into 32-wide warps scheduled on streaming multiprocessors. The mature CUDA ecosystem (cuBLAS, cuDNN, NCCL) is a major reason NVIDIA dominates inference.

Streaming Multiprocessor (SM)

A datacenter GPU has ~100+ SMs, each with warp schedulers, a large register file, L1/shared memory, CUDA cores and Tensor Cores. Thread blocks are assigned to SMs; occupancy (active warps per SM) hides memory latency.

Tensor Core

Tensor Cores perform fused matrix multiply-accumulate on small tiles in one operation, delivering the bulk of a GPU's FLOPs for FP16/BF16/FP8/INT8. They are why modern GPUs are orders of magnitude faster at matmuls than CUDA cores alone.

PCIe

PCIe Gen5 x16 offers ~64 GB/s per direction — roughly an order of magnitude below NVLink. It is fine for feeding data to a single GPU but a bottleneck for tensor parallelism, which is why NVLink exists.

MIG (Multi-Instance GPU)

MIG splits an A100/H100 into up to seven instances with dedicated SMs, cache and memory bandwidth, giving hard isolation for multi-tenancy or for serving small models that would otherwise waste a whole GPU.

TTFT (time to first token)

Time-to-first-token measures responsiveness: how long the user waits before text starts streaming. It is set by queueing + prefill time and scales with prompt length. Distinct from per-token speed (TPOT).

TPOT / ITL

TPOT is the average time between successive generated tokens during decode. Because decode is memory-bound, TPOT is roughly bytes-moved-per-token ÷ HBM bandwidth. Its reciprocal is per-stream tokens/sec.

Throughput vs latency

Batching many requests raises aggregate throughput (and GPU utilization) by reusing weight reads, but can raise individual latency. Serving is the art of maximizing throughput within a latency SLA (TTFT/TPOT targets).

FAQ

Because Apple Silicon uses unified memory: the GPU cores can address the entire RAM pool, so a Q4 70B (~40 GB of weights) fits with room to spare and is read in place — no PCIe copy. A discrete GPU must hold the weights in its own VRAM; 40 GB simply does not fit in 24 GB, and offloading the overflow to host RAM pays a steep PCIe-per-token tax. The catch is bandwidth: the Mac runs it at ~10 tok/s (reading speed), while the 4090 would be far faster on anything that fits its 24 GB.

Q4_K_M is the default — the knee of the size/quality curve, ~4x smaller than fp16 with loss most people cannot feel in chat. Go higher (Q5_K_M, Q6_K, Q8_0) if you have the RAM and want to close the gap; go lower (Q3, Q2) only under memory pressure, and expect noticeable degradation below Q3. A useful rule: at equal file size, a bigger model at a lower quant usually beats a smaller model at a higher quant, down to about Q3.

Safetensors is the interchange format for full-precision (fp16/bf16) weights and is what vLLM and TensorRT-LLM ingest. GGUF is the deployment format for quantized, portable, local inference — it is self-contained (weights + tokenizer + chat template in one file) and is what llama.cpp and Ollama consume. Both are safe (no code execution, unlike pickle) and both support mmap/zero-copy loading. Rule of thumb: serving full precision on NVIDIA → Safetensors; running quantized locally or on a Mac → GGUF.

Almost always the KV cache. Weights are static, but the KV cache grows with context length x batch size and lives in the same VRAM. If nvidia-smi shows more used than your weight bytes, the difference is KV plus buffers; if it OOMs at high context, the KV cache is the culprit. Fixes: lower the context length (-c), quantize the KV cache (-ctk q8_0), reduce batch, or pick a smaller weight quant to free room.

Yes, within limits. Decode is bandwidth-bound, and CPU DDR5 (~50–90 GB/s) is 10–40x slower than GPU memory, so CPU decode is slow. But it always works and is genuinely fine for small models (<=3-8B) doing occasional, latency-tolerant work — a background 3B summarizer, dev/testing, or a machine with no GPU. For anything interactive or multi-user, use a GPU. Prefill is less of a problem since it is compute-bound.

Prefer a smaller quant that fully fits. Partial offload (llama.cpp --n-gpu-layers) keeps some layers in CPU RAM, and every token those layers run on the slow tier (or stream over PCIe), so throughput is dragged toward CPU speed — offloading even ~20% of layers can halve tok/s. The cliff is steep. Offload only when no quant that fits is acceptable; otherwise drop from, say, Q5_K_M to Q4_K_M and stay fully resident.

References

The works the paper builds on — every entry links to the cited paper.

  1. [1]

    Vaswani et al. (2017). Attention Is All You Need. NeurIPS — the Transformer architecture

  2. [2]

    Kwon et al. (2023). Efficient Memory Management for LLM Serving with PagedAttention (vLLM). SOSP

  3. [3]

    Dao et al. (2022). FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness. NeurIPS

  4. [4]

    Ainslie et al. (2023). GQA: Training Generalized Multi-Query Transformer Models. EMNLP

  5. [5]

    Frantar et al. (2022). GPTQ: Accurate Post-Training Quantization for Generative Pretrained Transformers. ICLR 2023

  6. [6]

    Lin et al. (2023). AWQ: Activation-aware Weight Quantization for LLM Compression. MLSys 2024

  7. [7]

    NVIDIA (2022). NVIDIA H100 Tensor Core GPU Architecture (Hopper Whitepaper). NVIDIA technical whitepaper

  8. [8]

    Gerganov et al. (2023). llama.cpp — LLM inference in C/C++ (GGUF). GitHub

  9. [9]

    Yu et al. (2022). Orca: A Distributed Serving System for Transformer-Based Generative Models. OSDI — iteration-level (continuous) batching