Tutorial 18: End-to-End — Putting It All Together¶
This is the final tutorial. We assemble every component from the previous 17 tutorials into a complete, working inference engine with a CLI and a server. By the end, you should be able to load a real model, generate text, and serve it over HTTP.
1. The Complete Architecture¶
┌──────────────────────────────────────────────────────────┐
│ CLI (cli.py) │
│ run → single-request streaming generation │
│ serve → OpenAI-compatible HTTP server │
│ bench → performance benchmarking │
└──────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ Engine (engine/) │
│ generator.py → stream_generate() for single requests │
│ scheduler.py → BatchedEngine for concurrent requests │
│ sampler.py → temperature / top-p / top-k sampling │
└──────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ Model (core/) │
│ model.py → Qwen3ForCausalLM (dense + MoE) │
│ attention.py → SDPA, GQA, paged attention │
│ rope.py → RoPE │
│ norms.py → RMSNorm │
│ embedding.py → Embedding + tied weights │
│ moe.py → Mixture of Experts │
│ quantize.py → 4-bit quantization │
│ layers.py → linear, silu, softmax │
└──────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ State (cache/) Kernels (ext + kernels.py + Metal) │
│ dense.py rms_norm, rope, swiglu, │
│ paged.py quantized_matmul, paged_attention, │
│ decode_attention │
└──────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ Loading (config.py + weights.py) │
│ config.json → config dataclass │
│ safetensors → WeightStore │
└──────────────────────────────────────────────────────────┘
2. Loading a Real Model¶
from anllm.core.model import load_model, ModelFeatures
# Load a real Qwen3 model (downloaded from HuggingFace, or local)
# Smallest options: Qwen3-0.6B / Qwen3-1.7B / Qwen3-4B (4-bit)
model = load_model(
"mlx-community/Qwen3-0.6B-4bit",
features=ModelFeatures(kernels=False), # use pure Python for now
)
print(f"Loaded model: {model.config.hidden_size} hidden, "
f"{model.num_hidden_layers} layers, "
f"{model.vocab_size} vocab")
What Happens Under the Hood¶
def load_model(model_path, features=None):
from pathlib import Path
# 1. Locate config.json
model_path = Path(model_path)
config_file = (
model_path / "config.json" if model_path.is_dir()
else model_path.parent / "config.json"
)
# 2. Parse config into a dataclass (handles Qwen3-specific fields)
config = Qwen3Config.from_json_file(config_file)
# 3. Load safetensors weights + detect quantization
store = WeightStore.from_path(model_path)
# 4. Construct the model with those weights
return Qwen3ForCausalLM(config, store, features)
3. Running Single-Request Generation (CLI)¶
from anllm.cli import run
# The CLI has three commands:
# anllm run "Tell me a joke" → generate text
# anllm serve → start HTTP server
# anllm bench → benchmark performance
def run_generation():
# Tokenize (in practice, use a tokenizer library like transformers)
prompt = "Once upon a time, "
prompt_tokens = tokenize(prompt) # → [100, 200, ...]
# Configure the sampler
from anllm.engine.sampler import make_sampler
sampler = make_sampler(temp=0.7, top_p=0.9, top_k=50)
# Generate
from anllm.engine.generator import stream_generate
tokens = []
for token in stream_generate(
model,
prompt_tokens,
max_new_tokens=100,
sampler=sampler,
eos_token_ids=[151645, 151643], # <eos>, <endoftext>
):
tokens.append(token)
text = detokenize(tokens)
print(text, end="", flush=True) # print as we generate
What Happens Per Token¶
Step 0 (prefill):
Input: [100, 200, 300, 400, 500] ← full prompt
Output: token 700 ← predicted next token
KV cache now holds 5 tokens
Step 1 (decode):
Input: [700] ← just the new token
Output: token 250
KV cache now holds 6 tokens
Step 2 (decode):
Input: [250]
Output: token 900
KV cache now holds 7 tokens
...
4. Running the Server¶
# From the terminal:
anllm serve --model mlx-community/Qwen3-0.6B-4bit --max-batch 4
# Or in Python:
from aioserver import App
import uvicorn
app = create_server(model, batch_size=4)
# Start the server
uvicorn.run(app, host="0.0.0.0", port=8000)
Test It¶
# Chat completion (non-streaming)
curl http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model": "qwen3", "messages": [{"role": "user", "content": "Hello!"}], "max_tokens": 64}'
# Chat completion (streaming)
curl http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model": "qwen3", "messages": [{"role": "user", "content": "Tell me a story"}], "stream": true}'
# List models
curl http://localhost:8000/v1/models
5. The Full Generation Pipeline¶
Let's trace a complete request through the entire stack:
Client sends: {"messages": [{"role": "user", "content": "What is 2+2?"}]}
│
▼
Validator: ChatCompletionRequest
│
▼
Tokenizer: "What is 2+2?" → [1001, 125, 355, 3, 1050, 525, 30]
│
▼
Scheduler.submit(prompt_tokens, max_new_tokens=64, temp=0.0)
│
▼
Queue → Prefilling (chunked): process 7 tokens
│
▼
Awaiting Slot → Decode Slot (when free)
│
▼
Each step:
Embed → TransformerBlocks(32) → Norm → LM Head
→ logits (1, 151936)
→ argmax (temp=0) → next token
→ append to KV cache
→ yield token
│
▼
Client receives: "It is 4."
Finish reason: "stop" (hit EOS) or "length" (hit max_tokens)
6. Performance Tuning¶
Set 1: Correctness First¶
features = ModelFeatures(
kernels=False, # pure Python — always works
decode_attention_kernel=False,
force_dequantized=False,
)
Set 2: Enable Metal Kernels (if extension is compiled)¶
features = ModelFeatures(
kernels=True, # use native RMSNorm, RoPE, SwiGLU
decode_attention_kernel=True, # specialized decode attention
force_dequantized=False, # keep quantized matmul (fastest)
)
Set 3: Force Dequantization (debugging quantization issues)¶
features = ModelFeatures(
kernels=True,
decode_attention_kernel=False,
force_dequantized=True, # use full-precision weights (memory heavy)
)
Scheduler Tuning¶
from anllm.engine.scheduler import BatchedEngine
engine = BatchedEngine(
model,
batch_size=4, # concurrent decode slots — bigger = more throughput
max_seq_len=512, # max tokens per request (incl. prompt + generated)
prefill_step=128, # chunk size for prefill — smaller = lower latency
)
Balance:
- Larger
batch_size→ more throughput, more memory - Larger
prefill_step→ faster prefill, longer per-step latency - Smaller
prefill_step→ more responsive, more steps
7. Debugging Checklist¶
Wrong output / NaN logits¶
- Check the config:
num_attention_heads % num_key_value_heads == 0 - Check RoPE:
dimsmust be even,basemust match the checkpoint - Check QK-norm: only Qwen3 has it (q_norm, k_norm tensors)
- Check tie_word_embeddings: if weight is missing,
lm_headmay be separate
Out of memory¶
- Lower
batch_sizeormax_seq_len - Enable quantization (4-bit) at load time
- Use paged cache instead of dense
- Reduce
prefill_step
Slow generation¶
- Enable Metal kernels
- Use
logits_to_keep=1— don't compute logits for all positions - Verify batch size is reasonable (each additional slot adds memory)
Wrong results with quantization¶
- Force dequantization:
force_dequantized=True - Verify
group_sizematches the checkpoint config - Check scales/biases are loaded (not just weight)
8. What You Have Built¶
Across these 18 tutorials, you have built:
- Math foundations — linear algebra, softmax, matrix multiplication
- Core layers — embeddings, RMSNorm, RoPE, SiLU, linear
- Attention — SDPA, GQA, causal masking
- Model architecture — transformer blocks, MoE, quantization
- State management — KV cache, paged attention
- Generation — autoregressive loops, sampling
- Concurrency — continuous batching, scheduling
- Performance — custom Metal kernels
- Serving — OpenAI-compatible HTTP API
Each component is a complete, working piece that connects to the others through well-defined interfaces. You can extend it with new models (see ADD.md), new kernels, or new serving features.
9. Next Steps¶
- Add a new model family: Follow ADD.md to add Llama, Mistral, Qwen2.5.
- Write custom Metal kernels: Optimize the hot paths (quantized matmul, paged attention, decode kernel).
- Add speculative decoding: Use a small draft model to predict 4-8 tokens, verify with the large model in one pass.
- Add multi-GPU support: Split layers across MLX's multi-device API.
- Add the tokenizer: Integrate a local tokenizer (e.g., sentencepiece) so the server accepts raw text instead of token IDs.
- Add logprobs to the API: Expose per-token probabilities for downstream applications.
- Add stop sequences: Currently only EOS is checked; multi-string stop support requires more logic.
Happy learning to build systems!