What makes a model runnable
A runnable model needs five pieces, and each format stores them differently.
| Piece | Hugging Face repo | GGUF |
|---|---|---|
| Weights | model-0000x-of-0000N.safetensors + model.safetensors.index.json |
tensor data section |
| Architecture | config.json (layers, heads, rope, vocab…) |
<arch>.* metadata keys |
| Tokenizer | tokenizer.json / tokenizer.model |
tokenizer.ggml.* keys |
| Chat template | tokenizer_config.json → chat_template (Jinja) |
tokenizer.chat_template |
| Generation defaults | generation_config.json |
runtime parameters (Ollama Modelfile PARAMETER) |
- GGUF (llama.cpp, Ollama, LM Studio): one self-describing file that the OS can memory-map. It stores the quantized types (Q4_K, Q8_0, IQ…, MXFP4) natively.
- GGUF layout: the magic
GGUF, a version, the tensor and metadata counts, the metadata, the tensor infos (name, shape, type, offset), padding, then the data. - Safetensors (Hugging Face, PyTorch, vLLM, SGLang, TensorRT-LLM checkpoints): an 8-byte header length, a JSON header (dtype, shape, byte offsets), then raw bytes. It is safe because loading it cannot run code, unlike pickle
.binfiles. - ONNX (ONNX Runtime, Windows ML): an operator graph plus weights. Execution providers (CUDA, TensorRT, TensorRT for RTX, DirectML) make it portable across vendors.
- TensorRT engine (TensorRT, TensorRT-LLM): a graph compiled for one GPU architecture and TensorRT version. It is the fastest format, but you rebuild it for each target.
- TensorRT for RTX builds the engine on the device, just in time. An app then ships one portable file to the RTX installed base.
- MLX (Apple silicon): safetensors with MLX-specific quantization.
Lab: open a real GGUF
Ollama stores every model as a GGUF blob, and the header holds everything a runtime needs. The lab reads the header of the blob on this machine and skips the weights.
Lab · runs on your own machine
uv run labs/06_gguf.pyDownload all labs (.zip)Needs uv and, for the live parts, Ollama with a small chat model: ollama pull qwen3:0.6b.
Show the code
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
import _local as local
from collections import Counter
name = (local.chat_models() or local.embed_models() or [None])[0]
if name:
path = local.blob_path(name)
g = local.read_gguf(path)
print(f"{name} → {path}")
print(f"GGUF v{g['version']} · {len(g['tensors'])} tensors · {len(g['metadata'])} metadata keys · header {g['header_bytes']/1024:.0f} KiB of {g['file_bytes']/1e9:.2f} GB")
for key, value in g["metadata"].items():
if key.startswith("tokenizer.ggml.") and isinstance(value, dict):
value = f"array of {value['array_of']:,}: {value['first'][:4]}…"
print(f" {key:45} {str(value)[:90]}")
print("tensor types:", dict(Counter(t["type"] for t in g["tensors"])))
for t in g["tensors"][:6]:
print(f" {t['name']:32} {str(t['shape']):>16} {t['type']:>6} @ {t['offset']:,}")
model = local.need_chat_model()
if model:
print(local.show(model).get("template", "")[:1200])
Sample output on this course's Mac (Apple M4, 32 GB)
qwen3:0.6b → /Users/mpl/.ollama/models/blobs/sha256-7f4030143c1c477224c5434f8272c662a8b042079a0a584f0a27a1684fe2e1fa
GGUF v3 · 311 tensors · 28 metadata keys · header 5812 KiB of 0.52 GB
general.architecture qwen3
general.type model
general.name Qwen3 0.6B
general.basename Qwen3
general.size_label 0.6B
general.license apache-2.0
qwen3.block_count 28
qwen3.context_length 40960
qwen3.embedding_length 1024
qwen3.feed_forward_length 3072
qwen3.attention.head_count 16
qwen3.attention.head_count_kv 8
qwen3.rope.freq_base 1000000.0
qwen3.attention.layer_norm_rms_epsilon 9.999999974752427e-07
qwen3.attention.key_length 128
qwen3.attention.value_length 128
tokenizer.ggml.model gpt2
tokenizer.ggml.pre qwen2
tokenizer.ggml.tokens array of 151,936: ['!', '"', '#', '$']…
tokenizer.ggml.token_type array of 151,936: [1, 1, 1, 1]…
tokenizer.ggml.merges array of 151,387: ['Ġ Ġ', 'ĠĠ ĠĠ', 'i n', 'Ġ t']…
tokenizer.ggml.eos_token_id 151645
tokenizer.ggml.padding_token_id 151643
tokenizer.ggml.bos_token_id 151643
tokenizer.ggml.add_bos_token False
tokenizer.chat_template {%- if tools %}
{{- '<|im_start|>system\n' }}
{%- if messages[0].role == 'system'
general.quantization_version 2
general.file_type 15
tensor types: {'Q6_K': 15, 'F32': 113, 'Q4_K': 155, 'F16': 28}
output.weight [1024, 151936] Q6_K @ 0
output_norm.weight [1024] F32 @ 127,626,240
token_embd.weight [1024, 151936] Q4_K @ 127,630,336
blk.0.attn_k.weight [1024, 1024] Q4_K @ 215,145,472
blk.0.attn_k_norm.weight [128] F32 @ 215,735,296
blk.0.attn_norm.weight [1024] F32 @ 215,735,808
{{- $lastUserIdx := -1 -}}
{{- range $idx, $msg := .Messages -}}
{{- if eq $msg.Role "user" }}{{ $lastUserIdx = $idx }}{{ end -}}
{{- end }}
{{- if or .System .Tools }}<|im_start|>system
{{ if .System }}
{{ .System }}
{{- end }}
{{- if .Tools }}
# Tools
You may call one or more functions to assist with the user query.
You are provided with function signatures within <tools></tools> XML tags:
<tools>
{{- range .Tools }}
{"type": "function", "function": {{ .Function }}}
{{- end }}
</tools>
For each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:
<tool_call>
{"name": <function-name>, "arguments": <args-json-object>}
</tool_call>
{{- end -}}
<|im_end|>
{{ end }}
{{- range $i, $_ := .Messages }}
{{- $last := eq (len (slice $.Messages $i)) 1 -}}
{{- if eq .Role "user" }}<|im_start|>user
{{ .Content }}
{{- if and $.IsThinkSet (eq $i $lastUserIdx) }}
{{- if $.Think -}}
{{- " "}}/think
{{- else -}}
{{- " "}}/no_think
{{- end -}}
{{- end }}<|im_end|>
{{ else if eq .Role "assistant" }}<|im_start|>assistant
{{ if (and $.IsThinkSet (and .Thinking (or $last (gt $i $lastUserIdx)))) -}}
<think>{{ .Thinking }}</What to notice: the tokenizer vocabulary, the special-token IDs and the architecture settings all live in the file. The header is a few hundred KB. The OS pages in the weights on demand, so the second load takes milliseconds: the pages are still in the file cache.
Lab: safetensors from scratch
A safetensors file is an 8-byte length, a JSON header and raw bytes. The script builds one and reads it back.
import json, struct, torch
from safetensors.torch import save_file
tensors = {"layer0.weight": torch.randn(256, 128, dtype=torch.float16), "layer0.bias": torch.zeros(256, dtype=torch.float16)}
save_file(tensors, "tiny.safetensors", metadata={"format": "pt", "course": "local-ai-builder"})
raw = open("tiny.safetensors", "rb").read()
header_len = struct.unpack("<Q", raw[:8])[0]
print("header length:", header_len, "bytes")
print(json.dumps(json.loads(raw[8:8 + header_len]), indent=1))
print("data bytes:", len(raw) - 8 - header_len, "= 256·128·2 + 256·2 =", 256 * 128 * 2 + 256 * 2)
Chat templates: the silent integration bug
The wrong chat template gives rambling output, broken tool calls or ignored system prompts, even with the right weights. Always check the template that the runtime applies.
Conversion paths
Two conversion paths cover nearly every local runtime.
# Hugging Face → GGUF (llama.cpp tools)
python convert_hf_to_gguf.py ./Qwen3-8B --outtype bf16 --outfile qwen3-8b-bf16.gguf
llama-quantize qwen3-8b-bf16.gguf qwen3-8b-Q4_K_M.gguf Q4_K_M # add --imatrix for low-bit quants
ollama create my-qwen -f Modelfile # FROM ./qwen3-8b-Q4_K_M.gguf
# Hugging Face → NVIDIA-optimized
# TensorRT Model Optimizer quantizes (FP8, NVFP4, INT4 AWQ) → TensorRT-LLM builds or loads → serve
To cover every local runtime on day one, publish BF16 safetensors, an official GGUF set (Q8_0, Q6_K, Q4_K_M) and an FP8 or NVFP4 checkpoint.
Explain it
- "Why did the ecosystem move from
.binto safetensors?" Pickle can run code on load. Safetensors is pure data, and it loads faster (zero-copy, mmap). - "A model works in Transformers but outputs garbage in Ollama. First suspects?" Check the chat template, the stop tokens, the tokenizer conversion, and the rope or context settings. Check the quantization last.
- "What is the trade-off with TensorRT engines?" Compiling for the exact GPU gives peak speed but costs portability. TensorRT for RTX and Windows ML reduce that cost.
Exercises
- Extend
read_ggufto compute each tensor's byte size, and check that the sizes add up to the file. - Compare the metadata of two quantizations of one model: what changes besides the tensor types?